PageSourceSearch

https://streamsnip.com/

html streamsnip.com collected 2026-09-24 14:53:13 UTC 252,102 bytes, 5,753 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3
4<head>
5  
5<script>
6    // View Transitions reject .ready/.finished when a transition is interrupted (fast
7    // navigation, tab hidden, viewport resized). These are expected and benign, but show
8    // up as noisy unhandled rejections (AbortError / InvalidStateError) in error tracking.
9    // Swallow only those, tightly scoped to transition-related DOMExceptions.
10    window.addEventListener('unhandledrejection', function (e) {
11      var r = e.reason;
12      if (r && (r.name === 'AbortError' || r.name === 'InvalidStateError')
13            && typeof r.message === 'string' && /transition/i.test(r.message)) {
14        e.preventDefault();
15      }
16    });
17
18    // Direction-aware cross-document page transitions. Tag <html> so CSS slides content
19    // in from the right on forward navigations and from the left when going back/forward
20    // through history. Registered before first paint so it catches the pagereveal event.
21    window.addEventListener('pagereveal', function (e) {
22      if (!e.viewTransition) return;
23      var dir = 'forward';
24      try {
25        var act = window.navigation && window.navigation.activation;
26        if (act) {
27          if (act.navigationType === 'reload') { dir = null; }
28          else if (act.navigationType === 'traverse' && act.from && act.entry) {
29            dir = act.entry.index < act.from.index ? 'back' : 'forward';
30          }
31        }
32      } catch (_) {}
33      var root = document.documentElement;
34      root.classList.remove('vt-forward', 'vt-back');
35      if (dir) root.classList.add(dir === 'back' ? 'vt-back' : 'vt-forward');
36      e.viewTransition.finished.finally(function () {
37        root.classList.remove('vt-forward', 'vt-back');
38      });
39    });
40  </script>
40
41  
41<script>
42    // Set theme synchronously before first paint to avoid FOUC. The class is mirrored onto <body>
43    // by an inline script at the top of <body> so `body.dark-theme` selectors still match.
44    (function(){var d=localStorage.getItem('darkTheme');var dark=d?d==='true':window.matchMedia('(prefers-color-scheme: dark)').matches;if(dark)document.documentElement.classList.add('dark-theme');})();
45    // Admin palette preview — re-apply the accent chosen via the navbar palette dropdown, before first paint.
46    (function(){try{var p=JSON.parse(localStorage.getItem('ss_palette')||'null');if(p&&p.h){var s=document.documentElement.style;s.setProperty('--accent-h',p.h);s.setProperty('--accent-s',p.s);s.setProperty('--accent-l',p.l);if(p.rgb)s.setProperty('--color-primary-rgb',p.rgb);}}catch(e){}})();
47  </script>
47
48  
48<script>
49    (function () {
50      function isWeakGPU(renderer) {
51        if (!renderer || renderer === 'Unknown') return false;
52        var r = renderer.toLowerCase();
53        // Intel integrated
54        if (r.indexOf('intel') !== -1 && (
55          r.indexOf('hd graphics') !== -1 ||
56          r.indexOf('uhd graphics') !== -1 ||
57          r.indexOf('iris') !== -1
58        )) return true;
59        // AMD APU integrated (no discrete RX/Pro)
60        if (r.indexOf('amd') !== -1 && r.indexOf('radeon') !== -1 &&
61          (r.indexOf('vega') !== -1 || r.indexOf('graphics') !== -1) &&
62          r.indexOf(' rx ') === -1 && r.indexOf(' pro ') === -1) return true;
63        // Software renderers
64        if (r.indexOf('microsoft basic render') !== -1 ||
65          r.indexOf('swiftshader') !== -1 ||
66          r.indexOf('llvmpipe') !== -1) return true;
67        return false;
68      }
69      try {
70        var c = document.createElement('canvas');
71        var gl = c.getContext('webgl2') || c.getContext('webgl') || c.getContext('experimental-webgl');
72        if (!gl) {
73          document.documentElement.classList.add('no-gpu');
74        } else {
75          var dbg = gl.getExtension('WEBGL_debug_renderer_info');
76          var renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : '';
77          if (isWeakGPU(renderer)) document.documentElement.classList.add('no-gpu');
78        }
79      } catch (e) { document.documentElement.classList.add('no-gpu'); }
80    })();
81  </script>
81
82  <!-- ========= SENTRY ========= -->
83  
83<script src="https://browser.sentry-cdn.com/9.18.0/bundle.tracing.replay.feedback.min.js"
84    integrity="sha384-AjEie096M0si7Z7evQkKpGRBFXtrRj0v7pb1FTQau13fhSYTdtEgfW85kTIrWdBe"
85    crossorigin="anonymous"></script>
85
86  
86<script>
87    // Guard against the CDN script being blocked (ad blockers, offline, integrity failure)
88    if (typeof Sentry !== 'undefined') {
89      Sentry.init({
90        dsn: "https://05d1a950ad00f7142bea03c3e956de46@o4509799317897216.ingest.us.sentry.io/4509799330152448",
91        integrations: [
92          Sentry.browserTracingIntegration(),
93          Sentry.replayIntegration({
94            maskAllText: false,
95            blockAllMedia: false,
96          }),
97          Sentry.feedbackIntegration({
98            colorScheme: "dark",
99            // Don't inject the floating widget; attach to the navbar button instead
100            // so it can never overlap the download-queue panel.
101            autoInject: false,
102          }),
103        ],
104        // Anchored to our own origin so the trace header is never attached to
105        // third-party requests (e.g. Beamer) whose URLs merely contain our
106        // domain as a query param — that triggers a CORS preflight rejection.
107        tracePropagationTargets: [/^https:\/\/(www\.)?streamsnip\.com/, /^\//],
108        // Sampled down from 1.0/0.1/1.0: replay's DOM recorder and tracing ran for
109        // every visitor (error replay buffers even on unsampled sessions), adding
110        // measurable main-thread overhead. ~90% of sessions now record nothing.
111        tracesSampleRate: 0.1,
112        replaysSessionSampleRate: 0.01,
113        replaysOnErrorSampleRate: 0.1,
114        // Crashes thrown entirely inside a visitor's browser extension are not our
115        // bugs and we can't fix them — keep them out of the issue stream.
116        denyUrls: [/^chrome-extension:\/\//i, /^moz-extension:\/\//i, /^safari-web-extension:\/\//i],
117        beforeSend(event, hint) {
118          // View Transitions reject .ready/.finished when interrupted (fast nav, tab hidden,
119          // viewport resized), surfacing as benign AbortError/InvalidStateError unhandled
120          // rejections. Sentry's own unhandledrejection handler captures these regardless of
121          // the global preventDefault() handler, so they have to be dropped here at the SDK level.
122          var ex = hint && hint.originalException;
123          if (ex && (ex.name === 'AbortError' || ex.name === 'InvalidStateError')
124                && typeof ex.message === 'string' && /transition/i.test(ex.message)) {
125            return null;
126          }
127          
128          return event;
129        },
130      });
131      // The trigger lives in the navbar (rendered later in <body>), so wait for the DOM.
132      document.addEventListener('DOMContentLoaded', function () {
133        const btn = document.getElementById('report-bug-btn');
134        if (btn) Sentry.getFeedback()?.attachTo(btn, { formTitle: "Report a bug" });
135      });
136    }
137  </script>
137
138
139  <!-- ========= GOOGLE ANALYTICS (GA4) ========= -->
140  
141  <!-- No SRI: Google serves gtag.js dynamically per id and updates it often, so a pinned hash would break it. -->
vendor: 66 bytes, lines 141-142
141
142  <script async src="https://www.googletagmanager.com/gtag/js?id=
142G-1W58W85E8M
vendor: 14 bytes, lines 142-143
142"></script>
143  
143<script>
144    
vendor: 139 bytes, lines 144-147
144window.dataLayer = window.dataLayer || [];
145    function gtag(){dataLayer.push(arguments);}
146    gtag('js', new Date());
147    gtag('config', "
147G-1W58W85E8M
vendor: 6 bytes, lines 147-148
147");
148  
148</script>
148
149  
150
151  <!-- ========= META & SEO ========= -->
152  <meta charset="utf-8" />
153  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
154  
155  <title>StreamSnip - Simplified YouTube Clipping & Discovery</title>
156  <meta name="description"
157    content="Discover and capture the best moments from YouTube livestreams. StreamSnip makes clipping easy for creators and fans." />
158  <meta name="keywords"
159    content="Livestream, YouTube, StreamSnip, !clip, Nightbot, Nightbot-clip, YouTube Clipping, Stream Moments" />
160  <meta name="author" content="Suraj Bhari" />
161  <meta name="copyright" content="© 2026 Suraj Bhari. All rights reserved." />
162  
163  <meta name="robots"
164    content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1, noai, noimageai" />
165  <link rel="canonical" href="https://streamsnip.com/" />
166  <link rel="manifest" href="/static/manifest.json" />
167  <meta name="theme-color" content="#0080ff" />
168  <meta name="mobile-web-app-capable" content="yes" />
169  <meta name="apple-mobile-web-app-capable" content="yes" />
170  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
171  <meta name="apple-mobile-web-app-title" content="StreamSnip" />
172
173  <!-- Open Graph / Facebook -->
174  <meta property="og:type" content="website" />
175  <meta property="og:url" content="https://streamsnip.com/" />
176  <meta property="og:title" content="StreamSnip - Simplified YouTube Clipping & Discovery" />
177  <meta property="og:description" content="Discover and capture the best moments from YouTube livestreams. StreamSnip makes clipping easy for creators and fans." />
178  <meta property="og:image"
179    content="https://streamsnip.com/static/logo.png?v=7142fd7189" />
180  <meta property="og:image:alt" content="StreamSnip - Simplified YouTube Clipping & Discovery" />
181  <meta property="og:site_name" content="StreamSnip" />
182  <meta property="og:locale" content="en_US" />
183
184  <!-- Twitter -->
185  <meta name="twitter:card" content="summary_large_image" />
186  <meta name="twitter:url" content="https://streamsnip.com/" />
187  <meta name="twitter:title" content="StreamSnip - Simplified YouTube Clipping & Discovery" />
188  <meta name="twitter:description" content="Discover and capture the best moments from YouTube livestreams. StreamSnip makes clipping easy for creators and fans." />
189  <meta name="twitter:image" content="https://streamsnip.com/static/logo.png?v=7142fd7189" />
190  <meta name="twitter:image:alt" content="StreamSnip - Simplified YouTube Clipping & Discovery" />
191
192  <!-- Favicon -->
193  <link rel="icon" type="image/png" href="/static/logo.png?v=7142fd7189" />
194  <link rel="apple-touch-icon" href="/static/logo.png?v=7142fd7189" />
195
196  <!-- ========= STYLES & CRITICAL ASSETS PRELOAD ========= -->
197  <link rel="dns-prefetch" href="https://fonts.googleapis.com" />
198  <link rel="dns-prefetch" href="https://fonts.gstatic.com" />
199  <link rel="dns-prefetch" href="https://use.fontawesome.com" />
200  <link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
201
202  <link rel="preconnect" href="https://fonts.googleapis.com" />
203  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
204  <link rel="preconnect" href="https://use.fontawesome.com" crossorigin />
205  <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
206
207  <link rel="preload" as="style" href="/static/style.css?v=2632f62682" />
208  <link rel="preload" as="style"
209    href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Poppins:wght@400;600&family=Rajdhani:wght@600;700&display=swap" />
210  <link rel="preload" as="image" href="/static/logo-grey.svg" />
211  <link rel="preload" as="image" href="/static/logo.svg" />
212
213  <!-- FontAwesome is icons only (not layout) — load it off the render-blocking path so the
214       page content paints without waiting on this CDN request. Falls back to a blocking
215       load with JS disabled. -->
216  <link rel="preload" as="style" href="https://use.fontawesome.com/releases/v6.5.1/css/all.css"
217    onload="this.onload=null;this.rel='stylesheet'" />
218  <noscript>
219    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v6.5.1/css/all.css" />
220  </noscript>
221  <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
222    integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous" />
223  <link
224    href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;
224800&family=Poppins:wght@400;600&family=Rajdhani:wght@600;700&display=swap"
225    rel="stylesheet" />
226
227  <link rel="stylesheet" href="/static/style.css?v=2632f62682" />
228
229  <style>
230    body {
231      -webkit-font-smoothing: antialiased;
232      -moz-osx-font-smoothing: grayscale;
233    }
234
235    .btn:active,
236    .nav-link:active {
237      transform: scale(0.96) !important;
238      transition: transform 0.1s cubic-bezier(0.4, 0, 0.2, 1) !important;
239    }
240
241    h1,
242    h2,
243    h3,
244    h4,
245    h5,
246    h6 {
247      text-wrap: balance;
248    }
249
250    p {
251      text-wrap: pretty;
252    }
253
254    .profile-frame img {
255      box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
256    }
257
258    .dark-theme .profile-frame img {
259      box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
260    }
261
262    .site_icon,
263    #site_icon,
264    .site-icon,
265    .navbar-brand,
266    .navbar-brand img,
267    .navbar-brand:focus,
268    .navbar-brand:active {
269      box-shadow: none !important;
270      outline: none !important;
271      border: none !important;
272    }
273
274    /* Navbar clapper logo */
275    #_nbl_stick, #_nbl_clapper { fill: #444444; }
276    body.dark-theme #_nbl_stick, body.dark-theme #_nbl_clapper { fill: #fffcfc; }
277    #_nbl_svg { transform: rotate(17deg); transform-origin: center; }
278    @keyframes _nbl_impact {
279      0%   { transform: rotate(17deg) scale(1)    translateX(0px); }
280      51%  { transform: rotate(17deg) scale(1.07) translateX(0px); animation-timing-function: cubic-bezier(0.36,0.07,0.19,0.97); }
281      53%  { transform: rotate(17deg) scale(1.05) translateX(-5px); }
282      55%  { transform: rotate(17deg) scale(1.04) translateX(5px); }
283      57%  { transform: rotate(17deg) scale(1.02) translateX(-3px); }
284      59%  { transform: rotate(17deg) scale(1.01) translateX(2px); }
285      63%  { transform: rotate(17deg) scale(1)    translateX(0px); }
286      100% { transform: rotate(17deg) scale(1)    translateX(0px); }
287    }
288    #_nbl_svg._nbl_play { animation: _nbl_impact 2.6s linear 1 forwards; }
289
290    /* ===== Clapper loading animation: a looping version of the navbar logo's clap ===== */
291    /* Faithful loop of the navbar clap: ease-out open → HOLD open → snap shut →
292       bounce → hold closed → settle to rest → brief pause, then repeat. */
293    @keyframes ss-clap {
294      0%   { transform: rotate(-17deg); animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
295      16%  { transform: rotate(-57deg); animation-timing-function: linear; }
296      22%  { transform: rotate(-57deg); animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); }
297      40%  { transform: rotate(0deg);   animation-timing-function: cubic-bezier(0, 0, 0.3, 1); }
298      45%  { transform: rotate(-8deg);  animation-timing-function: cubic-bezier(0.25, 1, 0.5, 1); }
299      51%  { transform: rotate(0deg);   animation-timing-function: linear; }
300      62%  { transform: rotate(0deg);   animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
301      78%  { transform: rotate(-17deg); }
302      100% { transform: rotate(-17deg); }
303    }
304    /* Whole-board recoil: scale punch + damped horizontal shake, synced to the snap (40%). */
305    @keyframes ss-clap-impact {
306      0%    { transform: rotate(17deg) scale(1)    translateX(0); }
307      40%   { transform: rotate(17deg) scale(1.07) translateX(0);    animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
308      41.5% { transform: rotate(17deg) scale(1.05) translateX(-5px); }
309      43%   { transform: rotate(17deg) scale(1.04) translateX(5px); }
310      44.5% { transform: rotate(17deg) scale(1.02) translateX(-3px); }
311      46%   { transform: rotate(17deg) scale(1.01) translateX(2px); }
312      49%   { transform: rotate(17deg) scale(1)    translateX(0); }
313      100%  { transform: rotate(17deg) scale(1)    translateX(0); }
314    }
315    .ss-clap {
316      width: 48px; height: 48px; display: block; overflow: visible;
317      transform: rotate(17deg); transform-origin: center;
318      animation: ss-clap-impact 1.6s linear infinite;
319    }
320    .ss-clap .ss-clap-stick { transform-origin: 958px 614px; animation: ss-clap 1.6s linear infinite; }
321    .ss-clap .ss-clap-stick, .ss-clap .ss-clap-board { fill: #444444; transition: fill .3s ease; }
322    body.dark-theme .ss-clap .ss-clap-stick, body.dark-theme .ss-clap .ss-clap-board { fill: #fffcfc; }
323    @media (prefers-reduced-motion: reduce) {
324      .ss-clap, .ss-clap .ss-clap-stick { animation-duration: 2.4s; }
325    }
326    .clap-loader-box { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .85rem; }
327    .clap-loader-box.inline { flex-direction: row; gap: .55rem; }
328    .clap-loader-label { font-size: .9rem; color: var(--color-text-muted); font-weight: 500; }
329  </style>
330  <!-- ========= SPECULATION RULES =========
331       Make internal navigation feel instant, pairing with the cross-document View
332       Transitions above. `prefetch` only fetches the HTML (no script execution), so it
333       never triggers a page's analytics/view-tracking or YouTube loads early — safe to
334       apply broadly. `prerender` fully loads + runs the target, so it's limited to the
335       static informational pages where running their JS ahead of time has no side
336       effects. Auth/download/payment/API routes are excluded from both. `moderate`
337       eagerness fires on hover / pointerdown intent rather than eagerly on render. -->
338  
338<script type="speculationrules">
339  {
340    "prefetch": [{
341      "where": { "and": [
342        { "href_matches": "/*" },
343        { "not": { "href_matches": "/logout*" } },
344        { "not": { "href_matches": "/login*" } },
345        { "not": { "href_matches": "/download*" } },
346        { "not": { "href_matches": "/video/*" } },
347        { "not": { "href_matches": "/api/*" } },
348        { "not": { "href_matches": "/pay*" } },
349        { "not": { "href_matches": "/gift/*" } },
350        { "not": { "selector_matches": "[data-no-prefetch]" } },
351        { "not": { "selector_matches": "[target=_blank]" } }
352      ]},
353      "eagerness": "moderate"
354    }],
355    "prerender": [
356      
357      {
358      "where": { "or": [
359        { "href_matches": "/e/*" },
360        { "href_matches": "/exports/*" },
361        { "href_matches": "/get-started" },
362        { "href_matches": "/guides" },
363        { "href_matches": "/aboutus" },
364        { "href_matches": "/contactus" },
365        { "href_matches": "/privacy-policy" },
366        { "href_matches": "/tnc" },
367        { "href_matches": "/stats" },
368        { "href_matches": "/membership" },
369        { "href_matches": "/all_games" }
370      ]},
371      "eagerness": "moderate"
372    }]
373  }
374  </script>
374
375
376  <!-- ========= STRUCTURED DATA: Organization + WebSite (site-wide entity graph) ========= -->
377  
377<script type="application/ld+json">
378  {
379    "@context": "https://schema.org",
380    "@graph": [
381      {
382        "@type": "Organization",
383        "@id": "https://streamsnip.com/#organization",
384        "name": "StreamSnip",
385        "url": "https://streamsnip.com",
386        "logo": {
387          "@type": "ImageObject",
388          "url": "https://streamsnip.com/static/logo.png",
389          "width": 512,
390          "height": 512
391        },
392        "description": "StreamSnip is a freemium clipping tool for YouTube livestreamers. Capture highlight moments instantly with a single chat command.",
393        "foundingDate": "2023-01-24",
394        "founder": {
395          "@type": "Person",
396          "@id": "https://streamsnip.com/#founder",
397          "name": "Suraj Bhari"
398        },
399        "contactPoint": {
400          "@type": "ContactPoint",
401          "contactType": "customer support",
402          "email": "[email protected]",
403          "url": "https://streamsnip.com/contactus"
404        },
405        "sameAs": [
406          "https://discord.gg/2XVBWK99Vy"
407        ]
408      },
409      {
410        "@type": "WebSite",
411        "@id": "https://streamsnip.com/#website",
412        "url": "https://streamsnip.com",
413        "name": "StreamSnip",
414        "description": "Simplified YouTube livestream clipping for streamers and their communities.",
415        "publisher": {
416          "@id": "https://streamsnip.com/#organization"
417        }
418      }
419    ]
420  }
421  </script>
421
422
423  
424<!-- ========= HOME INTRO SPLASH =========
425     Full-screen clapper-board "clap" on the first home visit per browser session.
426     Reuses the exact _nbl_clap / _nbl_impact keyframes that drive the navbar logo
427     hover (defined in layout.html) so the motion is identical, composited here from
428     the standalone board + stick SVGs. The gate script below runs before first paint
429     so the overlay shows without a flash of the page underneath. -->
430<style>
431    #home-splash {
432        position: fixed;
433        inset: 0;
434        z-index: 100000;
435        display: none;
436        align-items: center;
437        justify-content: center;
438        background: var(--color-bg);
439    }
440    html.show-splash { overflow: hidden; }
441    html.show-splash #home-splash { display: flex; }
442    html.splash-hiding #home-splash {
443        opacity: 0;
444        transition: opacity 0.45s ease;
445        pointer-events: none;
446    }
447
448    .home-splash-logo {
449        position: relative;
450        width: min(42vmin, 300px);
451        aspect-ratio: 1 / 1;
452        transform-origin: center;
453        opacity: 0;                       /* hidden until both board + stick have decoded */
454        transition: opacity 0.15s ease;
455    }
456    /* Fill the clapper's hinge hole with the splash background colour so the stick can't
457       be seen through it as it swings. A small disc (rather than a CSS mask) is used so
458       the stick can still extend past the logo box without being clipped. Coords/radius
459       match the hole in the board SVG (center 760.9,655.1 of the 3334 viewBox). It lives
460       inside the logo wrapper, so it scales with the impact animation and stays aligned. */
461    .home-splash-hole {
462        position: absolute;
463        left: 22.82%;
464        top: 19.65%;
465        width: 5.9%;
466        height: 5.9%;
467        transform: translate(-50%, -50%);
468        border-radius: 50%;
469        background: var(--color-bg);
470        z-index: 3;
471    }
472    html.splash-play .home-splash-logo { opacity: 1; animation: ss-splash-impact 1.5s linear 1 both; }
473
474    /* Impact shake at the clap moment — same beats as the navbar's _nbl_impact but
475       without its baked rotate(17deg), so the splash logo sits upright to match the
476       canonical brand logo (logo.svg) instead of tilting into a diamond. */
477    @keyframes ss-splash-impact {
478        0%   { transform: scale(1)     translate(0, 0); }
479        52%  { transform: scale(1.07)  translate(0, 0); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
480        54%  { transform: scale(1.05)  translate(-7px, 2px); }
481        56%  { transform: scale(1.04)  translate(7px, -1px); }
482        58%  { transform: scale(1.03)  translate(-5px, 1px); }
483        60%  { transform: scale(1.015) translate(3px, 0); }
484        64%  { transform: scale(1)     translate(0, 0); }
485        100% { transform: scale(1)     translate(0, 0); }
486    }
487
488    .home-splash-board,
489    .home-splash-stick {
490        position: absolute;
491        inset: 0;
492        width: 100%;
493        height: 100%;
494        object-fit: contain;
495    }
496    /* Stick pivots at the clapper hinge: 958/3334, 614/3334 of the 3334 viewBox. */
497    .home-splash-stick {
498        transform: rotate(0deg);
499        transform-origin: 35.4% 17.3%;
500    }
501    html.splash-play .home-splash-stick { animation: ss-splash-clap 1.5s linear 1 both; }
502
503    /* Clap with a rolling pivot: the stick hinges at the OPEN anchor (22.9% 19.65%, the
504       hole) while open and at the SHUT anchor (35.4% 17.3%) at rest/strike, interpolating
505       between as it swings. Rest 0° (ideal) -> open -57° -> strike 23.5° (flush slam) ->
506       settle back to 0°. */
507    @keyframes ss-splash-clap {
508        0%   { transform: rotate(0deg);    transform-origin: 35.4% 17.3%;  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); }
509        28%  { transform: rotate(-57deg);  transform-origin: 22.9% 19.65%; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
510        36%  { transform: rotate(-57deg);  transform-origin: 22.9% 19.65%; animation-timing-function: cubic-bezier(0.6, 0, 0.95, 0.45); }
511        52%  { transform: rotate(23.5deg); transform-origin: 35.4% 17.3%;  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
512        62%  { transform: rotate(5deg);    transform-origin: 35.4% 17.3%;  animation-timing-function: cubic-bezier(0.34, 1.4, 0.64, 1); }
513        74%  { transform: rotate(0deg);    transform-origin: 35.4% 17.3%;  animation-timing-function: ease-out; }
514        100% { transform: rotate(0deg);    transform-origin: 35.4% 17.3%; }
515    }
516
517    /* Theme-matched assets (solid black on light, white no-stroke on dark) are set as the
518       <img> src in the gate script below, which reveals both only once decoded. */
519</style>
520<script>
521    // Show the splash only on the first home visit of the session, and never when the
522    // user prefers reduced motion. Runs before first paint to avoid a flash.
523    (function () {
524        try {
525            var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
526            if (!reduce && !sessionStorage.getItem('ss_home_splash_seen')) {
527                document.documentElement.classList.add('show-splash');
528                sessionStorage.setItem('ss_home_splash_seen', '1');
529            }
530        } catch (e) {}
531    })();
532</script>
vendor: 1 bytes, line 532
532
533<script>
534    // Dynamically load only the needed hero font to save 30+ redundant requests
535    (function () {
536        const getCookie = (name) => {
537            const v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
538            return v ? v[2] : null;
539        };
540        const font = decodeURIComponent(getCookie('hero_font') || 'Rajdhani');
541        const coreFonts = ['Outfit', 'Poppins', 'Rajdhani'];
542        if (!coreFonts.includes(font)) {
543            const link = document.createElement('link');
544            link.rel = 'stylesheet';
545            link.href = `https://fonts.googleapis.com/css2?family=${font.replace(/ /g, '+')}:wght@700&display=swap`;
546            document.head.appendChild(link);
547        }
548    })();
549</script>
549
550<style>
551    .hero-wrapper {
552        position: relative;
553        min-height: 100svh;
554        /* Reduced height to flow better on desktop */
555        width: 100%;
556        overflow: hidden;
557        display: flex;
558        align-items: center;
559        justify-content: center;
560        /* Removed redundant radial gradient background - now handled globally in layout.html */
561        background: transparent;
562        transition: background var(--transition-base);
563    }
564
565    .dark-theme .hero-wrapper {
566        background: transparent;
567    }
568
569
570    #hero-canvas {
571        position: absolute;
572        top: 0;
573        left: 0;
574        width: 100%;
575        height: 100%;
576        z-index: 1;
577        pointer-events: none;
578        /* Subtle bleed-through */
579    }
580
581    /* Linear-clean calm scrim: quiets the live thumbnail mosaic so the headline
582       floats on settled ground. Sits above the canvas (z:1) and below the hero
583       content (z:2) and the scene/font controls (z:5+), so nothing interactive dims. */
584    .hero-wrapper::after {
585        content: "";
586        position: absolute;
587        inset: 0;
588        z-index: 1;
589        pointer-events: none;
590        background: radial-gradient(ellipse 85% 75% at 50% 45%,
591            hsl(var(--accent-h) 36% 97% / 0.30) 0%,
592            hsl(var(--accent-h) 36% 96% / 0.74) 100%);
593    }
594
595    .dark-theme .hero-wrapper::after {
596        background: radial-gradient(ellipse 85% 75% at 50% 45%,
597            hsl(var(--accent-h) 32% 7% / 0.42) 0%,
598            hsl(var(--accent-h) 32% 7% / 0.80) 100%);
599    }
600
601    .hero-content {
602        position: relative;
603        z-index: 2;
604        text-align: center;
605        max-width: 1000px;
606        padding: 4rem 2rem;
607    }
608
609    /* Centring the CTA row lives here rather than on Bootstrap's
610       .justify-content-center utility, whose !important the wide-hero rule
611       below cannot override. */
612    .hero-cta-row {
613        justify-content: center;
614    }
615
616    /* Inline padding lives here rather than on Bootstrap's px-4 / px-md-5,
617       whose !important the wide-hero rule below cannot override. These two
618       reproduce the utilities they replaced. */
619    .hero-btn {
620        padding-inline: 1.5rem;
621    }
622
623    @media (min-width: 768px) {
624        .hero-btn {
625            padding-inline: 3rem;
626        }
627    }
628
629    /* ── Wide hero ──
630       Splits the hero into a headline column and a supporting column so the
631       page uses the width it has. Scoped to >=900px rather than a laptop-sized
632       breakpoint: browser zoom divides the viewport in CSS pixels (200% on a
633       1920 screen is a 960px viewport), so a high breakpoint drops zoomed-in
634       users back to the stacked column. The title, gutters and gap below are
635       all fluid so the two columns survive down to 900. */
636    @media (min-width: 900px) {
637        .hero-content {
638            display: grid;
639            grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
640            align-items: center;
641            gap: clamp(2rem, 4vw, 5rem);
642            max-width: min(1320px, 92vw);
643            /* Extra block-end padding keeps the side column clear of the
644               scene/font selectors, which float 2rem off the hero's bottom
645               corners at z-index 10 once the Konami code reveals them. */
646            padding: 4rem clamp(1.25rem, 3vw, 3rem) 7rem;
647            text-align: start;
648        }
649
650        .hero-col-main,
651        .hero-col-side {
652            min-width: 0;
653        }
654
655        .hero-col-side {
656            display: flex;
657            flex-direction: column;
658            /* Twice the 1rem spacing between steps inside a card, so the two
659               cards read as separate groups rather than one list. */
660            gap: 2rem;
661        }
662
663        /* Half the width means the 8vw headline no longer fits two words to a
664           line; scale it to the column it now lives in. The lower bound has to
665           stay small enough that "STREAM CLIPPING" still fits the column at a
666           900px viewport, or the headline wraps to three lines. */
667        .hero-content .hero-title {
668            font-size: clamp(2rem, 4.4vw, 5rem);
669            margin-block-end: 1.5rem;
670        }
671
672        /* The subtitle centres itself with `margin: 0 auto` in the stacked
673           layout; in the grid it should hug the left edge like the title. */
674        .hero-content .hero-subtitle {
675            margin-inline: 0;
676            margin-block-end: 2.25rem;
677        }
678
679        .hero-content .hero-cta-row {
680            justify-content: flex-start;
681            flex-wrap: wrap;
682        }
683
684        /* The narrower column let the flex row squeeze buttons until their
685           labels broke mid-phrase ("My / Clips"). Refusing to shrink keeps each
686           label on one line and wraps whole buttons to the next row instead;
687           max-width means a label that outgrows the column still wraps inside
688           the button rather than overflowing it. */
689        .hero-content .hero-btn {
690            flex-shrink: 0;
691            max-width: 100%;
692            /* Two buttons at the full 3rem inline padding need 488px, more
693               than the 433px column at a 900-1000px viewport. Scaling the
694               padding keeps them on one row without shrinking the labels. */
695            padding-inline: clamp(1.5rem, 3.2vw, 3rem);
696        }
697
698        .hero-content .stats-pill-wrapper {
699            justify-content: flex-start;
700            min-height: 0;
701            margin-block-start: 0;
702        }
703
704        .hero-col-side .hero-side-card {
705            margin-block-start: 0;
706        }
707
708        /* The scrim is most transparent at its centre, which suited a single
709           centred column sitting in that quiet spot. Content now reaches both
710           edges, so lift the inner stop to settle the mosaic under the whole
711           row. Geometry is unchanged; only the centre opacity moves. */
712        .hero-wrapper::after {
713            background: radial-gradient(ellipse 85% 75% at 50% 45%,
714                hsl(var(--accent-h) 36% 97% / 0.48) 0%,
715                hsl(var(--accent-h) 36% 96% / 0.80) 100%);
716        }
717
718        .dark-theme .hero-wrapper::after {
719            background: radial-gradient(ellipse 85% 75% at 50% 45%,
720                hsl(var(--accent-h) 32% 7% / 0.60) 0%,
721                hsl(var(--accent-h) 32% 7% / 0.86) 100%);
722        }
723    }
724
725    /* Supporting cards in the side column. Below the grid breakpoint they are
726       plain stacked blocks, so they space themselves; inside the grid the
727       column's `gap` does it and this margin is zeroed out. */
728    .hero-side-card {
729        margin-block-start: 2rem;
730        background: var(--glass-bg);
731        backdrop-filter: blur(16px) saturate(180%);
732        -webkit-backdrop-filter: blur(16px) saturate(180%);
733        border: 1px solid var(--glass-border);
734        box-shadow: var(--glass-shadow);
735        border-radius: var(--radius-md);
736        padding: 1.5rem;
737        text-align: start;
738    }
739
740    .hero-step {
741        display: flex;
742        align-items: flex-start;
743        gap: 0.9rem;
744    }
745
746    .hero-step+.hero-step {
747        margin-block-start: 1rem;
748    }
749
750    .hero-step-num {
751        flex: 0 0 1.75rem;
752        height: 1.75rem;
753        border-radius: var(--radius-full);
754        display: grid;
755        place-items: center;
756        font-weight: 700;
757        font-size: 0.85rem;
758        font-variant-numeric: tabular-nums;
759        color: var(--color-text);
760        background: var(--glass-bg-hover);
761        border: 1px solid var(--glass-border);
762    }
763
764    .hero-step-title {
765        font-weight: 700;
766        font-size: 0.98rem;
767        line-height: 1.3;
768        color: var(--color-text);
769        margin-bottom: 0.15rem;
770    }
771
772    .hero-step-text {
773        font-size: 0.85rem;
774        line-height: 1.45;
775        margin-bottom: 0;
776        color: var(--color-text-muted);
777    }
778
779    /* Kick strip: same information as the old full-width shout, sized to sit
780       beside the headline instead of competing with it. */
781    .hero-kick-title {
782        display: flex;
783        align-items: center;
784        gap: 0.5rem;
785        font-weight: 800;
786        font-size: 1.15rem;
787        line-height: 1.2;
788        letter-spacing: -0.01em;
789        color: var(--color-text);
790        margin-bottom: 0.5rem;
791    }
792
793    .hero-kick-title img {
794        height: 1.05rem;
795        width: auto;
796    }
797
798    .hero-kick-text {
799        font-size: 0.85rem;
800        line-height: 1.5;
801        margin-bottom: 0;
802        color: var(--color-text-muted);
803    }
804
805    /* Terminal block, same component as get-started.html and guides.html.
806       layout.html adds the Copy button to every .terminal-container on load,
807       so the markup here carries no button of its own. */
808    .terminal-container {
809        background: rgba(15, 23, 42, 0.6);
810        backdrop-filter: blur(12px);
811        border: 1px solid var(--glass-border);
812        border-radius: var(--radius-btn);
813        overflow: hidden;
814        box-shadow: var(--glass-shadow);
815        margin: 0;
816    }
817
818    .terminal-header {
819        background: rgba(255, 255, 255, 0.05);
820        padding: 8px 16px;
821        display: flex;
822        align-items: center;
823        gap: 8px;
824        border-bottom: 1px solid rgba(255, 255, 255, 0.05);
825    }
826
827    .terminal-dot {
828        width: 10px;
829        height: 10px;
830        border-radius: 50%;
831    }
832
833    .dot-red { background: #ff5f56; }
834    .dot-yellow { background: #ffbd2e; }
835    .dot-green { background: #27c93f; }
836
837    .terminal-body {
838        padding: 16px;
839        font-family: 'Fira Code', 'Cascadia Code', 'Source Code Pro', monospace;
840        /* A notch smaller than the get-started page: this one lives in the
841           hero's side column, not a full-width content block. */
842        font-size: 0.78rem;
843        color: #f8fafc;
844        position: relative;
845    }
846
847    .terminal-body pre {
848        margin: 0;
849        white-space: pre-wrap;
850        word-break: break-all;
851        color: inherit;
852    }
853
854    /* ── Narrow hero: trim the side column ──
855       Below the grid breakpoint the side column stacks inline under the CTAs.
856       At full size that is ~800px of cards on a phone, burying the clips grid
857       nearly three screens down. Keep every card, but reduce them to what can
858       be scanned: step titles without their descriptions, tighter padding,
859       smaller type. The full text is still there from 900px up. */
860    @media (max-width: 899.98px) {
861        .hero-side-card {
862            margin-block-start: 1.25rem;
863            padding: 1.15rem;
864        }
865
866        .hero-step+.hero-step {
867            margin-block-start: 0.65rem;
868        }
869
870        .hero-step-text {
871            display: none;
872        }
873
874        .hero-step-num {
875            flex: 0 0 1.5rem;
876            height: 1.5rem;
877            font-size: 0.78rem;
878        }
879
880        .hero-step-title {
881            font-size: 0.92rem;
882            margin-bottom: 0;
883        }
884
885        /* Adding the !clip command means pasting it into a chat bot's
886           dashboard -- a desktop job. On a phone the card is the tallest
887           thing in the hero and nobody acts on it there, so it waits for a
888           wider viewport. /get-started still carries it everywhere. */
889        .hero-command-card {
890            display: none;
891        }
892
893        .hero-kick-title {
894            font-size: 1rem;
895            margin-bottom: 0.35rem;
896        }
897
898        .hero-kick-text {
899            font-size: 0.8rem;
900        }
901    }
902
903    .hero-command-label {
904        font-size: 0.85rem;
905        line-height: 1.5;
906        color: var(--color-text-muted);
907        margin-block-end: 0.85rem;
908    }
909
910    .hero-title {
911        font-family: 'Rajdhani', sans-serif;
912        font-size: clamp(2.5rem, 8vw, 6.5rem);
913        font-weight: 700;
914        line-height: 1.1;
915        margin-bottom: 2rem;
916        text-transform: uppercase;
917        letter-spacing: -0.02em;
918        color: var(--color-text);
919        transition: color var(--transition-base);
920    }
921
922    @media (max-width: 768px) {
923        .hero-title {
924            font-size: clamp(2.2rem, 10vw, 4rem);
925            line-height: 1.2;
926            margin-bottom: 1.5rem;
927        }
928
929        .hero-subtitle {
930            font-size: 1.1rem;
931            margin-bottom: 2rem;
932            padding: 0 1rem;
933        }
934
935        .hero-content {
936            padding: 4.5rem 1.25rem 1.5rem;
937        }
938    }
939
940    @media (max-width: 575px) {
941        .hero-content .hero-btn {
942            width: 100%;
943            max-width: 260px;
944        }
945    }
946
947    .dark-theme .hero-title {
948        color: #f8fafc;
949    }
950
951    .hero-subtitle {
952        font-size: clamp(1.1rem, 3vw, 1.5rem);
953        font-weight: 500;
954        color: var(--color-text-muted);
955        max-width: 700px;
956        margin: 0 auto 3rem;
957        line-height: 1.4;
958    }
959
960
961    /* ── Snips Hero Integration ── */
962    .snips-beta-badge {
963        display: inline-flex;
964        align-items: center;
965        gap: 8px;
966        padding: 6px 16px;
967        background: var(--primary-12);
968        border: 1px solid hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.3);
969        border-radius: var(--radius-full);
970        color: var(--color-primary-light);
971        font-size: 0.8rem;
972        font-weight: 800;
973        letter-spacing: 1px;
974        text-transform: uppercase;
975        text-decoration: none;
976        transition: background-color 0.3s ease, color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
977        margin-bottom: 2rem;
978    }
979
980    .snips-beta-badge:hover {
981        background: var(--primary-20);
982        transform: translateY(-2px);
983        color: var(--color-primary-light);
984        box-shadow: 0 6px 16px -4px hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.3);
985    }
986
987    .pulsating-dot {
988        width: 8px;
989        height: 8px;
990        background: var(--color-primary-light);
991        border-radius: 50%;
992        position: relative;
993    }
994
995    .pulsating-dot::after {
996        content: '';
997        position: absolute;
998        top: 0;
999        left: 0;
1000        width: 100%;
1001        height: 100%;
1002        background: inherit;
1003        border-radius: inherit;
1004        animation: pulse-ring 1.5s cubic-bezier(0.24, 0, 0.38, 1) infinite;
1005    }
1006
1007    @keyframes pulse-ring {
1008        0% {
1009            transform: scale(0.5);
1010            opacity: 0.8;
1011        }
1012
1013        100% {
1014            transform: scale(3);
1015            opacity: 0;
1016        }
1017    }
1018
1019    .btn-snips-hero {
1020        background: var(--color-primary);
1021        color: white;
1022        border: none !important;
1023        transition: background-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease;
1024    }
1025
1026    .btn-snips-hero:hover {
1027        background: var(--color-primary-hover);
1028        transform: translateY(-2px);
1029        box-shadow: 0 8px 20px -4px hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.4);
1030        color: white;
1031    }
1032
1033    .command-bar {
1034        background: var(--glass-bg);
1035        backdrop-filter: blur(20px);
1036        -webkit-backdrop-filter: blur(20px);
1037        border: 1px solid var(--glass-border);
1038        border-radius: var(--radius-full);
1039        padding: 0.6rem 1.25rem;
1040        display: flex;
1041        align-items: center;
1042        gap: 1.25rem;
1043        box-shadow: var(--glass-shadow);
1044        margin-bottom: 3.5rem;
1045        transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
1046        position: relative;
1047        overflow: visible !important;
1048        /* Allow the glow to bleed out slightly */
1049    }
1050
1051    .searching-pulse {
1052        border-color: var(--color-primary) !important;
1053        box-shadow: 0 0 0 3px var(--color-primary-glow) !important;
1054        z-index: 10;
1055    }
1056
1057    .command-bar:focus-within {
1058        border-color: var(--color-primary);
1059        box-shadow: 0 0 0 4px var(--color-primary-glow);
1060    }
1061
1062    .command-bar .search-wrapper {
1063        flex: 1;
1064        display: flex;
1065        align-items: center;
1066        gap: 0.75rem;
1067        padding-inline-start: 0.5rem;
1068    }
1069
1070    .command-bar input {
1071        width: 100%;
1072        border: none;
1073        background: transparent;
1074        color: var(--color-text);
1075        font-weight: 500;
1076        font-size: 1rem;
1077        outline: none;
1078    }
1079
1080    .command-bar .custom-dropdown-container {
1081        width: auto;
1082        min-width: 150px;
1083        flex: 1;
1084    }
1085
1086    .command-bar .invert-btn {
1087        width: 40px;
1088        height: 40px;
1089        display: flex;
1090        align-items: center;
1091        justify-content: center;
1092        border-radius: 50%;
1093        transition: background-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast), border-color var(--transition-fast);
1094        color: var(--color-text);
1095        background: var(--glass-bg-subtle);
1096        border: 1px solid var(--glass-border);
1097        cursor: pointer;
1098        flex-shrink: 0;
1099    }
1100
1101    .command-bar .invert-btn i {
1102        transition: transform var(--transition-fast);
1103    }
1104
1105    .command-bar .invert-btn:hover i {
1106        transform: scale(1.2);
1107    }
1108
1109    .command-bar .invert-btn.active {
1110        background: var(--color-primary);
1111        color: white;
1112        border-color: var(--color-primary);
1113    }
1114
1115
1116    .glass-footer {
1117        background: hsl(var(--accent-h) 30% 9% / 0.4);
1118        backdrop-filter: blur(12px);
1119        -webkit-backdrop-filter: blur(12px);
1120        border-top: 1px solid rgba(255, 255, 255, 0.1);
1121        transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
1122    }
1123
1124    .hover-lift {
1125        transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.4s ease;
1126        will-change: transform, box-shadow;
1127        transform: translateZ(0);
1128    }
1129
1130    .hover-lift img {
1131        transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
1132        will-change: transform;
1133    }
1134
1135    .hover-lift:hover {
1136        transform: translateY(-6px) scale(1.015);
1137        box-shadow: 0 16px 32px -4px rgba(0, 0, 0, 0.15), 0 8px 16px -4px rgba(0, 0, 0, 0.1);
1138    }
1139
1140    .hover-lift:hover img {
1141        transform: scale(1.05);
1142    }
1143
1144    .hover-lift:hover .glass-footer {
1145        background: hsl(var(--accent-h) 30% 9% / 0.6);
1146    }
1147
1148    /* ── Premium Card: Rotating Gradient Border ── */
1149    .premium-glow {
1150        position: relative;
1151        border-radius: var(--radius-lg);
1152        padding: 3px;
1153        background: conic-gradient(from var(--gradient-angle, 0deg),
1154                #3b82f6,
1155                #8b5cf6,
1156                #ec4899,
1157                #f59e0b,
1158                #10b981,
1159                #3b82f6);
1160        animation: spin-gradient 3s linear infinite;
1161    }
1162
1163    .premium-glow::before {
1164        content: '';
1165        position: absolute;
1166        inset: -8px;
1167        border-radius: inherit;
1168        background: conic-gradient(from var(--gradient-angle, 0deg),
1169                #3b82f6,
1170                #8b5cf6,
1171                #ec4899,
1172                #f59e0b,
1173                #10b981,
1174                #3b82f6);
1175        animation: spin-gradient 3s linear infinite;
1176        filter: blur(18px);
1177        opacity: 0.6;
1178        z-index: -1;
1179    }
1180
1181    @media (max-width: 768px) {
1182        .premium-glow::before {
1183            filter: blur(12px);
1184            opacity: 0.4;
1185            inset: -4px;
1186        }
1187
1188        .premium-glow {
1189            padding: 2px;
1190        }
1191    }
1192
1193    .premium-glow .card {
1194        border-radius: var(--radius-lg);
1195    }
1196
1197    @property --gradient-angle {
1198        syntax: '<angle>';
1199        initial-value: 0deg;
1200        inherits: false;
1201    }
1202
1203    @keyframes spin-gradient {
1204        to {
1205            --gradient-angle: 360deg;
1206        }
1207    }
1208
1209    .stats-pill-wrapper {
1210        min-height: 95px;
1211        /* Matches pill height + margins */
1212        display: flex;
1213        justify-content: center;
1214        align-items: center;
1215        margin-top: 2rem;
1216    }
1217
1218    @media (max-width: 768px) {
1219        .stats-pill-wrapper {
1220            min-height: 75px;
1221            margin-top: 1.5rem;
1222        }
1223    }
1224
1225    .hero-stats-boast {
1226        background: rgba(255, 255, 255, 0.03);
1227        backdrop-filter: blur(10px);
1228        border: 1px solid rgba(255, 255, 255, 0.05);
1229        padding: 0.5rem 1.75rem;
1230        border-radius: var(--radius-full);
1231        display: none;
1232        flex-direction: column;
1233        align-items: center;
1234        gap: 0.5rem;
1235        transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
1236        margin-top: 0;
1237        /* Margin moved to wrapper */
1238    }
1239
1240    .dark-theme .hero-stats-boast {
1241        background: hsla(210, 100%, 100%, 0.05) !important;
1242        border: 1px solid rgba(255, 255, 255, 0.1) !important;
1243    }
1244
1245    @keyframes hero-stats-float {
1246
1247        0%,
1248        100% {
1249            transform: translateY(0);
1250        }
1251
1252        50% {
1253            transform: translateY(-10px);
1254        }
1255    }
1256
1257    .text-gradient-primary {
1258        background: linear-gradient(135deg, var(--color-text) 0%, var(--color-primary) 100%);
1259        -webkit-background-clip: text;
1260        background-clip: text;
1261        -webkit-text-fill-color: transparent;
1262    }
1263
1264    .dark-theme .text-gradient-primary {
1265        background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
1266        -webkit-background-clip: text;
1267        background-clip: text;
1268        -webkit-text-fill-color: transparent;
1269    }
1270
1271    /* Custom component integration wrapper rules apply */
1272
1273
1274
1275    /* ── Hero Banners ── */
1276    .hero-banners-container {
1277        display: grid;
1278        grid-template-columns: 1fr 1fr;
1279        gap: 2rem;
1280        margin-bottom: 5rem;
1281    }
1282
1283    @media (max-width: 768px) {
1284        .hero-banners-container {
1285            gap: 1rem;
1286            margin-bottom: 3rem;
1287        }
1288    }
1289
1290    .hero-banner-card {
1291        background: var(--glass-bg);
1292        backdrop-filter: blur(16px);
1293        -webkit-backdrop-filter: blur(16px);
1294        border: 1px solid var(--glass-border);
1295        border-radius: var(--radius-xl);
1296        overflow: hidden;
1297        transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.4s ease, background-color 0.4s ease;
1298        will-change: transform, box-shadow, border-color;
1299        transform: translateZ(0);
1300        box-shadow: var(--glass-shadow);
1301        height: 100%;
1302        display: flex;
1303        flex-direction: column;
1304        backface-visibility: hidden;
1305        transform-style: preserve-3d;
1306    }
1307
1308    .hero-banner-card:hover {
1309        transform: translateY(-8px) scale(1.01);
1310        border-color: rgba(255, 255, 255, 0.2);
1311        box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
1312    }
1313
1314    .btn-snips {
1315        padding: 1rem 2.5rem;
1316        font-weight: 700;
1317        font-size: 1.1rem;
1318        border-radius: var(--radius-full);
1319        background: var(--color-primary);
1320        border: none;
1321        transition: background-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease;
1322    }
1323
1324    .btn-snips:hover {
1325        background: var(--color-primary-hover);
1326        transform: translateY(-2px);
1327        box-shadow: 0 8px 20px -4px hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.4);
1328        color: white;
1329    }
1330
1331    .snips-tease-visual {
1332        display: flex;
1333        justify-content: flex-end;
1334        position: relative;
1335        z-index: 1;
1336    }
1337
1338    .vertical-preview-wrapper {
1339        width: 240px;
1340        height: 420px;
1341        border-radius: 40px;
1342        border: 6px solid #1a1a1a;
1343        background: #000;
1344        overflow: hidden;
1345        box-shadow: 0 40px 80px rgba(0, 0, 0, 0.6);
1346        transform: rotate(5deg) translateY(20px);
1347        transition: transform 0.6s cubic-bezier(0.19, 1, 0.22, 1);
1348    }
1349
1350    .vertical-preview-wrapper img {
1351        width: 100%;
1352        height: 100%;
1353        object-fit: cover;
1354    }
1355
1356    .hero-banner-card img,
1357    .hero-banner-card iframe {
1358        width: 100%;
1359        height: 100%;
1360        object-fit: cover;
1361    }
1362
1363    .hero-banner-overlay {
1364        position: absolute;
1365        inset: 0;
1366        background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 60%);
1367        display: flex;
1368        flex-direction: column;
1369        justify-content: flex-end;
1370        padding: 2rem;
1371        color: white;
1372    }
1373
1374    @media (max-width: 991px) {
1375        .hero-banners-container {
1376            grid-template-columns: 1fr;
1377        }
1378
1379        .hero-banner-card {
1380            aspect-ratio: 600 / 315;
1381            height: auto;
1382        }
1383
1384        .hero-banner-card img,
1385        .hero-banner-card iframe {
1386            min-height: 0 !important;
1387        }
1388    }
1389
1390    @media (max-width: 768px) {
1391        .command-bar {
1392            flex-direction: column;
1393            border-radius: var(--radius-lg);
1394            padding: 1rem;
1395            gap: 1rem;
1396        }
1397
1398        .command-bar .search-wrapper {
1399            width: 100%;
1400            border-bottom: 1px solid var(--glass-border);
1401            padding-bottom: 0.75rem;
1402            padding-inline-start: 0;
1403        }
1404
1405        .command-bar .border-start {
1406            border-inline-start: none !important;
1407            padding-inline-start: 0 !important;
1408            width: 100%;
1409            justify-content: space-between;
1410        }
1411
1412        .command-bar #sortby {
1413            flex: 1;
1414            min-width: 0;
1415        }
1416    }
1417
1418    /* Removed Scroll Lock until first load - now allowed to scroll immediately */
1419
1420    /* ── Hero Scene Selector ── */
1421    .scene-selector-wrapper {
1422        position: absolute;
1423        bottom: 2rem;
1424        inset-inline-end: 2rem;
1425        z-index: 10;
1426        display: flex;
1427        align-items: center;
1428        gap: 0.75rem;
1429        background: var(--glass-bg);
1430        backdrop-filter: blur(16px);
1431        -webkit-backdrop-filter: blur(16px);
1432        border: 1px solid var(--glass-border);
1433        padding: 0.5rem 1rem;
1434        border-radius: var(--radius-full);
1435        box-shadow: var(--glass-shadow);
1436        transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
1437        opacity: 0;
1438        display: none;
1439        pointer-events: none;
1440    }
1441
1442    .scene-selector-wrapper:hover {
1443        opacity: 1;
1444        transform: translateY(-2px);
1445    }
1446
1447    .scene-selector-wrapper i {
1448        color: var(--color-primary);
1449        font-size: 0.9rem;
1450    }
1451
1452    #hero-scene-select {
1453        background: transparent;
1454        border: none;
1455        color: var(--color-text);
1456        font-size: 0.85rem;
1457        font-weight: 600;
1458        cursor: pointer;
1459        outline: none;
1460        padding-inline-end: 1.5rem;
1461        appearance: none;
1462        -webkit-appearance: none;
1463        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
1464        background-repeat: no-repeat;
1465        background-position: right center;
1466        background-size: 1rem;
1467    }
1468
1469    @media (max-width: 768px) {
1470        .scene-selector-wrapper {
1471            bottom: 1rem;
1472            inset-inline-end: 1rem;
1473            padding: 0.4rem 0.8rem;
1474        }
1475    }
1476
1477    /* ── Sale Strip (Independence Day tricolour) ── */
1478    .sale-strip {
1479        --flag-saffron: #ff9933;
1480        --flag-green: #138808;
1481        /* Text tints: readable on the light theme by default, brightened for dark below */
1482        --flag-saffron-text: #b45f06;
1483        --flag-green-text: #138808;
1484        position: relative;
1485        display: flex;
1486        align-items: center;
1487        gap: 1rem;
1488        margin-bottom: 1.25rem;
1489        padding: 1rem 1.25rem 0.85rem;
1490        border: 1px solid rgba(255, 153, 51, 0.55);
1491        border-radius: var(--radius-md);
1492        background: linear-gradient(90deg,
1493                rgba(255, 153, 51, 0.20) 0%,
1494                rgba(255, 255, 255, 0.06) 50%,
1495                rgba(19, 136, 8, 0.20) 100%);
1496        text-decoration: none;
1497        color: var(--color-text);
1498        overflow: hidden;
1499        transition: transform 0.3s ease, box-shadow 0.3s ease;
1500    }
1501
1502    /* Tricolour rule along the top edge */
1503    .sale-strip::before {
1504        content: "";
1505        position: absolute;
1506        inset: 0 0 auto 0;
1507        height: 4px;
1508        background: linear-gradient(90deg,
1509                var(--flag-saffron) 0 33.33%,
1510                #fff 33.33% 66.66%,
1511                var(--flag-green) 66.66% 100%);
1512    }
1513
1514    .sale-strip:hover {
1515        transform: translateY(-2px);
1516        box-shadow: 0 12px 25px -12px rgba(0, 0, 0, 0.45);
1517        color: var(--color-text);
1518    }
1519
1520    .sale-strip-badge {
1521        flex-shrink: 0;
1522        font-weight: 800;
1523        font-size: 0.85rem;
1524        letter-spacing: 0.04em;
1525        padding: 0.45rem 0.8rem;
1526        border-radius: var(--radius-full);
1527        background: var(--flag-saffron);
1528        color: #1a1200;
1529    }
1530
1531    .sale-strip-body {
1532        flex: 1;
1533        min-width: 0;
1534    }
1535
1536    .sale-strip-title {
1537        display: block;
1538        font-weight: 700;
1539        font-size: 1rem;
1540    }
1541
1542    .sale-strip-prices {
1543        display: flex;
1544        flex-wrap: wrap;
1545        gap: 0.35rem 1rem;
1546        margin-top: 0.15rem;
1547        font-size: 0.875rem;
1548    }
1549
1550    .sale-strip-price s {
1551        opacity: 0.55;
1552    }
1553
1554    .dark-theme .sale-strip {
1555        --flag-saffron-text: #ffb166;
1556        --flag-green-text: #4bbf5a;
1557    }
1558
1559    .sale-strip-ends {
1560        font-weight: 600;
1561        color: var(--flag-green-text);
1562    }
1563
1564    .sale-strip-cta {
1565        flex-shrink: 0;
1566        font-weight: 700;
1567        font-size: 0.9rem;
1568        color: var(--flag-saffron-text);
1569        white-space: nowrap;
1570    }
1571
1572    @media (max-width: 768px) {
1573        .sale-strip {
1574            flex-wrap: wrap;
1575            gap: 0.6rem;
1576            padding: 0.75rem 1rem;
1577        }
1578
1579        .sale-strip-title {
1580            font-size: 0.92rem;
1581        }
1582
1583        .sale-strip-prices {
1584            font-size: 0.8rem;
1585        }
1586
1587        .sale-strip-cta {
1588            width: 100%;
1589        }
1590    }
1591
1592    /* ── Tips Banner ── */
1593    .tips-banner-wrapper {
1594        margin-top: -2.5rem;
1595        margin-bottom: 3.5rem;
1596        display: flex;
1597        justify-content: center;
1598        perspective: 1000px;
1599        z-index: 5;
1600        position: relative;
1601    }
1602
1603    .tips-banner {
1604        background: var(--glass-bg);
1605        backdrop-filter: blur(16px);
1606        -webkit-backdrop-filter: blur(16px);
1607        border: 1px solid var(--glass-border);
1608        border-radius: var(--radius-full);
1609        padding: 0.6rem 1.75rem;
1610        display: flex;
1611        align-items: center;
1612        gap: 0.85rem;
1613        box-shadow: var(--glass-shadow);
1614        transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s ease;
1615        cursor: pointer;
1616        max-width: 90%;
1617        user-select: none;
1618    }
1619
1620    .tips-banner:hover {
1621        transform: translateY(-4px) scale(1.02);
1622        border-color: var(--color-primary);
1623        box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.3);
1624        background: var(--glass-bg-subtle);
1625    }
1626
1627    .tip-icon {
1628        color: var(--color-primary);
1629        font-size: 1.1rem;
1630    }
1631
1632    .tip-text {
1633        font-size: 0.95rem;
1634        font-weight: 600;
1635        color: var(--color-text);
1636        white-space: nowrap;
1637        overflow: hidden;
1638        text-overflow: ellipsis;
1639        transition: all 0.4s ease;
1640    }
1641
1642    .tip-fade-transition {
1643        opacity: 0;
1644        transform: translateY(10px);
1645    }
1646
1647    @media (max-width: 768px) {
1648        .tip-text {
1649            font-size: 0.8rem;
1650        }
1651
1652        .tips-banner-wrapper {
1653            margin-top: 0;
1654            margin-bottom: 2rem;
1655        }
1656
1657        .tips-banner {
1658            padding: 0.5rem 1.25rem;
1659        }
1660    }
1661
1662    /* ── Bottom Hero Loader ── */
1663    .hero-bottom-loader {
1664        position: absolute;
1665        bottom: 0;
1666        left: 0;
1667        width: 100%;
1668        height: 5px;
1669        background: linear-gradient(90deg,
1670                transparent 0%,
1671                var(--color-primary) 50%,
1672                transparent 100%);
1673        background-size: 200% 100%;
1674        animation: loading-slide 2s infinite linear;
1675        z-index: 20;
1676        opacity: 0;
1677        transition: opacity 0.5s ease;
1678        pointer-events: none;
1679    }
1680
1681    .hero-bottom-loader.active {
1682        opacity: 1;
1683    }
1684
1685    @keyframes loading-slide {
1686        from {
1687            background-position: 200% 0;
1688        }
1689
1690        to {
1691            background-position: -200% 0;
1692        }
1693    }
1694
1695    /* Both hero selectors are pinned to the bottom edge of .hero-wrapper, which
1696       clips with overflow:hidden. The shared .custom-dropdown-menu opens downward
1697       (top: calc(100% + 6px)), so the menu is cut off — severely on the shorter
1698       logged-in hero (min-height:45vh), where all but the first option are
1699       unclickable. Open these two upward instead. */
1700    .scene-selector-wrapper .custom-dropdown-menu,
1701    .font-selector-wrapper .custom-dropdown-menu {
1702        top: auto;
1703        bottom: calc(100% + 6px);
1704        /* The chip sits 2rem above the hero floor, so the space above it is
1705           roughly 45vh - 76px on the logged-in hero. Cap the menu to fit there;
1706           the 280px default only applies once the viewport is tall enough. */
1707        max-height: min(280px, 30vh);
1708    }
1709
1710    /* ── Hero Font Selector (Mirrors Scene Selector) ── */
1711    .font-selector-wrapper {
1712        position: absolute;
1713        bottom: 2rem;
1714        inset-inline-start: 2rem;
1715        z-index: 10;
1716        display: flex;
1717        align-items: center;
1718        gap: 0.75rem;
1719        background: var(--glass-bg);
1720        backdrop-filter: blur(16px);
1721        -webkit-backdrop-filter: blur(16px);
1722        border: 1px solid var(--glass-border);
1723        padding: 0.5rem 1rem;
1724        border-radius: var(--radius-full);
1725        box-shadow: var(--glass-shadow);
1726        transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
1727        opacity: 0;
1728        display: none;
1729        pointer-events: none;
1730    }
1731
1732    .font-selector-wrapper:hover {
1733        opacity: 1;
1734        transform: translateY(-2px);
1735    }
1736
1737    .font-selector-wrapper i {
1738        color: var(--color-primary);
1739        font-size: 0.9rem;
1740    }
1741
1742    #hero-font-select {
1743        background: transparent;
1744        border: none;
1745        color: var(--color-text);
1746        font-size: 0.85rem;
1747        font-weight: 600;
1748        cursor: pointer;
1749        outline: none;
1750        padding-inline-end: 1.5rem;
1751        appearance: none;
1752        -webkit-appearance: none;
1753        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
1754        background-repeat: no-repeat;
1755        background-position: right center;
1756        background-size: 1rem;
1757    }
1758
1759    @media (max-width: 768px) {
1760        .font-selector-wrapper {
1761            bottom: 1rem;
1762            inset-inline-start: 1rem;
1763            padding: 0.4rem 0.8rem;
1764        }
1765    }
1766
1767    /* ── Game Cards ── */
1768    .game-cards-wrapper {
1769        position: relative;
1770        margin-bottom: 1.5rem;
1771    }
1772
1773    .game-cards-container {
1774        display: flex;
1775        gap: 1.25rem;
1776        margin-top: 1rem;
1777        overflow-x: auto;
1778        overflow-y: visible;
1779        /* Allow growth */
1780        padding: 1.5rem 1rem 1.5rem 1rem;
1781        /* Increased top padding to prevent clipping on hover */
1782        scrollbar-width: thin;
1783        scrollbar-color: var(--color-primary) transparent;
1784        scroll-behavior: smooth;
1785        -webkit-overflow-scrolling: touch;
1786        scroll-snap-type: x proximity;
1787    }
1788
1789    .game-cards-container::after {
1790        content: '';
1791        flex: 0 0 1rem;
1792        /* Spacer at the end */
1793    }
1794
1795    .game-cards-container::-webkit-scrollbar {
1796        height: 4px;
1797    }
1798
1799    .game-cards-container::-webkit-scrollbar-thumb {
1800        background: var(--color-primary);
1801        border-radius: 10px;
1802    }
1803
1804    .game-card {
1805        flex: 0 0 140px;
1806        aspect-ratio: 3/4;
1807        background: var(--glass-bg);
1808        border: 2px solid var(--glass-border);
1809        border-radius: var(--radius-md);
1810        overflow: hidden;
1811        position: relative;
1812        cursor: pointer;
1813        transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1814        will-change: transform, border-color, box-shadow;
1815        transform: translateZ(0);
1816        display: flex;
1817        flex-direction: column;
1818        justify-content: flex-end;
1819        scroll-snap-align: start;
1820    }
1821
1822    .game-card img {
1823        position: absolute;
1824        top: 0;
1825        left: 0;
1826        width: 100%;
1827        height: 100%;
1828        object-fit: cover;
1829        transition: transform 0.5s ease, opacity 0.5s ease;
1830        will-change: transform, opacity;
1831        z-index: 1;
1832        opacity: 0;
1833    }
1834
1835    .game-card img.loaded {
1836        opacity: 1;
1837    }
1838
1839    .game-placeholder {
1840        position: absolute;
1841        top: 0;
1842        left: 0;
1843        width: 100%;
1844        height: 100%;
1845        display: flex;
1846        align-items: center;
1847        justify-content: center;
1848        background: var(--glass-bg-subtle);
1849        color: var(--color-text-muted);
1850        z-index: 0;
1851        opacity: 0.5;
1852    }
1853
1854    .game-card .game-overlay {
1855        position: relative;
1856        z-index: 2;
1857        background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
1858        padding: 0.75rem 0.5rem;
1859        width: 100%;
1860    }
1861
1862    .game-card .game-title {
1863        color: white;
1864        font-size: 0.8rem;
1865        font-weight: 700;
1866        text-align: center;
1867        white-space: nowrap;
1868        overflow: hidden;
1869        text-overflow: ellipsis;
1870        text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
1871    }
1872
1873    .game-card:hover {
1874        transform: translateY(-8px) scale(1.05);
1875        border-color: var(--color-primary);
1876        box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
1877    }
1878
1879    .game-card:hover img {
1880        transform: scale(1.1);
1881    }
1882
1883    .game-card.active {
1884        border-color: var(--color-primary);
1885        box-shadow: 0 0 0 3px var(--color-primary);
1886    }
1887
1888    .game-card.active .game-overlay {
1889        background: var(--color-primary);
1890    }
1891
1892    .game-card.all-card {
1893        justify-content: center;
1894        align-items: center;
1895        background: var(--glass-bg-subtle);
1896    }
1897
1898    .game-card.all-card i {
1899        font-size: 2rem;
1900        color: var(--color-primary);
1901        margin-bottom: 0.5rem;
1902    }
1903
1904    .game-card.all-card .all-card-grid {
1905        position: absolute;
1906        inset: 0;
1907        display: grid;
1908        grid-template-columns: 1fr 1fr;
1909        grid-template-rows: 1fr 1fr;
1910        z-index: 1;
1911        background: var(--glass-bg-subtle);
1912    }
1913
1914    .game-card.all-card .all-card-grid > div {
1915        position: relative;
1916        overflow: hidden;
1917        background: var(--glass-bg-subtle);
1918    }
1919
1920    .game-card.all-card .all-card-grid img {
1921        position: absolute;
1922        inset: 0;
1923        width: 100%;
1924        height: 100%;
1925        object-fit: cover;
1926        opacity: 0;
1927        transition: opacity 0.4s ease;
1928    }
1929
1930    .game-card.all-card .all-card-grid img.loaded {
1931        opacity: 1;
1932    }
1933
1934    .scroll-btn {
1935        position: absolute;
1936        top: 50%;
1937        transform: translateY(-50%);
1938        width: 40px;
1939        height: 40px;
1940        border-radius: 50%;
1941        background: var(--glass-bg);
1942        backdrop-filter: blur(12px);
1943        -webkit-backdrop-filter: blur(12px);
1944        border: 1px solid var(--glass-border);
1945        color: var(--color-text);
1946        display: flex;
1947        align-items: center;
1948        justify-content: center;
1949        cursor: pointer;
1950        z-index: 10;
1951        transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1952        opacity: 0;
1953        pointer-events: none;
1954        box-shadow: var(--glass-shadow);
1955    }
1956
1957    @keyframes pulse-glow {
1958        0% {
1959            box-shadow: 0 0 0 0 hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.6);
1960            border-color: var(--color-primary);
1961        }
1962
1963        70% {
1964            box-shadow: 0 0 0 10px hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0);
1965            border-color: var(--color-primary);
1966        }
1967
1968        100% {
1969            box-shadow: 0 0 0 0 hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0);
1970        }
1971    }
1972
1973    .scroll-btn.loading-glow {
1974        opacity: 1 !important;
1975        pointer-events: auto !important;
1976        animation: pulse-glow 1.5s infinite;
1977        color: var(--color-primary) !important;
1978        background: var(--glass-bg-hover) !important;
1979        border-color: var(--color-primary) !important;
1980    }
1981
1982    .game-cards-wrapper:hover .scroll-btn {
1983        opacity: 1;
1984        pointer-events: auto;
1985    }
1986
1987    .scroll-btn:hover {
1988        background: var(--color-primary);
1989        color: white;
1990        border-color: var(--color-primary);
1991        box-shadow: 0 6px 16px -4px hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.4);
1992        transform: translateY(-50%) scale(1.1);
1993    }
1994
1995    .scroll-btn.left {
1996        left: -20px;
1997    }
1998
1999    .scroll-btn.right {
2000        right: -20px;
2001    }
2002
2003    @media (max-width: 1200px) {
2004        .scroll-btn.left {
2005            left: 5px;
2006        }
2007
2008        .scroll-btn.right {
2009            right: 5px;
2010        }
2011    }
2012
2013    @media (max-width: 991px) {
2014        .scroll-btn {
2015            display: none !important;
2016        }
2017    }
2018
2019    body:not(.dark-theme) .ss-poster-img {
2020        filter: invert(1) hue-rotate(180deg);
2021    }
2022
2023    /* Skeleton utilities (.skeleton / .skeleton-text) now live in style.css. */
2024
2025    /* ── Logged-In Greeting ── */
2026    /* No text-align here on purpose: the greeting inherits it from
2027       .hero-content, so it centres in the stacked layout and sits on the
2028       leading edge in the wide two-column hero. */
2029    .hero-greeting-wrapper {
2030        margin-block-start: 2.5rem;
2031        margin-block-end: 2rem;
2032    }
2033
2034    .hero-greeting-label {
2035        font-size: 0.9rem;
2036        font-weight: 700;
2037        letter-spacing: 3px;
2038        text-transform: uppercase;
2039        color: var(--color-primary);
2040        margin-bottom: 0.75rem;
2041        min-height: 1.4rem;
2042    }
2043
2044    .hero-greeting-name {
2045        font-family: 'Rajdhani', sans-serif;
2046        font-size: clamp(2.5rem, 8vw, 5rem);
2047        font-weight: 700;
2048        line-height: 1.1;
2049        text-transform: uppercase;
2050        letter-spacing: -0.02em;
2051        background-image: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 50%, var(--color-primary-hover) 100%);
2052        -webkit-background-clip: text !important;
2053        background-clip: text !important;
2054        -webkit-text-fill-color: transparent !important;
2055        filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.1));
2056    }
2057
2058
2059    @media (max-width: 768px) {
2060        .hero-greeting-name {
2061            font-size: clamp(2rem, 10vw, 3.5rem);
2062        }
2063        .hero-greeting-wrapper {
2064            margin-block-end: 1.5rem;
2065        }
2066    }
2067</style>
2068
2069<!-- ========= STRUCTURED DATA: WebApplication (homepage only) ========= -->
2070<script type="application/ld+json">
2071{
2072  "@context": "https://schema.org",
2073  "@type": "WebApplication",
2074  "@id": "https://streamsnip.com/#app",
2075  "name": "StreamSnip",
2076  "url": "https://streamsnip.com",
2077  "description": "StreamSnip enables YouTube livestreamers to clip highlight moments in real time using a simple !clip chat command. Supports Nightbot integration, Discord notifications, and viewer-driven clipping.",
2078  "applicationCategory": "MultimediaApplication",
2079  "operatingSystem": "Web",
2080  "browserRequirements": "Requires JavaScript. Works in any modern browser.",
2081  "inLanguage": "en",
2082  "featureList": [
2083    "Instant YouTube livestream clipping via chat command",
2084    "Nightbot integration with !clip command",
2085    "Discord clip notifications",
2086    "Clip feed and public export pages",
2087    "Chrome extension for clip management",
2088    "Viewer-driven clipping without streamer intervention"
2089  ],
2090  "offers": [
2091    {
2092      "@type": "Offer",
2093      "name": "Free Plan",
2094      "price": "0",
2095      "priceCurrency": "USD",
2096      "description": "The !clip command with a limit of 6 clips per stream."
2097    },
2098    {
2099      "@type": "Offer",
2100      "name": "Premium Membership",
2101      "url": "https://streamsnip.com/membership",
2102      "description": "Premium tier with advanced features, Discord integration, and priority support."
2103    }
2104  ],
2105  "publisher": {
2106    "@id": "https://streamsnip.com/#organization"
2107  },
2108  "screenshot": "https://streamsnip.com/static/logo.png"
2109}
2110</script>
2110
2111
2112<!-- Mirrors the visible "How to clip a YouTube livestream" section below. Keep the two in sync. -->
2113<script type="application/ld+json">
2114{
2115  "@context": "https://schema.org",
2116  "@type": "HowTo",
2117  "name": "How to clip a YouTube livestream",
2118  "description": "Add a !clip chat command to your YouTube livestream so viewers capture your highlights in real time.",
2119  "totalTime": "PT5M",
2120  "step": [
2121    {
2122      "@type": "HowToStep",
2123      "position": 1,
2124      "name": "Add the !clip command",
2125      "text": "Connect your channel and add the StreamSnip !clip command to your YouTube chat with Nightbot in a couple of minutes.",
2126      "url": "https://streamsnip.com/get-started"
2127    },
2128    {
2129      "@type": "HowToStep",
2130      "position": 2,
2131      "name": "Your viewers clip for you",
2132      "text": "Anyone in chat types !clip when something great happens. StreamSnip grabs the moment from your live VOD automatically.",
2133      "url": "https://streamsnip.com/clip-command-youtube"
2134    },
2135    {
2136      "@type": "HowToStep",
2137      "position": 3,
2138      "name": "Clips appear instantly",
2139      "text": "Every clip lands in your StreamSnip feed and your Discord, ready to download, share to Shorts, or browse later.",
2140      "url": "https://streamsnip.com/guides"
2141    }
2142  ]
2143}
2144</script>
2144
2145
2146</head>
2147
2148<body>
2149  
2149<script>
2150    // Mirror the early <html> dark-theme class onto <body> so existing `body.dark-theme` rules apply before first paint.
2151    if (document.documentElement.classList.contains('dark-theme')) document.body.classList.add('dark-theme');
2152  </script>
2152
2153
2154  <!-- ssIsTypingTarget / ssRegisterShortcuts. Not deferred: page scripts that bind
2155       shortcuts at parse time call into it. -->
2156  
2156<script src="/static/js/shortcuts.js?v=c98906de16"></script>
2156
2157  <!-- ssConfirm — themed replacement for window.confirm(). -->
2158  
2158<script src="/static/js/confirm.js?v=8a2fd43037" defer></script>
2158
2159
2160  <!-- ===== Reusable clapper loader (single source of truth) =====
2161       Server-rendered: <div data-clap-loader data-size="48" data-label="…"></div> (auto-hydrated).
2162       JS-injected:     window.clapLoaderHTML({size, label, inline}) / window.makeClapLoader(...). -->
2163  <template id="ss-clap-tpl">
2164    <svg class="ss-clap" viewBox="0 0 3334 3334" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
2165      <path class="ss-clap-stick" fill-rule="evenodd" d="M 2630.692 408.528 C 2630.622 408.998 2932.538 739.198 2932.538 739.198 L 2935.557 411.334 M 2329.824 405.758 C 2329.564 405.972 1890.172 401.018 1890.292 401.711 C 1890.412 402.336 2203.145 732.254 2205.705 732.506 C 2207.355 732.67 2629.269 736.133 2629.769 735.802 M 1532.807 398.421 C 1532.557 398.615 1083.58 393.355 1083.68 394.286 C 1083.76 395.084 1399.75 724.278 1400.29 724.803 C 1400.82 725.329 1835.934 729.102 1835.931 729.103 M 471.788 388.873 L 468.835 716.516 C 468.835 716.516 1089.13 725.939 1089.05 724.765 C 1088.98 723.601 779.835 391.763 776.285 391.456"/>
2166      <path class="ss-clap-board" fill-rule="evenodd"
2167        transform="matrix(0.95517403, -0.29604402, 0.29604402, 0.95517403, -456.95786825, 588.0155987)"
2168        d="M 524.433 1432.53 C 365.242 1947.64 234.251 2372.63 233.351 2376.95 C 232.451 2381.27 231.481 2389.75 231.191 2395.8 C 230.841 2403.26 231.181 2410.12 232.261 2417.13 C 233.131 2422.81 235.451 2432.26 237.411 2438.13 C 239.381 2444 242.971 2452.44 245.401 2456.9 C 247.831 2461.35 251.911 2467.88 254.481 2471.4 C 257.041 2474.92 262.871 2481.46 267.431 2485.94 C 271.991 2490.42 279.241 2496.41 283.551 2499.25 C 287.851 2502.1 295.001 2506.17 299.451 2508.31 C 305.091 2511.02 623.563 2609.86 1356.05 2836.23 C 1932.73 3014.45 2408.35 3161.15 2412.97 3162.23 C 2417.59 3163.32 2425.42 3164.74 2430.37 3165.38 C 2436.04 3166.13 2445.47 3166.35 2455.87 3165.98 C 2466.63 3165.6 2475.74 3164.68 2482.05 3163.34 C 2487.37 3162.21 2495.92 3159.92 2501.05 3158.25 C 2506.18 3156.57 2515.08 3152.96 2520.84 3150.21 C 2526.6 3147.46 2535.6 3142.35 2540.85 3138.86 C 2546.09 3135.37 2554.2 3129.17 2558.87 3125.08 C 2563.55 3121 2570.58 3113.86 2574.51 3109.23 C 2578.43 3104.59 2584.16 3097.03 2587.23 3092.43 C 2590.31 3087.83 2595.04 3079.5 2597.75 3073.93 C 2600.47 3068.36 2604.3 3058.85 2606.28 3052.8 C 2608.26 3046.75 2741.7 2615.21 2902.81 2093.83 C 3063.93 1572.44 3198.395 1137.842 3198.215 1137.672 C 3198.045 1137.492 2817.49 1343.35 2817.49 1343.35 C 2817.49 1343.35 2528.38 1254.14 2528.24 1254 C 2528.1 1253.86 2909.86 1047.81 2908.62 1047.27 C 2907.38 1046.73 2503.85 922.031 2503.22 921.811 C 2502.59 921.601 2109.55 1124.54 2109.55 1124.54 C 2109.55 1124.54 1767.13 1018.87 1765.62 1018.58 C 1764.11 1018.3 2151.16 813.221 2150.17 812.831 C 2149.18 812.441 1730.94 683.301 1730.12 683.301 C 1729.31 683.301 1363.89 871.901 1354.74 876.651 C 1345.59 881.401 1337.18 885.291 1336.04 885.291 C 1334.9 885.291 1062.03 800.871 1055.5 798.831 C 1048.97 796.791 1044.03 794.771 1044.5 794.341 C 1044.98 793.921 1433.417 591.777 1433.197 590.627 C 1432.977 589.477 881.045 419.444 844.455 408.154 M 943.532 525.271 C 940.872 525.761 935.492 527.121 931.582 528.311 C 927.672 529.501 921.022 532.201 916.812 534.321 C 912.602 536.441 905.722 540.901 901.522 544.241 C 897.322 547.581 891.112 553.791 887.712 558.051 C 884.182 562.481 879.512 570.071 876.812 575.791 C 874.201 581.291 871.321 588.721 870.391 592.291 C 869.471 595.871 868.471 603.971 868.181 610.291 C 867.781 618.801 868.071 624.131 869.331 630.771 C 870.261 635.701 872.341 643.171 873.961 647.361 C 875.572 651.551 879.542 658.981 882.772 663.891 C 886.012 668.781 891.532 675.561 895.052 678.931 C 898.582 682.311 904.952 687.341 909.212 690.131 C 913.472 692.911 920.422 696.521 924.662 698.151 C 928.902 699.781 935.842 701.831 940.082 702.711 C 944.322 703.581 952.322 704.301 957.872 704.301 C 963.422 704.301 971.422 703.581 975.662 702.711 C 979.902 701.831 986.842 699.781 991.082 698.151 C 995.322 696.521 1002.27 692.911 1006.53 690.131 C 1010.79 687.341 1017.16 682.311 1020.67 678.931 C 1024.19 675.561 1029.48 669.171 1032.43 664.741 C 1035.38 660.311 1039.22 653.341 104
21680.96 649.241 C 1042.7 645.151 1044.96 637.741 1045.99 632.791 C 1047.01 627.841 1047.85 619.971 1047.85 615.291 C 1047.85 610.621 1047.39 603.871 1046.83 600.291 C 1046.26 596.721 1044.86 590.871 1043.7 587.291 C 1042.54 583.721 1039.88 577.371 1037.79 573.181 C 1035.7 569.001 1031.26 562.141 1027.92 557.941 C 1024.59 553.741 1018.58 547.701 1014.58 544.521 C 1010.58 541.341 1003.7 536.861 999.292 534.571 C 994.882 532.281 987.242 529.211 982.322 527.761 C 975.302 525.691 970.682 525.041 960.872 524.761 C 953.992 524.561 946.192 524.791 943.532 525.271 M 1097.12 1239.09 C 964.982 1320.88 856.881 1388.02 856.881 1388.3 C 856.891 1388.57 1003.82 1573.48 1183.41 1799.2 L 1509.93 2209.59 L 1427.65 2264.44 L 1345.37 2319.28 L 1152.07 2252.4 C 1045.75 2215.62 958.492 2185.25 958.152 2184.91 C 957.812 2184.57 957.722 2184.15 957.952 2183.96 C 958.182 2183.78 1016.2 2150.17 1086.87 2109.29 C 1157.55 2068.4 1216.6 2034.2 1218.11 2033.29 L 1220.84 2031.64 L 1145.29 1932.72 C 1103.73 1878.31 1069.31 1833.91 1068.79 1834.05 C 1068.26 1834.2 914.962 1929.76 728.101 2046.42 C 541.253 2163.08 387.822 2259.04 387.162 2259.66 C 386.242 2260.52 508.042 2300.95 886.112 2425.3 C 1161.21 2515.77 1386.88 2589.8 1387.61 2589.8 C 1388.34 2589.8 1495.56 2520.05 1625.87 2434.8 C 1759.06 2347.67 1862.63 2279.29 1862.4 2278.63 C 1862.18 2277.99 1715.65 2093.49 1536.79 1868.63 C 1357.93 1643.77 1211.2 1459.31 1210.74 1458.71 C 1210.18 1457.99 1237.69 1439.08 1292.07 1402.83 L 1374.26 1348.04 L 1468.67 1372.7 L 1563.09 1397.36 L 1687.73 1320.88 C 1756.28 1278.81 1813.17 1243.81 1814.16 1243.08 C 1815.7 1241.95 1784.17 1231.67 1577.66 1166 C 1446.6 1124.32 1338.92 1090.25 1338.37 1090.29 C 1337.82 1090.33 1229.26 1157.29 1097.12 1239.09 M 1717.29 1441.36 C 1586.98 1526.65 1480.26 1596.74 1480.14 1597.11 C 1480.01 1597.49 1626.84 1782.47 1806.43 2008.19 L 2132.96 2418.58 L 2050.66 2473.42 L 1968.37 2528.27 L 1874.5 2503.73 C 1822.87 2490.24 1780.1 2479.33 1779.45 2479.5 C 1778.8 2479.66 1721.93 2514.38 1653.07 2556.65 C 1584.21 2598.92 1527.87 2633.8 1527.87 2634.17 C 1527.87 2634.54 1635.09 2668.96 1766.14 2710.66 L 2004.42 2786.47 L 2245.1 2637.64 C 2388.34 2549.07 2485.63 2488.31 2485.38 2487.59 C 2485.15 2486.93 2338.63 2302.43 2159.78 2077.59 C 1980.93 1852.76 1834.22 1668.31 1833.75 1667.71 C 1833.18 1666.98 1860.5 1648.21 1915.06 1611.84 L 1997.23 1557.05 L 2012.3 1562.22 C 2020.59 1565.06 2107.62 1595.15 2205.7 1629.09 C 2303.78 1663.03 2384.03 1691.25 2384.02 1691.8 C 2384.02 1692.35 2325.36 1726.77 2253.66 1768.3 C 2181.96 1809.82 2123.09 1844.11 2122.84 1844.5 C 2122.58 1844.88 2156.34 1889.71 2197.87 1944.11 L 2273.37 2043.01 L 2279.37 2039.25 C 2282.67 2037.17 2436.12 1941.35 2620.37 1826.31 C 2804.62 1711.26 2955.75 1616.77 2956.21 1616.33 C 2956.67 1615.89 2952.84 1614.14 2947.71 1612.45 C 2942.57 1610.76 2717.3 1536.69 2447.11 1447.84 C 2176.92 1358.99 1955.48 1286.3 1955.03 1286.3 C 1954.57 1286.3 1847.59 1356.08 1717.29 1441.36"/>
2169    </svg>
2170  </template>
2171  
2171<script>
2172    (function () {
2173      window.makeClapLoader = function (opts) {
2174        opts = opts || {};
2175        const tpl = document.getElementById('ss-clap-tpl');
2176        const box = document.createElement('div');
2177        box.className = 'clap-loader-box' + (opts.inline ? ' inline' : '');
2178        if (tpl && tpl.content.firstElementChild) {
2179          const svg = tpl.content.firstElementChild.cloneNode(true);
2180          const size = opts.size || 48;
2181          svg.style.width = size + 'px';
2182          svg.style.height = size + 'px';
2183          box.appendChild(svg);
2184        }
2185        if (opts.label) {
2186          const l = document.createElement('div');
2187          l.className = 'clap-loader-label';
2188          l.textContent = opts.label;
2189          box.appendChild(l);
2190        }
2191        return box;
2192      };
2193      window.clapLoaderHTML = function (opts) {
2194        const el = window.makeClapLoader(opts);
2195        return el ? el.outerHTML : '';
2196      };
2197      // Just the clapper SVG (no box/label) for replacing inline <i> spinners inside text.
2198      window.clapSvgHTML = function (size) {
2199        const tpl = document.getElementById('ss-clap-tpl');
2200        if (!tpl || !tpl.content.firstElementChild) return '';
2201        const svg = tpl.content.firstElementChild.cloneNode(true);
2202        const s = size || 20;
2203        svg.style.width = s + 'px'; svg.style.height = s + 'px';
2204        svg.style.display = 'inline-block'; svg.style.verticalAlign = 'middle'; svg.style.marginRight = '.45em';
2205        return svg.outerHTML;
2206      };
2207      function hydrate(root) {
2208        (root || document).querySelectorAll('[data-clap-loader]:not([data-clap-done])').forEach(function (el) {
2209          el.setAttribute('data-clap-done', '1');
2210          el.appendChild(window.makeClapLoader({
2211            size: parseInt(el.getAttribute('data-size'), 10) || 48,
2212            label: el.getAttribute('data-label') || '',
2213            inline: el.hasAttribute('data-inline')
2214          }));
2215        });
2216      }
2217      window.hydrateClapLoaders = hydrate;
2218      if (document.readyState === 'loading') {
2219        document.addEventListener('DOMContentLoaded', function () { hydrate(); });
2220      } else { hydrate(); }
2221    })();
2222  </script>
2222
2223
2224  <!-- ========= NAVBAR ========= -->
2225  <header class="fixed-top glass-nav">
2226    <div class="container">
2227      <nav class="navbar navbar-expand-lg navbar-dark" id="navbar_id">
2228        <a class="navbar-brand d-flex align-items-center" href="/">
2229          <div class="me-2 site_icon" id="site_icon" style="width:36px;height:36px;flex-shrink:0;overflow:visible;cursor:pointer;">
2230            <svg id="_nbl_svg" viewBox="0 0 3334 3334" width="36" height="36" xmlns="http://www.w3.org/2000/svg" style="overflow:visible;display:block;">
2231              <style>
2232                @keyframes _nbl_clap {
2233                  0%   { transform: rotate(-17deg); animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
2234                  18%  { transform: rotate(-57deg); animation-timing-function: linear; }
2235                  38%  { transform: rotate(-57deg); animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); }
2236                  51%  { transform: rotate(0deg);   animation-timing-function: cubic-bezier(0, 0, 0.3, 1); }
2237                  58%  { transform: rotate(-8deg);  animation-timing-function: cubic-bezier(0.25, 1, 0.5, 1); }
2238                  66%  { transform: rotate(0deg);   animation-timing-function: linear; }
2239                  80%  { transform: rotate(0deg);   animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
2240                  100% { transform: rotate(-17deg); }
2241                }
2242                #_nbl_stick {
2243                  transform-origin: 958px 614px;
2244                  transform: rotate(-17deg);
2245                }
2246                #_nbl_stick._nbl_play {
2247                  animation: _nbl_clap 2.6s linear 1 forwards;
2248                }
2249              </style>
2250              <!-- Punch the hinge hole through the icon so the stick can't be seen through it
2251                   as it claps. SVG mask (true transparency for the glass navbar); region is set
2252                   huge so the swinging stick is never clipped. Circle = the board hole in
2253                   user space (cx 640, cy 891, r 112 of the 3334 viewBox). -->
2254              <mask id="_nbl_holemask" maskUnits="userSpaceOnUse" x="-2000" y="-2000" width="8000" height="8000">
2255                <rect x="-2000" y="-2000" width="8000" height="8000" fill="#fff"/>
2256                <circle cx="640" cy="891" r="112" fill="#000"/>
2257              </mask>
2258              <g mask="url(#_nbl_holemask)">
2259              <path id="_nbl_stick" fill-rule="evenodd" paint-order="stroke fill"
2260                transform="matrix(0.999958, -0.009206, 0.009206, 0.999958, 0.019399, 0.012944)"
2261                d="M 2630.692 408.528 C 2630.622 408.998 2932.538 739.198 2932.538 739.198 L 2935.557 411.334 M 2329.824 405.758 C 2329.564 405.972 1890.172 401.018 1890.292 401.711 C 1890.412 402.336 2203.145 732.254 2205.705 732.506 C 2207.355 732.67 2629.269 736.133 2629.769 735.802 M 1532.807 398.421 C 1532.557 398.615 1083.58 393.355 1083.68 394.286 C 1083.76 395.084 1399.75 724.278 1400.29 724.803 C 1400.82 725.329 1835.934 729.102 1835.931 729.103 M 471.788 388.873 L 468.835 716.516 C 468.835 716.516 1089.13 725.939 1089.05 724.765 C 1088.98 723.601 779.835 391.763 776.285 391.456"/>
2262              <path id="_nbl_clapper" fill-rule="evenodd" paint-order="stroke fill"
2263                transform="matrix(0.95517403, -0.29604402, 0.29604402, 0.95517403, -456.95786825, 588.0155987)"
2264                d="M 524.433 1432.53 C 365.242 1947.64 234.251 2372.63 233.351 2376.95 C 232.451 2381.27 231.481 2389.75 231.191 2395.8 C 230.841 2403.26 231.181 2410.12 232.261 2417.13 C 233.131 2422.81 235.451 2432.26 237.411 2438.13 C 239.381 2444 242.971 2452.44 245.401 2456.9 C 247.831 2461.35 251.911 2467.88 254.481 2471.4 C 257.041 2474.92 262.871 2481.46 267.431 2485.94 C 271.991 2490.42 279.241 2496.41 283.551 2499.25 C 287.851 2502.1 295.001 2506.17 299.451 2508.31 C 305.091 2511.02 623.563 2609.86 1356.05 2836.23 C 1932.73 3014.45 2408.35 3161.15 2412.97 3162.23 C 2417.59 3163.32 2425.42 3164.74 2430.37 3165.38 C 2436.04 3166.13 2445.47 3166.35 2455.87 3165.98 C 2466.63 3165.6 2475.74 3164.68 2482.05 3163.34 C 2487.37 3162.21 2495.92 3159.92 2501.05 3158.25 C 2506.18 3156.57 2515.08 3152.96 2520.84 3150.21 C 2526.6 3147.46 2535.6 3142.35 2540.85 3138.86 C 2546.09 3135.37 2554.2 3129.17 2558.87 3125.08 C 2563.55 3121 2570.58 3113.86 2574.51 3109.23 C 2578.43 3104.59 2584.16 3097.03 2587.23 3092.43 C 2590.31 3087.83 2595.04 3079.5 2597.75 3073.93 C 2600.47 3068.36 2604.3 3058.85 2606.28 3052.8 
2264C 2608.26 3046.75 2741.7 2615.21 2902.81 2093.83 C 3063.93 1572.44 3198.395 1137.842 3198.215 1137.672 C 3198.045 1137.492 2817.49 1343.35 2817.49 1343.35 C 2817.49 1343.35 2528.38 1254.14 2528.24 1254 C 2528.1 1253.86 2909.86 1047.81 2908.62 1047.27 C 2907.38 1046.73 2503.85 922.031 2503.22 921.811 C 2502.59 921.601 2109.55 1124.54 2109.55 1124.54 C 2109.55 1124.54 1767.13 1018.87 1765.62 1018.58 C 1764.11 1018.3 2151.16 813.221 2150.17 812.831 C 2149.18 812.441 1730.94 683.301 1730.12 683.301 C 1729.31 683.301 1363.89 871.901 1354.74 876.651 C 1345.59 881.401 1337.18 885.291 1336.04 885.291 C 1334.9 885.291 1062.03 800.871 1055.5 798.831 C 1048.97 796.791 1044.03 794.771 1044.5 794.341 C 1044.98 793.921 1433.417 591.777 1433.197 590.627 C 1432.977 589.477 881.045 419.444 844.455 408.154 M 943.532 525.271 C 940.872 525.761 935.492 527.121 931.582 528.311 C 927.672 529.501 921.022 532.201 916.812 534.321 C 912.602 536.441 905.722 540.901 901.522 544.241 C 897.322 547.581 891.112 553.791 887.712 558.051 C 884.182 562.481 879.512 570.071 876.812 575.791 C 874.201 581.291 871.321 588.721 870.391 592.291 C 869.471 595.871 868.471 603.971 868.181 610.291 C 867.781 618.801 868.071 624.131 869.331 630.771 C 870.261 635.701 872.341 643.171 873.961 647.361 C 875.572 651.551 879.542 658.981 882.772 663.891 C 886.012 668.781 891.532 675.561 895.052 678.931 C 898.582 682.311 904.952 687.341 909.212 690.131 C 913.472 692.911 920.422 696.521 924.662 698.151 C 928.902 699.781 935.842 701.831 940.082 702.711 C 944.322 703.581 952.322 704.301 957.872 704.301 C 963.422 704.301 971.422 703.581 975.662 702.711 C 979.902 701.831 986.842 699.781 991.082 698.151 C 995.322 696.521 1002.27 692.911 1006.53 690.131 C 1010.79 687.341 1017.16 682.311 1020.67 678.931 C 1024.19 675.561 1029.48 669.171 1032.43 664.741 C 1035.38 660.311 1039.22 653.341 104
22640.96 649.241 C 1042.7 645.151 1044.96 637.741 1045.99 632.791 C 1047.01 627.841 1047.85 619.971 1047.85 615.291 C 1047.85 610.621 1047.39 603.871 1046.83 600.291 C 1046.26 596.721 1044.86 590.871 1043.7 587.291 C 1042.54 583.721 1039.88 577.371 1037.79 573.181 C 1035.7 569.001 1031.26 562.141 1027.92 557.941 C 1024.59 553.741 1018.58 547.701 1014.58 544.521 C 1010.58 541.341 1003.7 536.861 999.292 534.571 C 994.882 532.281 987.242 529.211 982.322 527.761 C 975.302 525.691 970.682 525.041 960.872 524.761 C 953.992 524.561 946.192 524.791 943.532 525.271 M 1097.12 1239.09 C 964.982 1320.88 856.881 1388.02 856.881 1388.3 C 856.891 1388.57 1003.82 1573.48 1183.41 1799.2 L 1509.93 2209.59 L 1427.65 2264.44 L 1345.37 2319.28 L 1152.07 2252.4 C 1045.75 2215.62 958.492 2185.25 958.152 2184.91 C 957.812 2184.57 957.722 2184.15 957.952 2183.96 C 958.182 2183.78 1016.2 2150.17 1086.87 2109.29 C 1157.55 2068.4 1216.6 2034.2 1218.11 2033.29 L 1220.84 2031.64 L 1145.29 1932.72 C 1103.73 1878.31 1069.31 1833.91 1068.79 1834.05 C 1068.26 1834.2 914.962 1929.76 728.101 2046.42 C 541.253 2163.08 387.822 2259.04 387.162 2259.66 C 386.242 2260.52 508.042 2300.95 886.112 2425.3 C 1161.21 2515.77 1386.88 2589.8 1387.61 2589.8 C 1388.34 2589.8 1495.56 2520.05 1625.87 2434.8 C 1759.06 2347.67 1862.63 2279.29 1862.4 2278.63 C 1862.18 2277.99 1715.65 2093.49 1536.79 1868.63 C 1357.93 1643.77 1211.2 1459.31 1210.74 1458.71 C 1210.18 1457.99 1237.69 1439.08 1292.07 1402.83 L 1374.26 1348.04 L 1468.67 1372.7 L 1563.09 1397.36 L 1687.73 1320.88 C 1756.28 1278.81 1813.17 1243.81 1814.16 1243.08 C 1815.7 1241.95 1784.17 1231.67 1577.66 1166 C 1446.6 1124.32 1338.92 1090.25 1338.37 1090.29 C 1337.82 1090.33 1229.26 1157.29 1097.12 1239.09 M 1717.29 1441.36 C 1586.98 1526.65 1480.26 1596.74 1480.14 1597.11 C 1480.01 1597.49 1626.84 1782.47 1806.43 2008.19 L 2132.96 2418.58 L 2050.66 2473.42 L 1968.37 2528.27 L 1874.5 2503.73 C 1822.87 2490.24 1780.1 2479.33 1779.45 2479.5 C 1778.8 2479.66 1721.93 2514.38 1653.07 2556.65 C 1584.21 2598.92 1527.87 2633.8 1527.87 2634.17 C 1527.87 2634.54 1635.09 2668.96 1766.14 2710.66 L 2004.42 2786.47 L 2245.1 2637.64 C 2388.34 2549.07 2485.63 2488.31 2485.38 2487.59 C 2485.15 2486.93 2338.63 2302.43 2159.78 2077.59 C 1980.93 1852.76 1834.22 1668.31 1833.75 1667.71 C 1833.18 1666.98 1860.5 1648.21 1915.06 1611.84 L 1997.23 1557.05 L 2012.3 1562.22 C 2020.59 1565.06 2107.62 1595.15 2205.7 1629.09 C 2303.78 1663.03 2384.03 1691.25 2384.02 1691.8 C 2384.02 1692.35 2325.36 1726.77 2253.66 1768.3 C 2181.96 1809.82 2123.09 1844.11 2122.84 1844.5 C 2122.58 1844.88 2156.34 1889.71 2197.87 1944.11 L 2273.37 2043.01 L 2279.37 2039.25 C 2282.67 2037.17 2436.12 1941.35 2620.37 1826.31 C 2804.62 1711.26 2955.75 1616.77 2956.21 1616.33 C 2956.67 1615.89 2952.84 1614.14 2947.71 1612.45 C 2942.57 1610.76 2717.3 1536.69 2447.11 1447.84 C 2176.92 1358.99 1955.48 1286.3 1955.03 1286.3 C 1954.57 1286.3 1847.59 1356.08 1717.29 1441.36"/>
2265              </g>
2266            </svg>
2267          </div>
2268          <span class="fw-bold tracking-tight d-none d-sm-inline">StreamSnip</span>
2269        </a>
2270        <button class="navbar-toggler border-0 shadow-none d-lg-none d-flex align-items-center justify-content-center"
2271          type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
2272          aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"
2273          style="width: 44px; height: 44px; padding: 0;">
2274          <div class="hamburger-icon">
2275            <span></span>
2276            <span></span>
2277            <span></span>
2278          </div>
2279        </button>
2280
2281        <div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
2282          <ul class="navbar-nav align-items-lg-center gap-1">
2283            <li class="nav-item">
2284              <a class="nav-link px-3 highlight-link d-flex align-items-center" href="/snips" title="View Clips Feed">
2285                <i class="fa-solid fa-play me-2" aria-hidden="true"></i> Snips
2286              </a>
2287            </li>
2288
2289            <li class="nav-item">
2290              <a class="nav-link px-3 highlight-link d-flex align-items-center" href="/get-started"
2291                title="How to use StreamSnip">
2292                <i class="fa-solid fa-rocket me-2 d-lg-none" aria-hidden="true"></i> Get Started
2293              </a>
2294            </li>
2295
2296            <li class="nav-item dropdown">
2297              <a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="resourcesDropdown"
2298                role="button" data-bs-toggle="dropdown" aria-expanded="false" title="More Resources">
2299                <i class="fa-solid fa-folder-open me-2 d-lg-none" aria-hidden="true"></i> Resources
2300              </a>
2301              <ul class="dropdown-menu dropdown-menu-end glass" aria-labelledby="resourcesDropdown">
2302                <li><a class="dropdown-item" href="/settings#discord"><i class="fa-solid fa-link"
2303                      aria-hidden="true"></i> Connect
2304                    Discord</a></li>
2305                <li><a class="dropdown-item"
2306                    href="https://chromewebstore.google.com/detail/stream
2306snip/jpnlipcmklpfniaheflfnkomjdkhiani"><i
2307                      class="fa-solid fa-puzzle-piece" aria-hidden="true"></i> Chrome Extension</a></li>
2308                <li><a class="dropdown-item" href="/stats"><i class="fa-solid fa-chart-line" aria-hidden="true"></i>
2309                    Dashboard Stats</a>
2310                </li>
2311                <li><a class="dropdown-item" href="/all_games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>
2312                    All Games</a></li>
2313                <li><a class="dropdown-item" href="/guides"><i class="fa-solid fa-book" aria-hidden="true"></i>
2314                    Guides</a></li>
2315                <li><a class="dropdown-item" href="/ytdlp-builder"><i class="fa-solid fa-terminal" aria-hidden="true"></i>
2316                    yt-dlp Builder</a></li>
2317                <li><a class="dropdown-item" href="/kick_downloader"><img src="/static/kick-icon.png" alt="" style="height:1em;width:auto;vertical-align:-0.15em;"> Kick VOD Downloader</a></li>
2318                <li><a class="dropdown-item" href="/valorant-pov"><i class="fa-solid fa-crosshairs" aria-hidden="true"></i> Valorant POV Finder</a></li>
2319                <li><a class="dropdown-item" href="/overlays"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> Stream Overlays</a></li>
2320                <li><a class="dropdown-item" href="https://streamsnip.cronitorstatus.com" target="_blank"><i
2321                      class="fa-solid fa-signal" aria-hidden="true"></i> System Status</a></li>
2322              </ul>
2323
2324            </li>
2325
2326            <li class="nav-item">
2327              <a class="nav-link px-3 d-flex align-items-center" href="#" id="beamer-whats-new" title="What's New">
2328                <i class="fa-solid fa-bell me-2 d-lg-none" aria-hidden="true"></i> <span>What's New</span>
2329              </a>
2330            </li>
2331
2332            <li class="nav-item">
2333              <a class="nav-link px-3 d-flex align-items-center" href="https://discord.gg/2XVBWK99Vy"
2334                title="Join our Discord Community" aria-label="Discord Community">
2335                <i class="fa-brands fa-discord me-2" aria-hidden="true"></i> <span class="d-lg-none">Discord</span>
2336              </a>
2337            </li>
2338
2339            <li class="nav-item">
2340              <a class="nav-link px-3 d-flex align-items-center" href="#" id="report-bug-btn" role="button"
2341                title="Report a bug" aria-label="Report a bug" onclick="return false;">
2342                <i class="fa-solid fa-bug me-2" aria-hidden="true"></i> <span class="d-lg-none">Report a bug</span>
2343              </a>
2344            </li>
2345
2346            
2347
2348            <li class="nav-item">
2349              <a class="nav-link px-3 d-flex align-items-center" href="#" id="theme_button" onclick="toggleDarkTheme()"
2350                title="Toggle Light/Dark Theme" aria-label="Toggle Theme">
2351                <i class="fa-solid fa-moon fa-fw me-2" aria-hidden="true"></i> <span class="d-lg-none">Toggle Theme</span>
2352              </a>
2353            </li>
2354
2355            
2356            <li class="nav-item d-lg-none">
2357              <a class="nav-link px-3 d-flex align-items-center" href="/login?next=/">
2358                <i class="fa-solid fa-sign-in me-2" aria-hidden="true"></i> Login
2359              </a>
2360            </li>
2361            <li class="nav-item ms-lg-2 d-none d-lg-flex align-items-center">
2362              <a class="btn btn-primary btn-sm rounded-pill px-4" href="/login?next=/">Login <i
2363                  class="fa-solid fa-sign-in ms-1"></i></a>
2364            </li>
2365            
2366          </ul>
2367        </div>
2368
2369      </nav>
2370      <style>
2371        /* Modern Hamburger Menu */
2372        .hamburger-icon {
2373          width: 22px;
2374          height: 16px;
2375          position: relative;
2376          display: flex;
2377          flex-direction: column;
2378          justify-content: space-between;
2379        }
2380
2381        .hamburger-icon span {
2382          display: block;
2383          height: 2px;
2384          width: 100%;
2385          background-color: var(--color-text);
2386          border-radius: 4px;
2387          transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease;
2388          transform-origin: center center;
2389        }
2390
2391        .navbar-toggler[aria-expanded="true"] .hamburger-icon span:nth-child(1) {
2392          transform: translateY(7px) rotate(45deg);
2393        }
2394
2395        .navbar-toggler[aria-expanded="true"] .hamburger-icon span:nth-child(2) {
2396          opacity: 0;
2397          transform: scale(0);
2398        }
2399
2400        .navbar-toggler[aria-expanded="true"] .hamburger-icon span:nth-child(3) {
2401          transform: translateY(-7px) rotate(-45deg);
2402        }
2403
2404        /* Mobile Navbar Fix */
2405        @media (max-width: 991px) {
2406          .navbar-nav {
2407            align-items: flex-start !important;
2408            padding-top: 1rem;
2409          }
2410
2411          .navbar-nav .nav-item {
2412            width: 100%;
2413          }
2414
2415          .navbar-nav .nav-link {
2416            width: 100%;
2417            padding: 0.75rem 1rem !important;
2418          }
2419
2420          .navbar-collapse {
2421            background: var(--glass-bg) !important;
2422            backdrop-filter: blur(24px);
2423            -webkit-backdrop-filter: blur(24px);
2424            border-radius: var(--radius-lg);
2425            margin-top: 0.75rem;
2426            padding: 0.5rem;
2427            border: 1px solid var(--glass-border);
2428            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
2429          }
2430
2431          body .navbar-nav .dropdown-menu {
2432            background: var(--glass-bg) !important;
2433            background-color: var(--glass-bg) !important;
2434            backdrop-filter: blur(12px) !important;
2435            -webkit-backdrop-filter: blur(12px) !important;
2436            border: 1px solid var(--glass-border) !important;
2437            border-inline-start: 3px solid var(--color-primary) !important;
2438            margin-block: 0.5rem !important;
2439            margin-inline: 1rem 0 !important;
2440            padding: 0.5rem !important;
2441            box-shadow: none !important;
2442            position: static !important;
2443            float: none !important;
2444            width: calc(100% - 1rem) !important;
2445            min-width: 0 !important;
2446            transform: none !important;
2447            opacity: 0 !important;
2448            visibility: hidden !important;
2449            display: none !important;
2450          }
2451
2452          body .navbar-nav .dropdown-menu.show {
2453            display: block !important;
2454            opacity: 1 !important;
2455            visibility: visible !important;
2456          }
2457
2458          .dropdown-item {
2459            padding: 0.5rem 0.75rem !important;
2460            border-radius: var(--radius-sm);
2461            font-size: 0.9rem;
2462          }
2463        }
2464
2465        /* Beamer panel uses the nav link as its trigger */
2466        #beamerSelector {
2467          display: none !important;
2468          border: 1px solid var(--color-primary);
2469        }
2470      </style>
2471  </header>
2472
2473  <!-- ========= ALERT CONTAINER ========= -->
2474  <style>
2475    #custom-alerts-container {
2476      position: fixed;
2477      bottom: 1.5rem;
2478      right: 1.5rem;
2479      z-index: 99999;
2480      display: flex;
2481      flex-direction: column;
2482      align-items: flex-end;
2483      gap: 0.5rem;
2484      pointer-events: none;
2485      isolation: isolate;
2486    }
2487
2488    @media (max-width: 767.98px) {
2489      #custom-alerts-container {
2490        right: 50%;
2491        transform: translateX(50%);
2492        align-items: center;
2493        width: 100%;
2494        padding: 0 1rem;
2495      }
2496    }
2497
2498    .custom-toast {
2499      pointer-events: auto;
2500      min-width: 250px;
2501      max-width: 100%;
2502      background: rgba(255, 255, 255, 0.9);
2503      backdrop-filter: blur(12px);
2504      -webkit-backdrop-filter: blur(12px);
2505      border: 1px solid rgba(255, 255, 255, 0.5);
2506      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
2507      border-radius: var(--radius-btn);
2508      padding: 1rem 1.25rem;
2509      position: relative;
2510      overflow: visible;
2511      display: flex;
2512      align-items: center;
2513      gap: 0.75rem;
2514      transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
2515    }
2516
2517    .dark-theme .custom-toast {
2518      background: hsl(var(--accent-h) 26% 12% / 0.85);
2519      border: 1px solid rgba(255, 255, 255, 0.1);
2520      color: var(--color-text);
2521    }
2522
2523    .custom-toast.toast-danger {
2524      border-inline-start: 4px solid var(--color-danger);
2525    }
2526
2527    .custom-toast.toast-success {
2528      border-inline-start: 4px solid var(--color-success);
2529    }
2530
2531    .custom-toast.toast-warning {
2532      border-inline-start: 4px solid var(--color-warning);
2533    }
2534
2535    .custom-toast.toast-info {
2536      border-inline-start: 4px solid var(--color-info);
2537    }
2538
2539    .custom-toast .toast-icon {
2540      font-size: 1.25rem;
2541      flex-shrink: 0;
2542    }
2543
2544    .custom-toast.toast-danger .toast-icon {
2545      color: var(--color-danger);
2546    }
2547
2548    .custom-toast.toast-success .toast-icon {
2549      color: var(--color-success);
2550    }
2551
2552    .custom-toast.toast-warning .toast-icon {
2553      color: var(--color-warning);
2554    }
2555
2556    .custom-toast.toast-info .toast-icon {
2557      color: var(--color-info);
2558    }
2559
2560    .custom-toast .toast-message {
2561      flex-grow: 1;
2562      word-break: break-word;
2563      font-size: 0.95rem;
2564      line-height: 1.4;
2565    }
2566
2567    .custom-toast .toast-action {
2568      flex-shrink: 0;
2569      border: 1px solid var(--glass-border);
2570      background: transparent;
2571      color: inherit;
2572      font: inherit;
2573      font-size: 0.85rem;
2574      font-weight: 600;
2575      padding: 0.35rem 0.75rem;
2576      border-radius: var(--radius-sm, 0.375rem);
2577      cursor: pointer;
2578      transition: background-color var(--transition-fast);
2579    }
2580
2581    .custom-toast .toast-action:hover {
2582      background: var(--overlay-hover, rgba(127, 127, 127, 0.14));
2583    }
2584  </style>
2585  <div id="custom-alerts-container" role="status" aria-live="polite" aria-atomic="false"></div>
2586
2587  
2588
2589  <!-- ========= MAIN CONTENT ========= -->
2590  <main class="content">
2591<div id="home-splash" aria-hidden="true">
2592    <div class="home-splash-logo">
2593        <img class="home-splash-board" alt="">
2594        <img class="home-splash-stick" alt="">
2595        <div class="home-splash-hole"></div>
2596    </div>
2597</div>
2598<script>
2599    (function () {
2600        var root = document.documentElement;
2601        if (!root.classList.contains('show-splash')) return;
2602        var el = document.getElementById('home-splash');
2603        var t;
2604        function dismiss() {
2605            if (!root.classList.contains('show-splash')) return;
2606            root.classList.add('splash-hiding');
2607            setTimeout(function () {
2608                root.classList.remove('show-splash', 'splash-hiding', 'splash-play');
2609                if (el) el.remove();
2610            }, 450); // matches the opacity transition
2611        }
2612        function play() {
2613            if (root.classList.contains('splash-play')) return;
2614            root.classList.add('splash-play');     // starts the clap from the idle (rest) frame
2615            // Fade out just after the strike + shake settle (~1.15s after the clap begins).
2616            t = setTimeout(dismiss, 1150);
2617        }
2618        if (el) el.addEventListener('click', function () { if (t) clearTimeout(t); dismiss(); });
2619
2620        // Reveal + clap only once BOTH the board and stick are fully decoded (paint-ready).
2621        // The board is a much heavier SVG than the stick, so waiting on load events alone
2622        // let the simple stick paint a frame before the board; img.decode() avoids that so
2623        // they appear together and the clap always starts from the idle frame.
2624        var dark = root.classList.contains('dark-theme');
2625        var boardEl = el.querySelector('.home-splash-board');
2626        var stickEl = el.querySelector('.home-splash-stick');
2627        boardEl.src = dark ? '/static/clapper-board-no-stroke.svg' : '/static/clapper-board-black.svg';
2628        stickEl.src = dark ? '/static/stick-arm-no-stroke.svg' : '/static/stick-arm-black.svg';
2629        var fired = false;
2630        function go() { if (fired) return; fired = true; play(); }
2631        Promise.all([boardEl, stickEl].map(function (im) {
2632            return (im.decode ? im.decode() : Promise.resolve()).catch(function () {});
2633        })).then(go);
2634        // Safety: never wait forever if an asset stalls.
2635        setTimeout(go, 800);
2636    })();
2637</script>
2637
2638
2639<div class="hero-wrapper mb-5">
2640    <canvas id="hero-canvas"></canvas>
2641    <div class="hero-content">
2642        <div class="hero-col-main">
2643        
2644        <h1 class="hero-title">Stream Clipping <br class="d-none d-lg-block"> Simplified</h1>
2645        <p class="hero-subtitle">Capture your best live moments instantly with a single command. Fully automated.</p>
2646        
2647
2648        <div class="hero-cta-row d-flex flex-column flex-sm-row gap-3 align-items-center mb-4 mb-md-5">
2649            
2650                <a href="/get-started" class="btn btn-primary btn-lg py-2 py-md-3 rounded-pill hero-btn">
2651                    Get Started <i class="fa-solid fa-arrow-right ms-2"></i>
2652                </a>
2653                <a href="/snips" class="btn btn-snips-hero btn-lg py-2 py-md-3 rounded-pill hero-btn">
2654                    <i class="fa-solid fa-play me-2"></i> Watch Snips
2655                </a>
2656            
2657        </div>
2658        </div>
2659
2660        <div class="hero-col-side">
2661            
2662            <!-- The same three steps the crawler-only section spells out, condensed
2663                 so signed-out visitors can actually see how clipping works. -->
2664            <div class="hero-side-card">
2665                <div class="hero-step">
2666                    <span class="hero-step-num">1</span>
2667                    <div>
2668                        <div class="hero-step-title">Add the !clip command</div>
2669                        <p class="hero-step-text">Connect your channel and add the <code class="glass border-0 px-1 rounded">!clip</code> command to your chat with Nightbot.</p>
2670                    </div>
2671                </div>
2672                <div class="hero-step">
2673                    <span class="hero-step-num">2</span>
2674                    <div>
2675                        <div class="hero-step-title">Your viewers clip for you</div>
2676                        <p class="hero-step-text">Anyone types <code class="glass border-0 px-1 rounded">!clip</code> when something great happens. StreamSnip grabs it from your live VOD.</p>
2677                    </div>
2678                </div>
2679                <div class="hero-step">
2680                    <span class="hero-step-num">3</span>
2681                    <div>
2682                        <div class="hero-step-title">Clips appear instantly</div>
2683                        <p class="hero-step-text">Every clip lands in your feed and your Discord, ready to download or share.</p>
2684                    </div>
2685                </div>
2686            </div>
2687            <!-- The command itself, so a streamer can copy it without leaving
2688                 the landing page. layout.html attaches the Copy button. -->
2689            <div class="hero-side-card hero-command-card">
2690                <p class="hero-command-label">Paste this into your Nightbot chat to add the command:</p>
2691                <div class="terminal-container">
2692                    <div class="terminal-header">
2693                        <div class="terminal-dot dot-red"></div>
2694                        <div class="terminal-dot dot-yellow"></div>
2695                        <div class="terminal-dot dot-green"></div>
2696                        <span class="small opacity-50 ms-auto font-monospace" style="font-size: 0.7rem;">Nightbot</span>
2697                    </div>
2698                    <div class="terminal-body">
2699                        <pre>!addcom !clip $(urlfetch https://streamsnip.com/clip/$(chatid)/$(querystring))</pre>
2700                    </div>
2701                </div>
2702            </div>
2703            
2704
2705            <!-- Kick integration announcement -->
2706            <div class="hero-side-card">
2707                <div class="hero-kick-title">
2708                    <img src="/static/kick-logo.svg" alt="Kick">
2709                    <span>integration is now <span style="color:#53fc18;">ON</span></span>
2710                </div>
2711                <p class="hero-kick-text">
2712                    Streamers need to <a href="/settings#kick" class="text-decoration-none fw-semibold" style="color:#53fc18;">connect their Kick account</a> in <a href="/settings#kick" class="text-decoration-none fw-semibold" style="color:#53fc18;">Settings</a> to show their Kick clips. Any queries? <a href="/guides#kick-guide" class="text-decoration-none fw-semibold">
2712Read guide</a> / <a href="/contactus" class="text-decoration-none fw-semibold">Contact support</a>.
2713                </p>
2714            </div>
2715
2716            <!-- Boasting Stats Pill -->
2717            <div class="stats-pill-wrapper">
2718                <div id="stats-pill" class="hero-stats-boast shadow-lg">
2719                    <div id="today_count" class="fs-5 fs-md-3 fw-bold text-gradient-primary"></div>
2720                    <div id="last_clip_time"
2721                        class="text-xs text-md-small text-muted opacity-75 letter-spacing-wide text-uppercase"
2722                        style="font-size: 0.7rem;"></div>
2723                </div>
2724            </div>
2725        </div>
2726    </div>
2727
2728
2729    <div class="scene-selector-wrapper" title="Change Hero Background">
2730        <i class="fa-solid fa-wand-magic-sparkles"></i>
2731        <select id="hero-scene-select" onchange="switchHeroScene(this.value)">
2732            <!-- Scenes will be populated by JS -->
2733        </select>
2734    </div>
2735
2736
2737    <!-- Bottom Loading Gradient -->
2738    <div id="hero-loader" class="hero-bottom-loader"></div>
2739
2740    <!-- Hero Font Selector (Mirrors Scene Selector) -->
2741    <div class="font-selector-wrapper" id="hero-font-wrapper">
2742        <i class="fa-solid fa-font"></i>
2743        <select id="hero-font-select" onchange="updateHeroFont(this.value)">
2744            <!-- Fonts will be populated by JS -->
2745        </select>
2746    </div>
2747</div>
2748
2749
2750
2751
2752<div class="container">
2753    <!-- Sale strip — renders only while the sale is running, then vanishes on its own -->
2754    
2755
2756    <!-- Premium Banners -->
2757    <div class="hero-banners-container">
2758        <div class="hero-banner-card position-relative overflow-hidden">
2759            <a href="/membership" class="d-block h-100">
2760                <img src="/static/ss_poster.png" alt="StreamSnip Poster" loading="lazy" width="600"
2761                    height="315" class="ss-poster-img" style="width: 100%; height: 100%; min-height: 315px; object-fit: cover; display: block;">
2762            </a>
2763        </div>
2764        <div class="hero-banner-card overflow-hidden">
2765            <iframe width="100%" height="100%" style="min-height: 315px;"
2766                src="https://www.youtube.com/embed/JKVr7r37aso" title="Add !Clip Command" frameborder="0" loading="lazy"
2767                allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
2768                referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
2769        </div>
2770    </div>
2771    <!-- Smart Tips Banner -->
2772    <div class="tips-banner-wrapper" data-aos="fade-up" data-aos-delay="200">
2773        <div id="tips-banner" class="tips-banner" onclick="handleTipClick()">
2774            <i class="tip-icon fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
2775            <span id="tip-text" class="tip-text">Pro members can auto clip Valorant highlights.</span>
2776        </div>
2777    </div>
2778
2779    <div id="search-console" class="row">
2780        <div class="col-md-12">
2781            <div class="command-bar glass" id="searchnsort" style="position: relative; z-index: 50;">
2782                <div class="search-wrapper">
2783                    <i class="fa-solid fa-magnifying-glass text-muted" aria-hidden="true"></i>
2784                    <input type="text" id="search" class="search" placeholder="Search for channels..."
2785                        aria-label="Search for channels">
2786                </div>
2787
2788                <div class="d-flex align-items-center gap-2 border-start border-secondary border-opacity-25 ps-3">
2789                    <span class="text-muted small fw-bold text-nowrap d-none d-lg-block"
2790                        style="opacity: 0.7;">Game:</span>
2791                    <select id="game-filter" onchange="setGame(this.value, true)" aria-label="Filter by Game"
2792                        data-container="body">
2793                        <option value="">All Games</option>
2794                    </select>
2795                </div>
2796
2797                <div class="d-flex align-items-center gap-2 border-start border-secondary border-opacity-25 ps-3">
2798                    <span class="text-muted small fw-bold text-nowrap d-none d-lg-block" style="opacity: 0.7;">Sort
2799                        by:</span>
2800                    <select id="sortby" onchange="reload()" aria-label="Sort channels by" data-container="body">
2801                        
2802                        <option value="sub_count">Sub Count</option>
2803                        <option value="default">Latest Clip Time</option>
2804                        
2805                        <option value="alphabetical">Alphabetical</option>
2806                        <option value="clip_count">Clip Count</option>
2807                        <option value="first_clip">First Clip Time</option>
2808                    </select>
2809
2810                    <a id="invert-btn" class="invert-btn" href="#" onclick="toggleInverse(); return false;"
2811                        aria-label="Invert Sort Direction" title="Invert Sort Order">
2812                        <i class="fa-solid fa-arrows-up-down" aria-hidden="true"></i>
2813                    </a>
2814                </div>
2815            </div>
2816            <div class="game-cards-wrapper" data-aos="fade-up" data-aos-delay="300">
2817                <button class="scroll-btn left" onclick="scrollGames(-1)" aria-label="Scroll Left">
2818                    <i class="fa-solid fa-chevron-left"></i>
2819                </button>
2820                <div id="game-cards" class="game-cards-container">
2821                    
2822                    <div class="game-card skeleton-game">
2823                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2824                        <div class="game-overlay">
2825                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2826                        </div>
2827                    </div>
2828                    
2829                    <div class="game-card skeleton-game">
2830                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2831                        <div class="game-overlay">
2832                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2833                        </div>
2834                    </div>
2835                    
2836                    <div class="game-card skeleton-game">
2837                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2838                        <div class="game-overlay">
2839                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2840                        </div>
2841                    </div>
2842                    
2843                    <div class="game-card skeleton-game">
2844                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2845                        <div class="game-overlay">
2846                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2847                        </div>
2848                    </div>
2849                    
2850                    <div class="game-card skeleton-game">
2851                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2852                        <div class="game-overlay">
2853                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2854                        </div>
2855                    </div>
2856                    
2857                    <div class="game-card skeleton-game">
2858                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2859                        <div class="game-overlay">
2860                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2861                        </div>
2862                    </div>
2863                    
2864                    <div class="game-card skeleton-game">
2865                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2866                        <div class="game-overlay">
2867                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2868                        </div>
2869                    </div>
2870                    
2871                    <div class="game-card skeleton-game">
2872                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2873                        <div class="game-overlay">
2874                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2875                        </div>
2876                    </div>
2877                    
2878                    <div class="game-card skeleton-game">
2879                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2880                        <div class="game-overlay">
2881                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2882                        </div>
2883                    </div>
2884                    
2885                    <div class="game-card skeleton-game">
2886                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2887                        <div class="game-overlay">
2888                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2889                        </div>
2890                    </div>
2891                    
2892                    <div class="game-card skeleton-game">
2893                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2894                        <div class="game-overlay">
2895                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2896                        </div>
2897                    </div>
2898                    
2899                </div>
2900                <button class="scroll-btn right" onclick="scrollGames(1)" aria-label="Scroll Right">
2901                    <i class="fa-solid fa-chevron-right"></i>
2902                </button>
2903            </div>
2904        </div>
2905    </div>
2906
2907    <div class="row">
2908        <div class="col-md-12">
2909            <div id="loading-line" class="" style="height: 10px; border-radius: var(--radius-full);">
2910                <div class="" role="" style="width: 100%;" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
2911                </div>
2912            </div>
2913        </div>
2914    </div>
2915    <div id="clips-container" class="row">
2916        
2917        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
2918            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
2919                <div class="position-relative h-100 overflow-hidden">
2920                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
2921                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
2922                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
2923                    </div>
2924                </div>
2925            </div>
2926        </div>
2927        
2928        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
2929            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
2930                <div class="position-relative h-100 overflow-hidden">
2931                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
2932                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
2933                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
2934                    </div>
2935                </div>
2936            </div>
2937        </div>
2938        
2939        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
2940            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
2941                <div class="position-relative h-100 overflow-hidden">
2942                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
2943                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
2944                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
2945                    </div>
2946                </div>
2947            </div>
2948        </div>
2949        
2950        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
2951            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
2952                <div class="position-relative h-100 overflow-hidden">
2953                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
2954                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
2955                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
2956                    </div>
2957                </div>
2958            </div>
2959        </div>
2960        
2961        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
2962            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
2963                <div class="position-relative h-100 overflow-hidden">
2964                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
2965                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
2966                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
2967                    </div>
2968                </div>
2969            </div>
2970        </div>
2971        
2972        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
2973            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
2974                <div class="position-relative h-100 overflow-hidden">
2975                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
2976                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
2977                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
2978                    </div>
2979                </div>
2980            </div>
2981        </div>
2982        
2983        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
2984            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
2985                <div class="position-relative h-100 overflow-hidden">
2986                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
2987                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
2988                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
2989                    </div>
2990                </div>
2991            </div>
2992        </div>
2993        
2994        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
2995            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
2996                <div class="position-relative h-100 overflow-hidden">
2997                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
2998                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
2999                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3000                    </div>
3001                </div>
3002            </div>
3003        </div>
3004        
3005        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3006            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3007                <div class="position-relative h-100 overflow-hidden">
3008                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3009                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3010                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3011                    </div>
3012                </div>
3013            </div>
3014        </div>
3015        
3016        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3017            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3018                <div class="position-relative h-100 overflow-hidden">
3019                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3020                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3021                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3022                    </div>
3023                </div>
3024            </div>
3025        </div>
3026        
3027        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3028            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3029                <div class="position-relative h-100 overflow-hidden">
3030                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3031                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3032                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3033                    </div>
3034                </div>
3035            </div>
3036        </div>
3037        
3038        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3039            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3040                <div class="position-relative h-100 overflow-hidden">
3041                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3042                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3043                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3044                    </div>
3045                </div>
3046            </div>
3047        </div>
3048        
3049    </div>
3050
3051    <!-- Infinite Scroll Anchor -->
3052    <div id="scroll-anchor" class="d-flex justify-content-center py-5" style="min-height: 100px;">
3053        <div id="infinite-loader" style="opacity: 0; transition: opacity var(--transition-base);"
3054            data-clap-loader data-size="40"></div>
3055    </div>
3056    </main>
3057
3058  <!-- ========= FOOTER ========= -->
3059  <footer class="py-3 border-top border-opacity-10" style="position: relative; z-index: 2;">
3060
3061    <div class="container">
3062      <div class="row align-items-center">
3063        <div class="col-md-6">© 2026&nbsp;Suraj&nbsp;Bhari</div>
3064        <nav class="col-md-6 text-md-end">
3065          <a href="/privacy-policy">Privacy&nbsp;Policy</a> |
3066          <a href="/aboutus">About&nbsp;Us</a> |
3067          <a href="/contactus">Contact&nbsp;Us</a> |
3068          <a href="/tnc">Terms&nbsp;&amp;&nbsp;Conditions</a>
3069        </nav>
3070      </div>
3071      <div class="row mt-2" aria-hidden="true">
3072        <div class="col-12 text-md-end small" style="color: transparent; user-select: none;">
3073          StreamSnip is original, copyrighted work. Use of any content, code, or design from this site
3074          for AI/ML training, RAG indexing, generative reproduction, or to "rebuild" or "clone" the site
3075          is not permitted.
3076        </div>
3077      </div>
3078    </div>
3079  </footer>
3080
3081  <!-- ========= DOWNLOAD QUEUE ========= -->
3082<style>
3083  #dq-panel { font-family: inherit; }
3084  .dq-row {
3085    border-radius: var(--radius-md);
3086    padding: 0.4rem 0.6rem;
3087    position: relative;
3088    overflow: hidden;
3089    flex-shrink: 0;          /* don't compress — let the body scroll instead */
3090    transition: background 0.2s ease;
3091  }
3092  .dq-row-pending  { background: rgba(255,255,255,0.04); }
3093  .dq-row-downloading { background: var(--primary-08); border: 1px solid hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.2); }
3094  .dq-row-done     { background: rgba(34,197,94,0.08);  border: 1px solid rgba(34,197,94,0.2); }
3095  .dq-row-error    { background: rgba(239,68,68,0.08);  border: 1px solid rgba(239,68,68,0.2); }
3096  .dq-label {
3097    font-size: 0.75rem;
3098    color: var(--color-text);
3099    white-space: nowrap;
3100    overflow: hidden;
3101    text-overflow: ellipsis;
3102    max-width: 100%;
3103  }
3104  .dq-sub    { font-size: 0.65rem;
3104 margin-top: 1px; }
3105  .dq-detail { font-size: 0.6rem; margin-top: 1px; color: var(--color-text-muted); opacity: 0.7; letter-spacing: 0.02em; }
3106  .dq-actions {
3107    display: flex;
3108    align-items: center;
3109    justify-content: space-between;
3110    gap: 4px;
3111    margin-top: 3px;
3112  }
3113  .dq-btn {
3114    font-size: 0.65rem;
3115    padding: 2px 6px;
3116    border-radius: 4px;
3117    border: none;
3118    cursor: pointer;
3119    line-height: 1.4;
3120    text-decoration: none;
3121    display: inline-flex;
3122    align-items: center;
3123    gap: 3px;
3124  }
3125  .dq-btn-dl   { background: rgba(34,197,94,0.2);  color: #86efac; }
3126  .dq-btn-dl:hover { background: rgba(34,197,94,0.35); color: #86efac; }
3127  .dq-btn-retry { background: rgba(239,68,68,0.2); color: #fca5a5; }
3128  .dq-btn-retry:hover { background: rgba(239,68,68,0.35); color: #fca5a5; }
3129  .dq-btn-rm   { background: none; color: var(--color-text-muted); opacity: 0.6; }
3130  .dq-btn-rm:hover { opacity: 1; }
3131  #dq-body::-webkit-scrollbar { width: 4px; }
3132  #dq-body::-webkit-scrollbar-thumb { background: var(--color-primary); border-radius: 4px; }
3133  /* downloading row indicator */
3134  .bouncing-gradient {
3135    background: var(--primary-12);
3136    border-inline-start: 3px solid var(--color-primary) !important;
3137  }
3138</style>
3139
3140<div id="dq-panel" style="display:none;position:fixed;bottom:1.5rem;inset-inline-start:1.5rem;z-index:1030;width:260px;">
3141  <div class="glass" style="border-radius:var(--radius-lg);overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.3);">
3142    <!-- Header -->
3143    <div id="dq-header"
3144         onclick="DownloadQueue._toggleCollapse()"
3145         style="display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.75rem;background:var(--glass-bg-subtle);border-bottom:1px solid var(--glass-border);cursor:pointer;user-select:none;">
3146      <span style="font-size:0.68rem;font-weight:700;letter-spacing:0.05em;color:var(--color-text-muted);text-transform:uppercase;display:flex;align-items:center;gap:6px;">
3147        <i class="fa-solid fa-download" style="color:var(--color-primary);font-size:0.7rem;"></i>
3148        <span id="dq-title">Downloads</span>
3149      </span>
3150      <div style="display:flex;align-items:center;gap:6px;">
3151        <a href="/downloads"
3152           onclick="event.stopPropagation();"
3153           style="font-size:0.6rem;color:var(--color-text-muted);padding:1px 4px;opacity:0.65;line-height:1;text-decoration:none;"
3154           title="Open the downloads page">view all</a>
3155        <button id="dq-clear-btn"
3156                onclick="event.stopPropagation();DownloadQueue.clearAll()"
3157                style="display:none;background:none;border:none;cursor:pointer;font-size:0.6rem;color:var(--color-text-muted);padding:1px 4px;opacity:0.65;line-height:1;"
3158                title="Cancel all queued items">clear all</button>
3159        <i id="dq-chevron" class="fa-solid fa-chevron-down" style="font-size:0.6rem;color:var(--color-text-muted);transition:transform 0.2s ease;"></i>
3160      </div>
3161    </div>
3162    <!-- Item list -->
3163    <div id="dq-body"
3164         style="padding:0.4rem;display:flex;flex-direction:column;gap:0.3rem;max-height:180px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--color-primary) transparent;">
3165    </div>
3166  </div>
3167</div>
3168
3169<script>
3170/* =================== DOWNLOAD QUEUE =================== */
3171window.DownloadQueue = (() => {
3172  const STORAGE_KEY  = 'ss_download_queue';
3173  const COLLAPSE_KEY = 'ss_dq_collapsed';
3174
3175  function _load() {
3176    try {
3177      const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
3178      const SIX_HOURS = 6 * 60 * 60 * 1000;
3179      const now = Date.now();
3180      const fresh = raw.filter(item => {
3181        if ((item.status === 'done' || item.status === 'error') && item.doneAt) {
3182          return now - item.doneAt < SIX_HOURS;
3183        }
3184        return true;
3185      });
3186      if (fresh.length !== raw.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
3187      return fresh;
3188    } catch { return []; }
3189  }
3190
3191  function _save(queue) {
3192    localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
3193  }
3194
3195  function _esc(s) {
3196    return String(s)
3197      .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
3198  }
3199
3200  function _isCollapsed() {
3201    return localStorage.getItem(COLLAPSE_KEY) === 'true';
3202  }
3203
3204  function _toggleCollapse() {
3205    const next = !_isCollapsed();
3206    localStorage.setItem(COLLAPSE_KEY, next);
3207    _applyCollapse(next);
3208  }
3209
3210  function _applyCollapse(collapsed) {
3211    const body    = document.getElementById('dq-body');
3212    const chevron = document.getElementById('dq-chevron');
3213    if (body)    body.style.display      = collapsed ? 'none'  : 'flex';
3214    if (chevron) chevron.style.transform = collapsed ? 'rotate(-90deg)' : '';
3215  }
3216
3217  function _formatDetail(item) {
3218    const parts = [];
3219    // Use stored human-readable label when available (e.g. "720p · MP4")
3220    if (item.detail) {
3221      parts.push(item.detail);
3222    } else {
3223      // Fallback for items added before detail was stored
3224      try {
3225        const p   = new URLSearchParams(item.qs);
3226        const fid = p.get('format_id') || '';
3227        const ext = (p.get('ext') || '').toUpperCase();
3228        if (fid.startsWith('custom-')) parts.push(fid.replace('custom-', '') + 'p');
3229        else if (fid) parts.push(fid);
3230        if (ext) parts.push(ext);
3231      } catch {}
3232    }
3233    // Always append clip duration when start/end are set
3234    try {
3235      const p  = new URLSearchParams(item.qs);
3236      const t1 = parseInt(p.get('start_time')), t2 = parseInt(p.get('end_time'));
3237      if (!isNaN(t1) && !isNaN(t2) && t2 > t1) {
3238        const dur = t2 - t1;
3239        const mm  = String(Math.floor(dur / 60)).padStart(2, '0');
3240        const ss  = String(dur % 60).padStart(2, '0');
3241        parts.push(`${mm}:${ss} clip`);
3242      }
3243    } catch {}
3244    return parts.join(' · ');
3245  }
3246
3247  function _render() {
3248    const queue  = _load();
3249    const panel  = document.getElementById('dq-panel');
3250    const body   = document.getElementById('dq-body');
3251    const title  = document.getElementById('dq-title');
3252    if (!panel || !body) return;
3253
3254    panel.style.display = queue.length === 0 ? 'none' : 'block';
3255    if (title) title.textContent = `Downloads (${queue.length})`;
3256    const clearBtn = document.getElementById('dq-clear-btn');
3257    if (clearBtn) clearBtn.style.display = queue.length > 0 ? 'inline' : 'none';
3258
3259    body.innerHTML = '';
3260    // Sort: downloading first, then pending, then error, then done (done: most recent on top)
3261    const _statusOrder = { downloading: 0, pending: 1, error: 2, done: 3 };
3262    const sorted = queue
3263      .map((item, i) => ({ item, i }))
3264      .sort((a, b) => {
3265        const diff = (_statusOrder[a.item.status] ?? 9) - (_statusOrder[b.item.status] ?? 9);
3266        if (diff !== 0) return diff;
3267        if (a.item.status === 'done') return b.i - a.i; // newest done first
3268        return a.i - b.i;
3269      })
3270      .map(({ item }) => item);
3271    sorted.forEach(item => {
3272      const row = document.createElement('div');
3273      row.className = `dq-row dq-row-${item.status}`;
3274      row.id = `dq-row-${CSS.escape(item.id)}`;
3275
3276      const labelEl = document.createElement('div');
3277      labelEl.className = 'dq-label';
3278      labelEl.title = item.label;
3279
3280      if (item.status === 'pending') {
3281        labelEl.textContent = item.label;
3282        const actions = document.createElement('div');
3283        actions.className = 'dq-actions';
3284
3285        const sub = document.createElement('div');
3286        sub.className = 'dq-sub';
3287        sub.style.color = 'var(--color-text-muted)';
3288        sub.textContent = 'queued';
3289
3290        const rmBtn = document.createElement('button');
3291        rmBtn.className = 'dq-btn dq-btn-rm';
3292        rmBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
3293        rmBtn.title = 'Cancel';
3294        rmBtn.onclick = () => remove(item.id);
3295
3296        actions.appendChild(sub);
3297        actions.appendChild(rmBtn);
3298        row.appendChild(labelEl);
3299        const det0 = _formatDetail(item);
3300        if (det0) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det0; row.appendChild(d); }
3301        row.appendChild(actions);
3302
3303      } else if (item.status === 'downloading') {
3304        row.classList.add('bouncing-gradient');
3305        labelEl.textContent = item.label;
3306        const actions = document.createElement('div');
3307        actions.className = 'dq-actions';
3308
3309        const sub = document.createElement('div');
3310        sub.className = 'dq-sub';
3311        sub.style.color = 'var(--color-primary)';
3312        sub.textContent = 'downloading…';
3313
3314        const rmBtn = document.createElement('button');
3315        rmBtn.className = 'dq-btn dq-btn-rm';
3316        rmBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
3317        rmBtn.title = 'Cancel';
3318        rmBtn.onclick = () => remove(item.id);
3319
3320        actions.appendChild(sub);
3321        actions.appendChild(rmBtn);
3322        row.appendChild(labelEl);
3323        const det1 = _formatDetail(item);
3324        if (det1) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det1; row.appendChild(d); }
3325        row.appendChild(actions);
3326
3327      } else if (item.status === 'done') {
3328        labelEl.innerHTML = '<i class="fa-solid fa-check me-1" style="color:#4ade80"></i>' + _esc(item.label);
3329        const actions = document.createElement('div');
3330        actions.className = 'dq-actions';
3331
3332        const sub = document.createElement('div');
3333        sub.className = 'dq-sub';
3334        sub.style.color = '#4ade80';
3335        sub.textContent = 'ready';
3336
3337        const dlBtn = document.createElement('a');
3338        dlBtn.href = `/video/${item.id}?${item.qs}`;
3339        dlBtn.className = 'dq-btn dq-btn-dl';
3340        dlBtn.innerHTML = '<i class="fa-solid fa-download"></i>';
3341        dlBtn.title = 'Save file';
3342
3343        const rmBtn = document.createElement('button');
3344        rmBtn.className = 'dq-btn dq-btn-rm';
3345        rmBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
3346        rmBtn.onclick = () => remove(item.id);
3347
3348        actions.appendChild(sub);
3349        actions.appendChild(dlBtn);
3350        actions.appendChild(rmBtn);
3351        row.appendChild(labelEl);
3352        const det2 = _formatDetail(item);
3353        if (det2) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det2; row.appendChild(d); }
3354        row.appendChild(actions);
3355
3356      } else if (item.status === 'error') {
3357        labelEl.innerHTML = '<i class="fa-solid fa-xmark me-1" style="color:#f87171"></i>' + _esc(item.label);
3358        const actions = document.createElement('div');
3359        actions.className = 'dq-actions';
3360
3361        const sub = document.createElement('div');
3362        sub.className = 'dq-sub';
3363        sub.style.color = '#f87171';
3364        sub.textContent = (item.error || 'Error').substring(0, 38);
3365
3366        const retryBtn = document.createElement('button');
3367        retryBtn.className = 'dq-btn dq-btn-retry';
3368        retryBtn.innerHTML = '<i class="fa-solid fa-rotate-right"></i> retry';
3369        retryBtn.onclick = () => retry(item.id);
3370
3371        const rmBtn = document.createElement('button');
3372        rmBtn.className = 'dq-btn dq-btn-rm';
3373        rmBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
3374        rmBtn.onclick = () => remove(item.id);
3375
3376        actions.appendChild(sub);
3377        actions.appendChild(retryBtn);
3378        actions.appendChild(rmBtn);
3379        row.appendChild(labelEl);
3380        const det3 = _formatDetail(item);
3381        if (det3) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det3; row.appendChild(d); }
3382        row.appendChild(actions);
3383      }
3384
3385      body.appendChild(row);
3386    });
3387
3388    _applyCollapse(_isCollapsed());
3389  }
3390
3391  /* ---------- public mutators ---------- */
3392
3393  function add(id, label, qs, detail) {
3394    const queue = _load();
3395    // One row per clip id — _settle/_pollJob/_triggerDownload all key off the
3396    // (assumed-unique) id. Re-download an already-finished clip and we must
3397    // re-activate its existing row, not push a duplicate: a duplicate id makes
3398    // _settle update the wrong row, leaving the new one stuck 'downloading' and
3399    // re-firing the save dialog on every page load.
3400    const existing = queue.find(i => i.id === id);
3401    if (existing) {
3402      if (existing.status === 'pending' || existing.status === 'downloading') return;
3403      existing.status = 'pending';
3404      existing.label  = label;
3405      existing.qs     = qs;
3406      existing.detail = detail || '';
3407      delete existing.error;
3408      delete existing.doneAt;
3409    } else {
3410      queue.push({ id, label, qs, detail: detail || '', status: 'pending' });
3411    }
3412    if (typeof gtag === 'function') gtag('event', 'clip_download', { clip_id: id, source: 'youtube' });
3413    _save(queue);
3414    _render();
3415    _kickProcessor();
3416  }
3417
3418  function remove(id) {
3419    _save(_load().filter(i => i.id !== id));
3420    _render();
3421  }
3422
3423  function clearAll() {
3424    _save([]);
3425    _render();
3426  }
3427
3428  function retry(id) {
3429    const queue = _load();
3430    const item  = queue.find(i => i.id === id);
3431    if (item) { item.status = 'pending'; delete item.error; }
3432    _save(queue);
3433    _render();
3434    _kickProcessor();
3435  }
3436
3437  /* ---------- processor ---------- */
3438
3439  let _processing = false;
3440
3441  function _kickProcessor() {
3442    if (!_processing) _process();
3443  }
3444
3445  function _process() {
3446    const queue = _load();
3447    const next  = queue.find(i => i.status === 'pending');
3448    if (!next) { _processing = false; return; }
3449    _processing = true;
3450    next.status = 'downloading';
3451    _save(queue);
3452    _render();
3453    document.dispatchEvent(new CustomEvent('dq:downloading', { detail: { id: next.id, qs: next.qs } }));
3454
3455    fetch(`/download/${next.id}?${next.qs}`, { method: 'POST' })
3456      .then(res => res.json())
3457      .then(({ job_id }) => _pollJob(job_id, next))
3458      .catch(() => _settle(next, 'error', 'Could not start download'))
3459      .finally(() => { _processing = false; _kickProcessor(); });
3460  }
3461
3462  function _pollJob(jobId, item) {
3463    return new Promise(resolve => {
3464      const tick = () => {
3465        // Stop polling if the item was removed from the queue mid-flight,
3466        // so a stuck/long-lived job doesn't leave an orphaned 2s loop running.
3467        if (!_load().some(i => i.id === item.id)) { resolve(); return; }
3468        fetch(`/jobs/${jobId}`)
3469          .then(r => r.json())
3470          .then(d => {
3471            if (d.status === 'succeeded') { _settle(item, 'done'); _triggerDownload(item); resolve(); }
3472            else if (d.status === 'failed' || d.status === 'unknown' || d.status === 'forbidden')
3473                 { _settle(item, 'error', d.error || 'Download failed'); resolve(); }
3474            else setTimeout(tick, 2000); // queued | doing
3475          })
3476          .catch(() => { _settle(item, 'error', 'Lost connection to job'); resolve(); });
3477      };
3478      tick();
3479    });
3480  }
3481
3482  function _settle(next, status, error) {
3483    const q = _load();
3484    const item = q.find(i => i.id === next.id);
3485    if (!item) return;
3486    item.status = status;
3487    if (error) item.error = error;
3488    item.doneAt = Date.now();
3489    _save(q);
3490    _render();
3491    document.dispatchEvent(new CustomEvent('dq:settled', { detail: { id: item.id, status: item.status } }));
3492  }
3493
3494  function _triggerDownload(item) {
3495    const a = document.createElement('a');
3496    a.href = `/video/${item.id}?${item.qs}`;
3497    a.style.display = 'none';
3498    document.body.appendChild(a);
3499    a.click();
3500    document.body.removeChild(a);
3501  }
3502
3503  /* ---------- init ---------- */
3504
3505  function _init() {
3506    const queue = _load();
3507    // Self-heal duplicate clip-id rows the old add() left in storage — they broke
3508    // _settle's find-by-id and stranded rows on 'downloading' (re-prompting a save
3509    // on every load). Collapse to one row per id (keep the latest), then reset any
3510    // row left 'downloading' by a previous page load back to 'pending'.
3511    const byId = new Map();
3512    queue.forEach(item => byId.set(item.id, item));
3513    const deduped = [...byId.values()];
3514    let changed = deduped.length !== queue.length;
3515    deduped.forEach(item => {
3516      if (item.status === 'downloading') { item.status = 'pending'; changed = true; }
3517    });
3518    if (changed) _save(deduped);
3519    _render();
3520    _kickProcessor();
3521  }
3522
3523  return { add, remove, retry, clearAll, _render, _load, _save, _toggleCollapse, _applyCollapse, _kickProcessor, _process, _init };
3524})();
3525
3526document.addEventListener('DOMContentLoaded', () => DownloadQueue._init());
3527</script>
3527
3528
3529  <!-- ========= SCRIPTS ========= -->
3530  
3530<script src="https://code.jquery.com/jquery-3.7.1.min.js"
3531    integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous" defer></script>
3531
3532
3533  
3533<script
3534    src="https://cdn.jsdelivr.net/gh/xcash/[email protected]/dist/latest/bootstrap-autocomplete.min.js"
3535    defer></script>
3535
3536  
3536<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
3537    integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"
3538    defer></script>
3538
3539  
3539<script src="/static/dropdown.js?v=ea61329d25" defer></script>
3539
3540  
3540<script defer>
3541    /* A nav menu's frosted blur is a no-op while it lives inside .glass-nav: the header
3542       is a backdrop root (backdrop-filter + view-transition-name), so a descendant's
3543       backdrop-filter has no page pixels to sample. Lift the menu out to <body> while
3544       it's open and anchor it to the toggle — the header is fixed, so that anchor never
3545       moves and no scroll repositioning is needed. Expanded navbar only; collapsed, the
3546       menu renders inline as an accordion and must stay where it is. */
3547    (function () {
3548      const desktop = window.matchMedia('(min-width: 992px)');
3549      const pairs = [];
3550
3551      function place(menu, toggle) {
3552        const rect = toggle.getBoundingClientRect();
3553        menu.style.setProperty('top', `${rect.bottom + 8}px`, 'important');
3554        menu.style.setProperty('right', `${document.documentElement.clientWidth - rect.right}px`, 'important');
3555      }
3556
3557      for (const toggle of document.querySelectorAll('header.glass-nav [data-bs-toggle="dropdown"]')) {
3558        const menu = toggle.parentElement.querySelector('.dropdown-menu');
3559        if (!menu) continue;
3560        const home = menu.parentElement;
3561        pairs.push([menu, toggle]);
3562
3563        toggle.addEventListener('show.bs.dropdown', () => {
3564          if (!desktop.matches) return;
3565          document.body.appendChild(menu);
3566          menu.classList.add('dropdown-menu-detached');
3567          place(menu, toggle);
3568        });
3569
3570        toggle.addEventListener('hidden.bs.dropdown', () => {
3571          if (!menu.classList.contains('dropdown-menu-detached')) return;
3572          menu.classList.remove('dropdown-menu-detached');
3573          menu.style.removeProperty('top');
3574          menu.style.removeProperty('right');
3575          home.appendChild(menu);
3576        });
3577      }
3578
3579      window.addEventListener('resize', () => {
3580        for (const [menu, toggle] of pairs) {
3581          if (!menu.classList.contains('dropdown-menu-detached')) continue;
3582          // Crossing into the collapsed navbar mid-open: close it so it reattaches.
3583          if (desktop.matches) place(menu, toggle);
3584          else bootstrap.Dropdown.getInstance(toggle)?.hide();
3585        }
3586      });
3587    })();
3588  </script>
3588
3589  
3589<script>
3590    var beamer_config = {
3591      product_id: 'MzmSqYwo78692',
3592      user_id: "anonymous",
3593      selector: '#beamer-whats-new',
3594      display: "light"
3595    };
3596  </script>
3596
3597  
3597<script type="text/javascript" src="https://app.getbeamer.com/js/beamer-embed.js" defer="defer"></script>
3597
3598  
3598<script defer>
3599    /* =================== THEME =================== */
3600    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
3601    const storageKey = 'darkTheme';
3602    var isHome = false;
3603    // iOS fixed background causes scroll jank — use scroll attachment instead
3604    const _isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
3605      (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
3606    function setTheme(dark) {
3607      document.documentElement.classList.toggle('dark-theme', dark);
3608      document.body.classList.toggle('dark-theme', dark);
3609
3610      // No colors are hardcoded here. Toggling the `.dark-theme` class (above) swaps the
3611      // token values in :root / .dark-theme, and `html, body { background: var(--color-canvas) }`
3612      // repaints the page background. Single source of truth = the CSS tokens in style.css.
3613      const themeBtnIcon = dark ? 'fa-moon' : 'fa-sun';
3614      document.getElementById('theme_button').innerHTML = `<i class="fa-solid ${themeBtnIcon} fa-fw me-2"></i><span class="d-lg-none">Toggle Theme</span>`;
3615      document.body.style.backgroundAttachment = '';
3616
3617
3618      // navbar / site‑icon color tweaks
3619      const navbar = document.getElementById('navbar_id');
3620      navbar.classList.toggle('navbar-dark', dark);
3621      navbar.classList.toggle('navbar-light', !dark);
3622
3623
3624      document.querySelectorAll('.site_icon').forEach(icon => {
3625        icon.src = dark ? '/static/logo.svg' : '/static/logo-grey.svg';
3626      });
3627
3628      // bulk toggle of dark‑theme utility class
3629      const selectors = ['.navbar-collapse', '.form-control', '.table', 'a', 'select', 'ul', '.card', '.dropdown-item', 'thead', 'footer', '.alert'];
3630      selectors.forEach(sel => document.querySelectorAll(sel).forEach(el => el.classList.toggle('dark-theme', dark)));
3631
3632      // Force mobile navbar theme-sync (hard-lock) - ONLY ON MOBILE
3633      if (window.innerWidth <= 991) {
3634        const collapse = document.querySelector('.navbar-collapse');
3635        if (collapse) {
3636          const bg = 'var(--color-bg)';
3637          const txt = 'var(--color-text)';
3638          collapse.style.setProperty('background', bg, 'important');
3639          collapse.querySelectorAll('.nav-link').forEach(link => {
3640            link.style.setProperty('color', txt, 'important');
3641          });
3642        }
3643      } else {
3644        // Reset on desktop
3645        const collapse = document.querySelector('.navbar-collapse');
3646        if (collapse) {
3647          collapse.style.removeProperty('background');
3648          collapse.querySelectorAll('.nav-link').forEach(link => {
3649            link.style.removeProperty('color');
3650          });
3651        }
3652      }
3653
3654      // google sign‑in buttons (if present)
3655      document.querySelectorAll('.google_sign_in').forEach(img => {
3656        const darkSrc = img.id === 'sign_in' ? '/static/sign_in_dark.png' : '/static/sign_up_dark.png';
3657        const lightSrc = img.id === 'sign_in' ? '/static/sign_in_light.png' : '/static/sign_up_light.png';
3658        img.src = dark ? darkSrc : lightSrc;
3659      });
3660      localStorage.setItem(storageKey, dark);
3661      if (typeof setThemeCallback === 'function') {
3662        setThemeCallback(dark);
3663      }
3664
3665      // Update Beamer theme on-the-fly
3666      if (window.Beamer && typeof Beamer.update === 'function') {
3667        Beamer.update({ display: dark ? 'dark' : 'light' });
3668      }
3669    }
3670
3671    function initTheme() {
3672      const stored = localStorage.getItem(storageKey);
3673      const dark = stored ? stored === 'true' : prefersDark.matches || !window.matchMedia; // default dark on small devices
3674
3675      setTheme(dark);
3676    }
3677
3678    function toggleDarkTheme() {
3679      setTheme(!document.body.classList.contains('dark-theme'));
3680    }
3681    function timeSince(time) {
3682      const current = new Date();
3683      const elapsed = (current - new Date(time)) / 1000; // seconds
3684      const secPerMinute = 60;
3685      const secPerHour = secPerMinute * 60;
3686      const secPerDay = secPerHour * 24;
3687      const secPerMonth = secPerDay * 30;
3688      const secPerYear = secPerDay * 365;
3689
3690      if (elapsed < secPerMinute) {
3691        return Math.floor(elapsed) === 1
3692          ? "1 second ago"
3693          : `${Math.floor(elapsed)} seconds ago`;
3694      } else if (elapsed < secPerHour) {
3695        return Math.floor(elapsed / 60) === 1
3696          ? "1 minute ago"
3697          : `${Math.floor(elapsed / 60)} minutes ago`;
3698      } else if (elapsed < secPerDay) {
3699        return Math.floor(elapsed / 3600) === 1
3700          ? "1 hour ago"
3701          : `${Math.floor(elapsed / 3600)} hours ago`;
3702      } else if (elapsed < secPerMonth) {
3703        return Math.floor(elapsed / 86400) === 1
3704          ? "~ 1 day ago"
3705          : `~ ${Math.floor(elapsed / 86400)} days ago`;
3706      } else if (elapsed < secPerYear) {
3707        return Math.floor(elapsed / 2592000) === 1
3708          ? "~ 1 month ago"
3709          : `~ ${Math.floor(elapsed / 2592000)} months ago`;
3710      } else {
3711        return Math.floor(elapsed / 31536000) === 1
3712          ? "~ 1 year ago"
3713          : `~ ${Math.floor(elapsed / 31536000)} years ago`;
3714      }
3715    }
3716
3717    /* =================== MINI-STATS =================== */
3718    let mini_stats_update_interval = null;
3719
3720    async function fetchMiniStats() {
3721      if (document.hidden) return;
3722      const url = "/mini_stats";
3723      let res;
3724      try { res = await fetch(url); } catch { return; }
3725      if (!res.ok) return;
3726
3727      const { last_clip, today_count } = await res.json();
3728
3729      // Update UI immediately on fetch
3730      updateMiniStats(last_clip, today_count);
3731
3732      if (mini_stats_update_interval) {
3733        clearInterval(mini_stats_update_interval);
3734      }
3735
3736      mini_stats_update_interval = setInterval(() => {
3737        updateMiniStats(last_clip, today_count);
3738      }, 5 * 60 * 1000); // 5 minutes
3739
3740      isFirstLoad = false;
3741      return { last_clip, today_count };
3742    }
3743
3744    function updateMiniStats(last_clip, today_count) {
3745      const todayEl = document.getElementById("today_count");
3746      const lastEl = document.getElementById("last_clip_time");
3747      const statsPill = document.getElementById("stats-pill");
3748
3749      if (todayEl) {
3750        todayEl.innerHTML = `<i class='fa-solid fa-fire-pulse me-2'></i> <strong style="font-variant-numeric: tabular-nums;">${today_count}</strong> Clips Captured in 24h`;
3751      }
3752
3753      if (lastEl) {
3754        if (last_clip) {
3755          const ts = timeSince(last_clip.time);
3756          lastEl.textContent = `Latest Clip: ${ts}`;
3757        } else {
3758          lastEl.textContent = `Start the first clip!`;
3759        }
3760      }
3761
3762      // Update stats pill visibility if it exists on the page (home page)
3763      if (statsPill) {
3764        if (today_count > 0) {
3765          statsPill.style.setProperty("display", "inline-flex", "important");
3766        } else {
3767          statsPill.style.setProperty("display", "none", "important");
3768        }
3769      }
3770    }
3771
3772
3773    function showMembershipAlerts() {
3774      
3775
3776    
3777    
3778    
3779    }
3780    /* =================== ALERTS =================== */
3781    // `action` — optional { label, onClick } that renders a button inside the toast,
3782    // for offering an undo instead of making the user hunt for the reverse operation.
3783    function send_alert(message, level, href, duration = 5000, action = null) {
3784      // Ensure a default level if none is provided
3785      if (!level) {
3786        level = 'info';
3787      }
3788
3789      // Create or get the custom alerts container
3790      let customAlertsContainer = document.getElementById('custom-alerts-container');
3791      if (!customAlertsContainer) {
3792        customAlertsContainer = document.createElement('div');
3793        customAlertsContainer.id = 'custom-alerts-container';
3794        customAlertsContainer.setAttribute('role', 'status');
3795        customAlertsContainer.setAttribute('aria-live', 'polite');
3796        customAlertsContainer.setAttribute('aria-atomic', 'false');
3797        document.body.appendChild(customAlertsContainer);
3798      }
3799
3800      // Determine the icon based on the alert level
3801      let iconClass = '';
3802      switch (level) {
3803        case 'danger':
3804          iconClass = 'fa-solid fa-circle-exclamation';
3805          break;
3806        case 'success':
3807          iconClass = 'fa-solid fa-circle-check';
3808          break;
3809        case 'warning':
3810          iconClass = 'fa-solid fa-triangle-exclamation';
3811          break;
3812        case 'info':
3813          iconClass = 'fa-solid fa-circle-info';
3814          break;
3815        default:
3816          iconClass = 'fa-solid fa-bell';
3817          break;
3818      }
3819
3820      // Create the alert element
3821      const alertElement = document.createElement('div');
3822      alertElement.className = `alert custom-toast toast-${level} m-0`;
3823
3824      let messageBody = message;
3825      if (href) {
3826        messageBody = `<a href="${href}" class="alert-link text-decoration-none">${message}</a>`;
3827      }
3828
3829      alertElement.innerHTML = `
3830        <i class="${iconClass} toast-icon" aria-hidden="true"></i>
3831        <div class="toast-message">${messageBody}</div>
3832      `;
3833
3834      // Apply initial animation styles for entry
3835      alertElement.style.opacity = '0';
3836      alertElement.style.transform = 'translateY(20px)';
3837
3838      // Append the new alert to the custom container
3839      customAlertsContainer.appendChild(alertElement);
3840
3841      // Trigger the entry animation after appending
3842      setTimeout(() => {
3843        alertElement.style.opacity = '1';
3844        alertElement.style.transform = 'translateY(0) translateX(0)';
3845      }, 10);
3846
3847      const dismissAlert = () => {
3848        alertElement.style.opacity = '0';
3849        alertElement.style.transform = 'scale(0.9)';
3850        setTimeout(() => {
3851          if (alertElement.parentNode) {
3852            alertElement.parentNode.removeChild(alertElement);
3853          }
3854        }, 400); // Wait for transition
3855      };
3856
3857      let dismissTimeout;
3858
3859      const startDismissTimer = () => {
3860        dismissTimeout = setTimeout(() => {
3861          dismissAlert();
3862        }, duration);
3863      };
3864
3865      startDismissTimer();
3866
3867      if (action && action.label) {
3868        const actionBtn = document.createElement('button');
3869        actionBtn.type = 'button';
3870        actionBtn.className = 'toast-action';
3871        actionBtn.textContent = action.label;
3872        actionBtn.addEventListener('click', () => {
3873          clearTimeout(dismissTimeout);
3874          dismissAlert();
3875          if (typeof action.onClick === 'function') action.onClick();
3876        });
3877        alertElement.appendChild(actionBtn);
3878      }
3879
3880      // Desktop hover pause
3881      alertElement.addEventListener('mouseenter', () => {
3882        clearTimeout(dismissTimeout);
3883        alertElement.style.opacity = '1';
3884        alertElement.style.transform = 'translateY(0) translateX(0)';
3885      });
3886
3887      alertElement.addEventListener('mouseleave', () => {
3888        startDismissTimer();
3889      });
3890
3891      // Swipe to dismiss logic for mobile
3892      let startX = 0;
3893      let currentX = 0;
3894
3895      alertElement.addEventListener('touchstart', (e) => {
3896        startX = e.touches[0].clientX;
3897        alertElement.style.transition = 'none'; // Disable transition for 1:1 finger tracking
3898        clearTimeout(dismissTimeout);
3899      }, { passive: true });
3900
3901      alertElement.addEventListener('touchmove', (e) => {
3902        currentX = e.touches[0].clientX;
3903        const diffX = currentX - startX;
3904        // Move the toast with finger
3905        alertElement.style.transform = `translateX(${diffX}px)`;
3906        // Fade out slightly as it moves away
3907        alertElement.style.opacity = Math.max(0, 1 - Math.abs(diffX) / window.innerWidth);
3908      }, { passive: true });
3909
3910      alertElement.addEventListener('touchend', (e) => {
3911        const diffX = currentX - startX;
3912        // Restore transition
3913        alertElement.style.transition = 'opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)';
3914
3915        if (Math.abs(diffX) > 80) {
3916          // Swipe threshold reached, fly off screen and dismiss
3917          const direction = diffX > 0 ? 1 : -1;
3918          alertElement.style.transform = `translateX(${direction * window.innerWidth}px)`;
3919          alertElement.style.opacity = '0';
3920          setTimeout(() => {
3921            if (alertElement.parentNode) {
3922              alertElement.parentNode.removeChild(alertElement);
3923            }
3924          }, 400);
3925        } else {
3926          // Snap back
3927          alertElement.style.transform = 'translateY(0) translateX(0)';
3928          alertElement.style.opacity = '1';
3929          startDismissTimer(); // Resume dismiss timer
3930        }
3931      });
3932    }
3933
3934
3935    /* =================== INIT =================== */
3936    document.addEventListener('DOMContentLoaded', () => {
3937      initTheme();
3938      fetchMiniStats();
3939      let miniStatsTimer = setInterval(fetchMiniStats, 60_000);
3940
3941      document.addEventListener('visibilitychange', () => {
3942        if (document.hidden) {
3943          clearInterval(miniStatsTimer);
3944          miniStatsTimer = null;
3945        } else {
3946          fetchMiniStats();
3947          miniStatsTimer = miniStatsTimer || setInterval(fetchMiniStats, 60_000);
3948        }
3949      });
3950      if (typeof showMembershipAlerts === 'function') {
3951        showMembershipAlerts();
3952      }
3953
3954
3955
3956      // Navbar logo hover clap
3957      (function() {
3958        var wrap  = document.getElementById('site_icon');
3959        var svg   = document.getElementById('_nbl_svg');
3960        var stick = document.getElementById('_nbl_stick');
3961        if (!wrap || !svg || !stick) return;
3962        var busy = false;
3963        wrap.addEventListener('mouseenter', function() {
3964          if (busy) return;
3965          busy = true;
3966          stick.classList.add('_nbl_play');
3967          svg.classList.add('_nbl_play');
3968          setTimeout(function() {
3969            stick.classList.remove('_nbl_play');
3970            svg.classList.remove('_nbl_play');
3971            busy = false;
3972          }, 2600);
3973        });
3974      })();
3975
3976      // Auto-collapse burger menu on outside click
3977      document.addEventListener('click', function (event) {
3978        const navbarCollapse = document.getElementById('navbarSupportedContent');
3979        const navbarToggler = document.querySelector('.navbar-toggler');
3980        if (navbarCollapse && navbarCollapse.classList.contains('show')) {
3981          if (!navbarCollapse.contains(event.target) && !navbarToggler.contains(event.target)) {
3982            new bootstrap.Collapse(navbarCollapse).hide();
3983          }
3984        }
3985      });
3986
3987      // Service Worker Registration for PWA
3988      if ('serviceWorker' in navigator) {
3989        window.addEventListener('load', () => {
3990          navigator.serviceWorker.register('/static/sw.js')
3991            .then(reg => console.log('[PWA] Service Worker registered:', reg.scope))
3992            .catch(err => console.log('[PWA] Service Worker registration failed:', err));
3993        });
3994      }
3995    });
3996
3997  </script>
3997
3998  
3999    
3999<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
3999
4000    
4001  
4002    
4002<script>
4003        let page = 1;
4004        let hasMore = true;
4005        let loading = false;
4006        let inverse = false;
4007        let firstLoad = true;
4008        let debounceTimer;
4009        let pendingReload = false; // queued while a fetch is in-flight
4010
4011        const GAME_CANONICAL_MAPPING = {
4012            "lies of p": "Lies of P",
4013            "minecraft": "Minecraft",
4014            "pubg mobile": "PUBG Mobile",
4015            "battlegrounds mobile india": "PUBG Mobile",
4016            "bgmi": "PUBG Mobile",
4017            "counter-strike 2": "Counter-Strike",
4018            "counter-strike: global offensive": "Counter-Strike",
4019            "grand theft auto v": "GTA V",
4020            "gta 5": "GTA V",
4021        };
4022
4023        function get_canonical_game_name(game_name) {
4024            if (!game_name) return game_name;
4025            const low_name = game_name.toLowerCase().trim();
4026            if (GAME_CANONICAL_MAPPING[low_name]) return GAME_CANONICAL_MAPPING[low_name];
4027            if (low_name.includes(":")) {
4028                const base = low_name.split(":")[0].trim();
4029                if (GAME_CANONICAL_MAPPING[base]) return GAME_CANONICAL_MAPPING[base];
4030            }
4031            return game_name;
4032        }
4033
4034        const container = document.getElementById("clips-container");
4035        const pageWrapper = document.getElementById("loading-line");
4036        const heroLoader = document.getElementById("hero-loader");
4037
4038        window.delayLoader = false;
4039
4040        function getSortBy() { return document.getElementById("sortby").value; }
4041        function getSearch() { return document.getElementById("search").value.trim(); }
4042        function getGame() { return document.getElementById("game-filter").value; }
4043
4044        function scrollCarouselToCard(container, card, behavior = 'smooth') {
4045            if (!container || !card) return;
4046            const left = card.offsetLeft - (container.clientWidth / 2) + (card.clientWidth / 2);
4047            container.scrollTo({ left: left, behavior: behavior });
4048        }
4049
4050        async function setGame(gameName, fromDropdown = false) {
4051            const current = getGame();
4052            let target = (gameName || "").trim();
4053            if (target.toLowerCase() === "all games") {
4054                target = "";
4055            }
4056
4057            // If clicking the same card, reset to "All"
4058            if (!fromDropdown && current === target && target !== "") {
4059                target = "";
4060            }
4061
4062            const filterEl = document.getElementById("game-filter");
4063            if (filterEl) {
4064                const targetClean = target.toLowerCase();
4065                const matchedOpt = [...filterEl.options].find(opt => opt.value.trim().toLowerCase() === targetClean);
4066                if (matchedOpt) {
4067                    filterEl.value = matchedOpt.value;
4068                } else {
4069                    filterEl.value = target;
4070                }
4071                if (filterEl.customDropdown) {
4072                    filterEl.customDropdown.updateLabel();
4073                    filterEl.customDropdown.syncSelectedClass();
4074                }
4075            }
4076            reload();
4077
4078            // Carousel Sync
4079            const container = document.getElementById("game-cards");
4080            if (!container) return;
4081
4082            if (target === "") {
4083                const allCard = container.querySelector('.game-card.all-card');
4084                if (allCard) {
4085                    container.querySelectorAll('.game-card').forEach(c => c.classList.remove('active'));
4086                    allCard.classList.add('active');
4087                    scrollCarouselToCard(container, allCard);
4088                }
4089                return;
4090            }
4091
4092            // Search for card, fetch more if not found
4093            const findCard = () => [...container.querySelectorAll('.game-card')].find(c => (c.getAttribute('data-game') || "").trim().toLowerCase() === target.toLowerCase());
4094            let card = findCard();
4095            let safety = 0;
4096            while (!card && hasMoreGames && safety < 50) { // Safety limit of 50 pages
4097                await fetchGames();
4098                card = findCard();
4099                safety++;
4100            }
4101
4102            if (card) {
4103                container.querySelectorAll('.game-card').forEach(c => c.classList.remove('active'));
4104                card.classList.add('active');
4105                scrollCarouselToCard(container, card);
4106            }
4107        }
4108        window.setGame = setGame; // Ensure it's globally accessible for onchange
4109
4110
4111        function setCookie(name, value, days) {
4112            const d = new Date();
4113            d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
4114            const expires = "expires=" + d.toUTCString();
4115            document.cookie = name + "=" + value + ";" + expires + ";path=/";
4116        }
4117
4118        function getCookie(name) {
4119            const nameEQ = name + "=";
4120            const ca = document.cookie.split(';');
4121            for (let i = 0; i < ca.length; i++) {
4122                let c = ca[i];
4123                while (c.charAt(0) == ' ') c = c.substring(1, c.length);
4124                if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
4125            }
4126            return null;
4127        }
4128
4129        function syncURL() {
4130            const url = new URL(window.location);
4131            const q = getSearch();
4132            const s = getSortBy();
4133            const g = getGame();
4134            q ? url.searchParams.set("q", q) : url.searchParams.delete("q");
4135            s !== "default" ? url.searchParams.set("sortby", s) : url.searchParams.delete("sortby");
4136            g ? url.searchParams.set("game", g) : url.searchParams.delete("game");
4137            inverse ? url.searchParams.set("inverse", "true") : url.searchParams.delete("inverse");
4138
4139            // Persist sortby in cookies    
4140            setCookie('sortby', s, 365);
4141            setCookie('inverse', inverse ? "true" : "false", 365);
4142
4143            window.history.replaceState({}, "", url);
4144        }
4145
4146        function lockControls() {
4147            // Never disable search — users must be able to keep typing freely
4148            document.getElementById("sortby").setAttribute("disabled", "");
4149            document.getElementById("searchnsort").classList.add("searching-pulse");
4150        }
4151
4152        function unlockControls() {
4153            document.getElementById("sortby").removeAttribute("disabled");
4154            document.getElementById("searchnsort").classList.remove("searching-pulse");
4155            // If the user typed a new query while the fetch was in-flight, run it now
4156            if (pendingReload) { pendingReload = false; reload(); }
4157        }
4158
4159        function cardHTML(ch) {
4160            const currentGame = getGame();
4161            const exportLink = currentGame ? `${ch.link}?g=${encodeURIComponent(currentGame)}` : ch.link;
4162            const pinIcon = ch.is_pinned ? '<div class="position-absolute top-0 end-0 m-2 glass p-1 rounded-circle" style="z-index:10"><i class="fa-solid fa-thumbtack text-white"></i></div>' : "";
4163            const premiumIcon = ch.is_premium && !ch.is_pinned ? '<div class="position-absolute top-0 end-0 m-2 glass p-1 rounded-circle" style="z-index:10"><i class="fa-solid fa-heart text-white"></i></div>' : "";
4164            const title = ch.deleted ? `DELETED CHANNEL (${ch.id})` : ch.name;
4165            const tooltip = `Last Clip - ${ch.last_clip.timesince}\n${ch.sub_count} Subscribers\nFirst Clip - ${ch.first_clip_timesince}\n${ch.clip_count} Clips`;
4166
4167            const premiumClass = ch.is_premium ? ' premium-glow' : '';
4168
4169            return `<div class="col-6 col-md-4 col-xl-2 mb-4">
4170            <a href="${exportLink}" title="${tooltip}" class="d-block h-100${premiumClass}" onclick="this.querySelector('.card-img-top').style.viewTransitionName='channel-hero'">
4171                <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative hover-lift no-hover-effect">
4172                    ${pinIcon}${premiumIcon}
4173                    <div class="position-relative h-100 overflow-hidden">
4174                        <img class="card-img-top h-100 w-100" style="object-fit: cover; aspect-ratio: 1/1;" width="200" height="200" src="${ch.image}" loading="lazy"
4175                             onerror="this.src='https://i.pinimg.com/564x/3b/27/a8/3b27a87fcf7d90ae564be23d7a37f778.jpg'">
4176                        <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5; background: linear-gradient(transparent, rgba(0,0,0,0.65));">
4177                            <h6 class="card-title mb-0 text-truncate text-white" style="font-weight: 600; text-shadow: 0 1px 3px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.7);" id="${ch.id}">${title}</h6>
4178                        </div>
4179                    </div>
4180                </div>
4181            </a>
4182        </div>`;
4183        }
4184
4185        async function fetchPage(reset = false) {
4186            if (!hasMore && !reset) return;
4187            if (loading) {
4188                // Don't stack fetches — remember to re-run once the current one settles
4189                if (reset) pendingReload = true;
4190                return;
4191            }
4192
4193            loading = true;
4194            lockControls();
4195
4196            if (reset) {
4197                page = 1;
4198                hasMore = true;
4199                pageWrapper.classList.add("bouncing-gradient-rgb");
4200                if (heroLoader) heroLoader.classList.add("active");
4201            } else if (infiniteLoader) {
4202                infiniteLoader.style.opacity = "1";
4203            }
4204
4205            // Add a small delay for smoother transitions
4206            await new Promise(r => setTimeout(r, 400));
4207
4208            const params = new URLSearchParams({
4209                page, limit: 50, sortby: getSortBy(), inverse: inverse ? "true" : "false",
4210            });
4211            const q = getSearch();
4212            if (q) params.set("q", q);
4213            const g = getGame();
4214            if (g) params.set("game", g);
4215
4216            // Show Skeletons on Reset
4217            if (reset) {
4218                const skeletons = Array(12).fill(0).map(() => `
4219                <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
4220                    <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
4221                        <div class="position-relative h-100 overflow-hidden">
4222                            <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
4223                            <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
4224                                <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
4225                            </div>
4226                        </div>
4227                    </div>
4228                </div>
4229            `).join("");
4230                container.innerHTML = skeletons;
4231            }
4232
4233            try {
4234                const res = await fetch(`/api/channels?${params}`);
4235                const data = await res.json();
4236
4237                hasMore = data.has_more;
4238                page = data.page + 1;
4239
4240                if (reset) {
4241                    container.innerHTML = "";
4242                    // Skeletons are already there, we replace them
4243                }
4244                container.insertAdjacentHTML("beforeend", data.channels.map(cardHTML).join(""));
4245
4246            } catch (e) {
4247                console.error("fetchPage error:", e);
4248            } finally {
4249                loading = false;
4250                unlockControls();
4251
4252                if (pageWrapper) pageWrapper.classList.remove("bouncing-gradient-rgb");
4253                if (heroLoader) heroLoader.classList.remove("active");
4254                if (infiniteLoader) infiniteLoader.style.opacity = "0";
4255
4256                if (reset) {
4257                    document.body.classList.add('loaded');
4258                }
4259            }
4260        }
4261
4262        function reload() { syncURL(); fetchPage(true); }
4263        function toggleInverse() {
4264            inverse = !inverse;
4265            document.getElementById("invert-btn").classList.toggle("active", inverse);
4266            reload();
4267        }
4268
4269
4270
4271        async function restoreFromURL() {
4272            const url = new URL(window.location);
4273            const q = url.searchParams.get("q") || "";
4274            const defaultSort = "sub_count";
4275            const sortby = url.searchParams.get("sortby") || getCookie('sortby') || defaultSort;
4276            const rawGame = url.searchParams.get("game") || "";
4277            const game = get_canonical_game_name(rawGame);
4278            inverse = url.searchParams.get("inverse") === "true";
4279
4280            document.getElementById("invert-btn").classList.toggle("active", inverse);
4281            document.getElementById("search").value = q;
4282            document.getElementById("sortby").value = sortby;
4283
4284            if (game) {
4285                const filter = document.getElementById("game-filter");
4286                const gameClean = game.trim();
4287                if (gameClean.toLowerCase() !== "all games" && ![...filter.options].some(opt => opt.value.trim().toLowerCase() === gameClean.toLowerCase())) {
4288                    const opt = document.createElement("option");
4289                    opt.value = gameClean;
4290                    opt.textContent = gameClean;
4291                    filter.appendChild(opt);
4292                    if (filter.customDropdown) {
4293                        filter.customDropdown.renderOptions();
4294                        filter.customDropdown.updateLabel();
4295                    }
4296                }
4297                await setGame(game, true);
4298            } else {
4299                const filterEl = document.getElementById("game-filter");
4300                if (filterEl) {
4301                    filterEl.value = "";
4302                    if (filterEl.customDropdown) {
4303                        filterEl.customDropdown.updateLabel();
4304                        filterEl.customDropdown.syncSelectedClass();
4305                    }
4306                }
4307            }
4308        }
4309
4310        window.addEventListener("popstate", () => { restoreFromURL(); fetchPage(true); });
4311
4312        /* ── Hero Scene Manager: 25 Premium Triple-Digit Aesthetics ── */
4313        let currentHeroScene = null;
4314        const sceneRegistry = {};
4315
4316        function registerScene(name, config) {
4317            sceneRegistry[name] = config;
4318        }
4319
4320        /* ── Scene Definitions ── */
4321
4322        registerScene('Kinetic Shard', {
4323            init: (scene, camera, color) => {
4324                const geo = new THREE.IcosahedronGeometry(8, 0);
4325                const mat = new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.1, shininess: 100, flatShading: true });
4326                const shard = new THREE.Mesh(geo, mat);
4327                const wire = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color, transparent: true, op
4327acity: 0.3 }));
4328                shard.add(wire);
4329                scene.add(shard);
4330                camera.position.z = 25;
4331                const dl = new THREE.DirectionalLight(0xffffff, 1.2);
4332                dl.position.set(10, 10, 10); scene.add(dl);
4333                const pl = new THREE.PointLight(0xffffff, 0.8);
4334                pl.position.set(-10, -10, 5); scene.add(pl);
4335                scene.add(new THREE.AmbientLight(0xffffff, 0.4));
4336                return { shard, mat, wire };
4337            },
4338            update: (data, time, mouse) => {
4339                data.shard.rotation.y += 0.015;
4340                data.shard.rotation.x += 0.008;
4341                const pulse = 1 + Math.sin(time * 1.5) * 0.1;
4342                data.shard.scale.setScalar(pulse);
4343            }
4344        });
4345
4346        registerScene('Cosmic Flow', {
4347            init: (scene, camera, color) => {
4348                const count = 3000;
4349                const pos = new Float32Array(count * 3);
4350                for (let i = 0; i < count; i++) {
4351                    pos[i * 3] = (Math.random() - 0.5) * 50; pos[i * 3 + 1] = (Math.random() - 0.5) * 40; pos[i * 3 + 2] = (Math.random() - 0.5) * 40;
4352                }
4353                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4354                const mat = new THREE.PointsMaterial({ color, size: 0.05, transparent: true, opacity: 0.6 });
4355                const points = new THREE.Points(geo, mat); scene.add(points);
4356                camera.position.z = 30;
4357                return { points, mat, geo, count };
4358            },
4359            update: (data, time) => {
4360                const p = data.geo.attributes.position.array;
4361                for (let i = 0; i < data.count; i++) {
4362                    p[i * 3] += Math.sin(time + p[i * 3 + 1] * 0.1) * 0.02;
4363                    p[i * 3 + 1] += Math.cos(time + p[i * 3] * 0.1) * 0.02;
4364                }
4365                data.geo.attributes.position.needsUpdate = true;
4366            }
4367        });
4368
4369        registerScene('Neural Plexus', {
4370            init: (scene, camera, color) => {
4371                const count = 100;
4372                const pos = new Float32Array(count * 3);
4373                const vels = new Float32Array(count * 3);
4374                for (let i = 0; i < count; i++) {
4375                    pos[i * 3] = (Math.random() - 0.5) * 40; pos[i * 3 + 1] = (Math.random() - 0.5) * 30; pos[i * 3 + 2] = (Math.random() - 0.5) * 20;
4376                    vels[i * 3] = (Math.random() - 0.5) * 0.02; vels[i * 3 + 1] = (Math.random() - 0.5) * 0.02; vels[i * 3 + 2] = (Math.random() - 0.5) * 0.02;
4377                }
4378                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4379                const mat = new THREE.PointsMaterial({ color, size: 0.1 });
4380                const points = new THREE.Points(geo, mat); scene.add(points);
4381                const lineMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.2 });
4382                const lineGeo = new THREE.BufferGeometry();
4383                lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(600), 3));
4384                const lines = new THREE.LineSegments(lineGeo, lineMat); scene.add(lines);
4385                camera.position.z = 25;
4386                return { points, geo, vels, count, lines, lineMat, lineGeo };
4387            },
4388            update: (data) => {
4389                const p = data.geo.attributes.position.array;
4390                for (let i = 0; i < data.count; i++) {
4391                    p[i * 3] += data.vels[i * 3]; p[i * 3 + 1] += data.vels[i * 3 + 1]; p[i * 3 + 2] += data.vels[i * 3 + 2];
4392                    if (Math.abs(p[i * 3]) > 20) data.vels[i * 3] *= -1;
4393                    if (Math.abs(p[i * 3 + 1]) > 15) data.vels[i * 3 + 1] *= -1;
4394                }
4395                data.geo.attributes.position.needsUpdate = true;
4396            }
4397        });
4398
4399        registerScene('Cyber Grid', {
4400            init: (scene, camera, color) => {
4401                const grid = new THREE.GridHelper(100, 40, color, color);
4402                grid.position.y = -5; grid.material.transparent = true; grid.material.opacity = 0.3;
4403                scene.add(grid);
4404                camera.position.z = 15;
4405                return { grid };
4406            },
4407            update: (data, time) => { data.grid.position.z = (time * 2) % 2.5; }
4408        });
4409
4410        registerScene('Glass Cubes', {
4411            init: (scene, camera, color) => {
4412                const cubes = [];
4413                for (let i = 0; i < 15; i++) {
4414                    const m = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshPhongMaterial({ color, transparent: true, op
4414acity: 0.1 }));
4415                    m.position.set((Math.random() - 0.5) * 30, (Math.random() - 0.5) * 20, (Math.random() - 0.5) * 15);
4416                    scene.add(m); cubes.push(m);
4417                }
4418                scene.add(new THREE.AmbientLight(0xffffff, 0.5));
4419                camera.position.z = 25;
4420                return { cubes, mat: cubes[0].material };
4421            },
4422            update: (data) => { data.cubes.forEach(c => { c.rotation.x += 0.01; c.rotation.y += 0.01; }); }
4423        });
4424
4425        registerScene('Aura Waves', {
4426            init: (scene, camera, color) => {
4427                const geo = new THREE.PlaneGeometry(60, 40, 20, 20);
4428                const mat = new THREE.MeshPhongMaterial({ color, wireframe: true, transparent: true, opacity: 0.15 });
4429                const plane = new THREE.Mesh(geo, mat); plane.rotation.x = -Math.PI * 0.3;
4430                scene.add(plane); camera.position.z = 30;
4431                return { geo, mat };
4432            },
4433            update: (data, time) => {
4434                const p = data.geo.attributes.position.array;
4435                for (let i = 0; i < p.length; i += 3) p[i + 2] = Math.sin(p[i] * 0.2 + time) * 2;
4436                data.geo.attributes.position.needsUpdate = true;
4437            }
4438        });
4439
4440        registerScene('Galaxy Spiral', {
4441            init: (scene, camera, color) => {
4442                const count = 3000;
4443                const pos = new Float32Array(count * 3);
4444                for (let i = 0; i < count; i++) {
4445                    const r = Math.random() * 20, a = r * 0.5 + (i % 3) * (Math.PI * 2 / 3);
4446                    pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = (Math.random() - 0.5) * 2; pos[i * 3 + 2] = Math.sin(a) * r;
4447                }
4448                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4449                const mat = new THREE.PointsMaterial({ color, size: 0.05, transparent: true, opacity: 0.6 });
4450                const g = new THREE.Points(geo, mat); g.rotation.x = 0.5;
4451                scene.add(g); camera.position.z = 25;
4452                return { g, mat };
4453            },
4454            update: (data) => { data.g.rotation.y += 0.002; }
4455        });
4456
4457        registerScene('Octa Swarm', {
4458            init: (scene, camera, color) => {
4459                const s = [];
4460                for (let i = 0; i < 20; i++) {
4461                    const m = new THREE.Mesh(new THREE.OctahedronGeometry(1), new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.2 }));
4462                    m.position.set((Math.random() - 0.5) * 40, (Math.random() - 0.5) * 30, (Math.random() - 0.5) * 20);
4463                    scene.add(m); s.push(m);
4464                }
4465                scene.add(new THREE.AmbientLight(0xffffff, 0.5));
4466                camera.position.z = 25;
4467                return { s, mat: s[0].material };
4468            },
4469            update: (data) => { data.s.forEach(m => { m.rotation.y += 0.01; m.position.y += Math.sin(performance.now() * 0.001) * 0.01; }); }
4470        });
4471
4472        registerScene('Atomic Gimbal', {
4473            init: (scene, camera, color) => {
4474                const rings = [];
4475                for (let i = 0; i < 3; i++) {
4476                    const r = new THREE.Mesh(new THREE.TorusGeometry(8 + i * 4, 0.05, 16, 100), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.4 }));
4477                    r.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
4478                    scene.add(r); rings.push(r);
4479                }
4480                camera.position.z = 35;
4481                return { rings };
4482            },
4483            update: (data) => { data.rings.forEach(r => { r.rotation.x += 0.01; r.rotation.y += 0.005; }); }
4484        });
4485
4486        registerScene('Hyper Jump', {
4487            init: (scene, camera, color) => {
4488                const count = 1000;
4489                const pos = new Float32Array(count * 3);
4490                for (let i = 0; i < count; i++) {
4491                    pos[i * 3] = (Math.random() - 0.5) * 20; pos[i * 3 + 1] = (Math.random() - 0.5) * 20; pos[i * 3 + 2] = (Math.random() - 1) * 20;
4492                }
4493                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4494                const mat = new THREE.PointsMaterial({ color, size: 0.05, transparent: true, op
4494acity: 0.8 });
4495                const s = new THREE.Points(geo, mat); scene.add(s);
4496                camera.position.z = 5;
4497                return { geo, count, mat };
4498            },
4499            update: (data) => {
4500                const p = data.geo.attributes.position.array;
4501                for (let i = 0; i < data.count; i++) { p[i * 3 + 2] += 0.2; if (p[i * 3 + 2] > 5) p[i * 3 + 2] = -15; }
4502                data.geo.attributes.position.needsUpdate = true;
4503            }
4504        });
4505
4506        registerScene('DNA Helix', {
4507            init: (scene, camera, color) => {
4508                const g1 = new THREE.Group(), g2 = new THREE.Group();
4509                for (let i = 0; i < 60; i++) {
4510                    const y = (i - 30) * 0.4, a = i * 0.3;
4511                    const s1 = new THREE.Mesh(new THREE.SphereGeometry(0.2), new THREE.MeshBasicMaterial({ color }));
4512                    s1.position.set(Math.cos(a) * 3, y, Math.sin(a) * 3); g1.add(s1);
4513                    const s2 = s1.clone(); s2.position.set(Math.cos(a + Math.PI) * 3, y, Math.sin(a + Math.PI) * 3); g2.add(s2);
4514                }
4515                scene.add(g1); scene.add(g2); camera.position.z = 30;
4516                return { g1, g2 };
4517            },
4518            update: (data) => { data.g1.rotation.y += 0.02; data.g2.rotation.y += 0.02; }
4519        });
4520
4521        registerScene('Digital Rain', {
4522            init: (scene, camera, color) => {
4523                const count = 1500;
4524                const pos = new Float32Array(count * 3);
4525                for (let i = 0; i < count; i++) {
4526                    pos[i * 3] = (Math.random() - 0.5) * 40; pos[i * 3 + 1] = Math.random() * 40 - 20; pos[i * 3 + 2] = (Math.random() - 0.5) * 20;
4527                }
4528                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4529                const mat = new THREE.PointsMaterial({ color, size: 0.1, transparent: true, opacity: 0.6 });
4530                const r = new THREE.Points(geo, mat); scene.add(r); camera.position.z = 25;
4531                return { geo, count, mat };
4532            },
4533            update: (data) => {
4534                const p = data.geo.attributes.position.array;
4535                for (let i = 0; i < data.count; i++) { p[i * 3 + 1] -= 0.2; if (p[i * 3 + 1] < -20) p[i * 3 + 1] = 20; }
4536                data.geo.attributes.position.needsUpdate = true;
4537            }
4538        });
4539
4540        registerScene('Floating Prisms', {
4541            init: (scene, camera, color) => {
4542                const p = [];
4543                for (let i = 0; i < 15; i++) {
4544                    const m = new THREE.Mesh(new THREE.CylinderGeometry(0, 1.8, 3.5, 3), new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.25, flatShading: true }));
4545                    const r = 10 + Math.random() * 20;
4546                    const a = Math.random() * Math.PI * 2;
4547                    m.position.set(Math.cos(a) * r, (Math.random() - 0.5) * 20, Math.sin(a) * r);
4548                    scene.add(m); p.push({ m, r, a, s: 0.005 + Math.random() * 0.01 });
4549                }
4550                const pl = new THREE.PointLight(0xffffff, 1);
4551                pl.position.set(5, 5, 5); scene.add(pl);
4552                camera.position.z = 35;
4553                return { p };
4554            },
4555            update: (data, time) => {
4556                data.p.forEach(o => {
4557                    o.a += o.s;
4558                    o.m.position.x = Math.cos(o.a) * o.r;
4559                    o.m.position.z = Math.sin(o.a) * o.r;
4560                    o.m.rotation.y += 0.02;
4561                    o.m.position.y += Math.sin(time + o.r) * 0.05;
4562                });
4563            }
4564        });
4565
4566        registerScene('Abstract Bokeh', {
4567            init: (scene, camera, color) => {
4568                const b = [];
4569                for (let i = 0; i < 8; i++) {
4570                    const m = new THREE.Mesh(new THREE.SphereGeometry(Math.random() * 4 + 2, 32, 32), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.05 }));
4571                    m.position.set((Math.random() - 0.5) * 40, (Math.random() - 0.5) * 30, (Math.random() - 0.5) * 20);
4572                    scene.add(m); b.push({ m, v: new THREE.Vector3((Math.random() - 0.5) * 0.01, (Math.random() - 0.5) * 0.01, 0) });
4573                }
4574                camera.position.z = 30;
4575                return { b };
4576            },
4577            update: (data, time) => {
4578                data.b.forEach((o, i) => {
4579                    o.m.position.add(o.v);
4580                    if (Math.abs(o.m.position.x) > 25) o.v.x *= -1;
4581                    if (Math.abs(o.m.position.y) > 20) o.v.y *= -1;
4582                    o.m.material.opacity = 0.05 + Math.sin(time + i) * 0.03;
4583                });
4584            }
4585        });
4586
4587        registerScene('Neon Horizon', {
4588            init: (scene, camera, color) => {
4589                const g = new THREE.GridHelper(100, 20, color, color);
4590                g.rotation.x = Math.PI * 0.5; g.position.z = -10;
4591                scene.add(g); camera.position.y = 2; camera.position.z = 10;
4592                return { g };
4593            },
4594            update: (data) => { data.g.position.y = (data.g.position.y - 0.05) % 5; }
4595        });
4596
4597        registerScene('Static Noise', {
4598            init: (scene, camera, color) => {
4599                const count = 5000;
4600                const pos = new Float32Array(count * 3);
4601                for (let i = 0; i < count; i++) {
4602                    pos[i * 3] = (Math.random() - 0.5) * 50; pos[i * 3 + 1] = (Math.random() - 0.5) * 40; pos[i * 3 + 2] = (Math.random() - 0.5) * 40;
4603                }
4604                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4605                const mat = new THREE.PointsMaterial({ color, size: 0.01 });
4606                const d = new THREE.Points(geo, mat); scene.add(d); camera.position.z = 25;
4607                return { geo, count, mat };
4608            },
4609            update: (data) => {
4610                const p = data.geo.attributes.position.array;
4611                for (let i = 0; i < data.count * 3; i++) p[i] += (Math.random() - 0.5) * 0.3;
4612                data.geo.attributes.position.needsUpdate = true;
4613            }
4614        });
4615
4616        registerScene('Pulse Field', {
4617            init: (scene, camera, color) => {
4618                const count = 400;
4619                const pos = new Float32Array(count * 3);
4620                for (let i = 0; i < count; i++) {
4621                    const a = Math.random() * Math.PI * 2, r = 5 + Math.random() * 15;
4622                    pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = Math.sin(a) * r; pos[i * 3 + 2] = (Math.random() - 0.5) * 10;
4623                }
4624                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4625                const mat = new THREE.PointsMaterial({ color, size: 0.1 });
4626                const p = new THREE.Points(geo, mat); scene.add(p); camera.position.z = 30;
4627                return { p, mat };
4628            },
4629            update: (data, time) => { data.p.scale.setScalar(1 + Math.sin(time) * 0.2); }
4630        });
4631
4632        registerScene('Torus Knot', {
4633            init: (scene, camera, color) => {
4634                const g = new THREE.TorusKnotGeometry(6, 1.2, 100, 16);
4635                const m = new THREE.Mesh(g, new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.15, shininess: 100 }));
4636                scene.add(m);
4637                const pl = new THREE.PointLight(0xffffff, 1);
4638                pl.position.set(10, 10, 10); scene.add(pl);
4639                camera.position.z = 25; return { m, mat: m.material };
4640            },
4641            update: (data, time) => {
4642                data.m.rotation.x += 0.015;
4643                data.m.rotation.y += 0.02;
4644                data.m.rotation.z += 0.01;
4645                const s = 1 + Math.sin(time * 0.8) * 0.15;
4646                data.m.scale.setScalar(s);
4647            }
4648        });
4649
4650        registerScene('The Shimmer', {
4651            init: (scene, camera, color) => {
4652                const count = 2000;
4653                const pos = new Float32Array(count * 3);
4654                for (let i = 0; i < count; i++) {
4655                    pos[i * 3] = (Math.random() - 0.5) * 50; pos[i * 3 + 1] = (Math.random() - 0.5) * 40; pos[i * 3 + 2] = (Math.random() - 0.5) * 30;
4656                }
4657                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4658                const mat = new THREE.PointsMaterial({ color, size: 0.05, transparent: true, opacity: 0.4 });
4659                const p = new THREE.Points(geo, mat); scene.add(p); camera.position.z = 25;
4660                return { mat };
4661            },
4662            update: (data, time) => { data.mat.opacity = 0.2 + Math.sin(time * 2) * 0.2; }
4663        });
4664
4665        registerScene('Spiral Vortex', {
4666            init: (scene, camera, color) => {
4667                const count = 1500;
4668                const pos = new Float32Array(count * 3);
4669                for (let i = 0; i < count; i++) {
4670                    const r = 5 + Math.random() * 20, a = Math.random() * Math.PI * 2;
4671                    pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = Math.sin(a) * r; pos[i * 3 + 2] = (Math.random() - 0.5) * 10;
4672                }
4673                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4674                const mat = new THREE.PointsMaterial({ color, size: 0.05 });
4675                const v = new THREE.Points(geo, mat); scene.add(v); camera.position.z = 30;
4676                return { v, mat };
4677            },
4678            update: (data) => { data.v.rotation.z += 0.02; data.v.scale.multiplyScalar(0.999); if (data.v.scale.x < 0.5) data.v.scale.setScalar(1.5); }
4679        });
4680
4681        registerScene('Geometric Portal', {
4682            init: (scene, camera, color) => {
4683                const g = new THREE.Group();
4684                for (let i = 0; i < 15; i++) {
4685                    const m = new THREE.Mesh(new THREE.RingGeometry(i * 2.5, i * 2.5 + 0.1, 32), new THREE.MeshBasicMaterial({ color, transparent: true, op
4685acity: 1 - i / 15 }));
4686                    m.position.z = -i * 6; g.add(m);
4687                }
4688                scene.add(g); camera.position.z = 5; return { g };
4689            },
4690            update: (data) => { data.g.children.forEach(c => { c.position.z += 0.15; if (c.position.z > 5) c.position.z = -85; }); }
4691        });
4692
4693        registerScene('Floating Gems', {
4694            init: (scene, camera, color) => {
4695                const g = [];
4696                for (let i = 0; i < 15; i++) {
4697                    const m = new THREE.Mesh(new THREE.DodecahedronGeometry(1.5), new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.2, shininess: 100 }));
4698                    m.position.set((Math.random() - 0.5) * 35, (Math.random() - 0.5) * 25, (Math.random() - 0.5) * 15);
4699                    scene.add(m); g.push({ m, offset: Math.random() * Math.PI * 2, speed: 0.01 + Math.random() * 0.02 });
4700                }
4701                const pl = new THREE.PointLight(0xffffff, 1.2);
4702                pl.position.set(0, 10, 10); scene.add(pl);
4703                camera.position.z = 30; return { g };
4704            },
4705            update: (data, time) => {
4706                data.g.forEach(o => {
4707                    o.m.rotation.y += o.speed;
4708                    o.m.rotation.x += o.speed * 0.5;
4709                    o.m.position.y += Math.sin(time + o.offset) * 0.03;
4710                });
4711            }
4712        });
4713
4714        registerScene('The Matrix', {
4715            init: (scene, camera, color) => {
4716                const count = 1500;
4717                const pos = new Float32Array(count * 3);
4718                for (let i = 0; i < count; i++) {
4719                    pos[i * 3] = (Math.random() - 0.5) * 40; pos[i * 3 + 1] = Math.random() * 40 - 20; pos[i * 3 + 2] = (Math.random() - 0.5) * 20;
4720                }
4721                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4722                const mat = new THREE.PointsMaterial({ color, size: 0.08, transparent: true, opacity: 0.8 });
4723                const p = new THREE.Points(geo, mat); scene.add(p); camera.position.z = 25;
4724                return { geo, count, mat };
4725            },
4726            update: (data) => {
4727                const p = data.geo.attributes.position.array;
4728                for (let i = 0; i < data.count; i++) {
4729                    p[i * 3 + 1] -= (0.2 + Math.random() * 0.3);
4730                    if (p[i * 3 + 1] < -20) {
4731                        p[i * 3 + 1] = 20;
4732                        p[i * 3] = (Math.random() - 0.5) * 40; // Horizontal glitch reset
4733                    }
4734                }
4735                data.geo.attributes.position.needsUpdate = true;
4736            }
4737        });
4738
4739        registerScene('Thumbnail Matrix', {
4740            init: (scene, camera, color) => {
4741                const count = 300;
4742                const pointsList = [];
4743                const group = new THREE.Group();
4744                scene.add(group);
4745                camera.position.z = 5;
4746
4747                fetch('/static/latest_streams.json')
4748                    .then(res => res.json())
4749                    .then(streams => {
4750                        if (streams && streams.length > 0) {
4751                            const loader = new THREE.TextureLoader();
4752                            const picked = streams;
4753
4754                            picked.forEach(id => {
4755                                const tx = loader.load(`https://img.youtube.com/vi/${id}/mqdefault.jpg`);
4756                                const pCount = Math.floor(count / picked.length);
4757
4758                                const pos = new Float32Array(pCount * 3);
4759                                const vels = new Float32Array(pCount);
4760
4761                                for (let i = 0; i < pCount; i++) {
4762                                    pos[i * 3] = (Math.random() - 0.5) * 120;
4763                                    pos[i * 3 + 1] = (Math.random() - 0.5) * 120;
4764                                    pos[i * 3 + 2] = -10 - (Math.random() * 120);
4765                                    vels[i] = 0.01 + Math.random() * 0.03;
4766                                }
4767
4768                                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4769                                const mat = new THREE.PointsMaterial({
4770                                    color: 0xffffff,
4771                                    size: 7.0,
4772                                    map: tx,
4773                                    transparent: true,
4774                                    opacity: 0.5
4775                                });
4776
4777                                const pts = new THREE.Points(geo, mat);
4778                                group.add(pts);
4779                                pointsList.push({ pts, geo, count: pCount, vels });
4780                            });
4781                        }
4782                    }).catch(e =>
4782 console.log('Thumbnail fetch error:', e));
4783
4784                return { pointsList };
4785            },
4786            update: (data) => {
4787                if (!data.pointsList) return;
4788                data.pointsList.forEach(obj => {
4789                    const p = obj.geo.attributes.position.array;
4790                    for (let i = 0; i < obj.count; i++) {
4791                        p[i * 3 + 2] += obj.vels[i];
4792                        if (p[i * 3 + 2] > 5) {
4793                            p[i * 3 + 2] = -120;
4794                            p[i * 3] = (Math.random() - 0.5) * 120;
4795                            p[i * 3 + 1] = (Math.random() - 0.5) * 120;
4796                        }
4797                    }
4798                    obj.geo.attributes.position.needsUpdate = true;
4799                });
4800            }
4801        });
4802
4803        registerScene('Collage Wallpaper', {
4804            init: (scene, camera, color) => {
4805                const loader = new THREE.TextureLoader();
4806                const texture = loader.load('/static/thumbnail_collage.png');
4807                texture.wrapS = THREE.RepeatWrapping;
4808                texture.wrapT = THREE.RepeatWrapping;
4809                texture.repeat.set(1.5, 1.5);
4810
4811                const isDark = document.body.classList.contains('dark-theme');
4812                const geo = new THREE.PlaneGeometry(120, 80);
4813                const mat = new THREE.MeshBasicMaterial({
4814                    map: texture,
4815                    transparent: true,
4816                    opacity: isDark ? 0.3 : 0.4
4817                });
4818                const m = new THREE.Mesh(geo, mat);
4819                m.position.z = -15;
4820                scene.add(m);
4821                camera.position.z = 25;
4822                return { m, texture, mat };
4823            },
4824            update: (data, time) => {
4825                data.texture.offset.x = time * 0.01;
4826                data.texture.offset.y = -(time * 0.005);
4827            }
4828        });
4829
4830        registerScene('Liquid Floor', {
4831            init: (scene, camera, color) => {
4832                const geo = new THREE.PlaneGeometry(80, 80, 30, 30);
4833                const mat = new THREE.MeshPhongMaterial({ color, wireframe: true, transparent: true, opacity: 0.1 });
4834                const f = new THREE.Mesh(geo, mat); f.rotation.x = -Math.PI * 0.5; f.position.y = -6;
4835                scene.add(f); scene.add(new THREE.AmbientLight(0xffffff, 0.5));
4836                camera.position.z = 20; camera.position.y = 2;
4837                return { f, geo, mat };
4838            },
4839            update: (data, time) => {
4840                const p = data.geo.attributes.position.array;
4841                for (let i = 0; i < p.length; i += 3) p[i + 2] = Math.sin(p[i] * 0.15 + time) * 2.5;
4842                data.geo.attributes.position.needsUpdate = true;
4843            }
4844        });
4845
4846
4847        registerScene('Micro Dust', {
4848            init: (scene, camera, color) => {
4849                const count = 4000;
4850                const pos = new Float32Array(count * 3);
4851                for (let i = 0; i < count; i++) {
4852                    pos[i * 3] = (Math.random() - 0.5) * 50; pos[i * 3 + 1] = (Math.random() - 0.5) * 40; pos[i * 3 + 2] = (Math.random() - 0.5) * 40;
4853                }
4854                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4855                const mat = new THREE.PointsMaterial({ color, size: 0.02, transparent: true, opacity: 0.4 });
4856                const d = new THREE.Points(geo, mat); scene.add(d); camera.position.z = 25;
4857                return { geo, count, mat };
4858            },
4859            update: (data, time) => {
4860                const p = data.geo.attributes.position.array;
4861                for (let i = 0; i < data.count; i++) {
4862                    p[i * 3] += Math.sin(time + i) * 0.01;
4863                    p[i * 3 + 1] += Math.cos(time + i) * 0.01;
4864                }
4865                data.geo.attributes.position.needsUpdate = true;
4866            }
4867        });
4868
4869        registerScene('Star field', {
4870            init: (scene, camera, color) => {
4871                const count = 1500;
4872                const pos = new Float32Array(count * 3);
4873                const vels = new Float32Array(count);
4874                for (let i = 0; i < count; i++) {
4875                    pos[i * 3] = (Math.random() - 0.5) * 20;
4876                    pos[i * 3 + 1] = (Math.random() - 0.5) * 20;
4877                    pos[i * 3 + 2] = (Math.random() - 1.0) * 15;
4878                    vels[i] = 0.004 + Math.random() * 0.008;
4879                }
4880                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4881
4882                const loader = new THREE.TextureLoader();
4883                const texture = loader.load('/static/logo.svg');
4884
4885                const isDark = document.body.classList.contains('dark-theme');
4886                const mat = new THREE.PointsMaterial({
4887                    color: 0xffffff, // Keep logo original color
4888                    size: 0.1,
4889                    map: texture,
4890                    transparent: true,
4891                    alphaTest: 0.2,
4892                    opacity: isDark ? 0.6 : 0.8
4893                });
4894                const s = new THREE.Points(geo, mat); scene.add(s); camera.position.z = 5;
4895                return { s, geo, count, mat, vels };
4896            },
4897            update: (data, time, mouse) => {
4898                const p = data.geo.attributes.position.array;
4899                for (let i = 0; i < data.count; i++) {
4900                    p[i * 3 + 2] += data.vels[i];
4901                    if (p[i * 3 + 2] > 5) p[i * 3 + 2] = -15;
4902                }
4903                data.geo.attributes.position.needsUpdate = true;
4904            }
4905        });
4906
4907        /* ── Core Scene Engine ── */
4908
4909        function initHeroScene() {
4910            const canvas = document.getElementById('hero-canvas');
4911            if (!canvas) return;
4912
4913            // Skip canvas when GPU is unavailable or user prefers reduced motion
4914            if (document.documentElement.classList.contains('no-gpu') ||
4915                window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
4916                canvas.style.display = 'none';
4917
4918                // Static collage fallback when Collage Wallpaper scene is selected
4919                const selectedScene = getCookie('hero_scene_v2') || 'Collage Wallpaper';
4920                if (selectedScene === 'Collage Wallpaper') {
4921                    const isDark = document.body.classList.contains('dark-theme');
4922                    const img = document.createElement('img');
4923                    img.src = '/static/thumbnail_collage.png';
4924                    img.alt = '';
4925                    img.setAttribute('aria-hidden', 'true');
4926                    img.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:1;opacity:${isDark ? 0.3 : 0.4};pointer-events:none;`;
4927                    canvas.parentElement.appendChild(img);
4928                }
4929
4930                // One-time GPU warning — no auto-dismiss, stored in localStorage
4931                if (!localStorage.getItem('no_gpu_warned')) {
4932                    let alertContainer = document.getElementById('custom-alerts-container');
4933                    if (!alertContainer) {
4934                        alertContainer = document.createElement('div');
4935                        alertContainer.id = 'custom-alerts-container';
4936                        document.body.appendChild(alertContainer);
4937                    }
4938                    const el = document.createElement('div');
4939                    el.className = 'alert custom-toast toast-warning m-0';
4940                    el.setAttribute('role', 'alert');
4941                    el.innerHTML = `
4942                        <i class="fa-solid fa-triangle-exclamation toast-icon"></i>
4943                        <div class="toast-message">Hardware acceleration is unavailable — animations have been disabled to keep the page smooth.</div>
4944                        <button type="button" style="background:none;border:none;color:inherit;opacity:0.7;cursor:pointer;padding:0 0.25rem;font-size:1.1rem;flex-shrink:0;" aria-label="Got it">
4945                            <i class="fa-solid fa-xmark"></i>
4946                        </button>
4947                    `;
4948                    el.style.opacity = '0';
4949                    el.style.transform = 'translateY(20px)';
4950                    alertContainer.appendChild(el);
4951                    setTimeout(() => { el.style.opacity = '1'; el.style.transform = 'translateY(0)'; }, 10);
4952                    el.querySelector('button').addEventListener('click', () => {
4953                        localStorage.setItem('no_gpu_warned', '1');
4954                        el.style.opacity = '0';
4955                        el.style.transform = 'scale(0.9)';
4956                        setTimeout(() => el.parentNode && el.parentNode.removeChild(el), 400);
4957                    });
4958                }
4959
4960                return;
4961            }
4962
4963            if (typeof THREE === 'undefined') return;
4964
4965            const scene = new THREE.Scene();
4966            const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
4967            const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
4968            renderer.setSize(window.innerWidth, window.innerHeight);
4969            // Lower pixel ratio on mobile to reduce GPU work (~2-4× cheaper on high-DPR phones)
4970            renderer.setPixelRatio(window.innerWidth <= 768
4971                ? Math.min(window.devicePixelRatio, 1.5)
4972                : Math.min(window.devicePixelRatio, 2));
4973
4974            let currentData = null, currentConfig = null, frameId = null;
4975            let mouseX = 0, mouseY = 0;
4976
4977            const populateDropdown = () => {
4978                const select = document.getElementById('hero-scene-select');
4979                if (!select) return;
4980                select.innerHTML = '';
4981                Object.keys(sceneRegistry).sort().forEach(name => {
4982                    const opt = document.createElement('option');
4983                    opt.value = name; opt.textContent = name;
4984                    select.appendChild(opt);
4985                });
4986                const stored = getCookie('hero_scene_v2') || 'Collage Wallpaper';
4987                select.value = stored;
4988                return stored;
4989            };
4990
4991            window.switchHeroScene = (name) => {
4992                if (frameId) cancelAnimationFrame(frameId);
4993                scene.clear();
4994                const isDark = document.body.classList.contains('dark-theme');
4995                currentConfig = sceneRegistry[name];
4996                if (currentConfig) {
4997                    currentData = currentConfig.init(scene, camera);
4998                    setCookie('hero_scene_v2', name, 365);
4999                    animate();
5000                }
5001            };
5002
5003            // Konami Code Implementation
5004            const konamiCode = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
5005            const reverseKonami = ['a', 'b', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp'];
5006            const recentKeys = [];
5007
5008            const fontRegistry = [
5009                "Outfit", "Poppins", "Abril Fatface", "Alfa Slab One", "Anton", "Bangers",
5010                "Barlow Condensed", "Bevan", "Bowlby One SC", "Bungee", "Caveat", "Cinzel", "Rajdhani",
5011                "Comfortaa", "Creepster", "Dancing Script", "Gloria Hallelujah", "Indie Flower",
5012                "Josefin Sans", "Kaushan Script", "Lobster", "Luckiest Guy", "Monoton",
5013                "Orbitron", "Pacifico", "Permanent Marker", "Playfair Display", "Press Start 2P",
5014                "Righteous", "Roboto Mono", "Sacramento", "Shadows Into Light", "Special Elite",
5015                "Syncopate", "Titan One", "Vampiro One"
5016            ].sort();
5017
5018            const populateFontDropdown = () => {
5019                const select = document.getElementById('hero-font-select');
5020                if (!select) return;
5021                select.innerHTML = '';
5022                fontRegistry.forEach(font => {
5023                    const opt = document.createElement('option');
5024                    opt.value = font; opt.textContent = font;
5025                    opt.style.fontFamily = `'${font}', sans-serif`;
5026                    select.appendChild(opt);
5027                });
5028                const stored = getCookie('hero_font') || 'Rajdhani';
5029                select.value = stored;
5030                return stored;
5031            };
5032
5033            window.updateHeroFont = (fontName) => {
5034                // Logged-out heroes render .hero-title; logged-in ones render the
5035                // greeting name instead. Without the second selector the font
5036                // picker silently did nothing for signed-in users.
5037                const title = document.querySelector('.hero-title, .hero-greeting-name');
5038                if (!title) return;
5039
5040                // Load font dynamically if not already present
5041                const fontId = `font-${fontName.replace(/ /g, '-')}`;
5042                if (!document.getElementById(fontId)) {
5043                    const link = document.createElement('link');
5044                    link.id = fontId;
5045                    link.rel = 'stylesheet';
5046                    // No weight axis: Google Fonts CSS2 returns HTTP 400 when a family
5047                    // has no such weight, and 23 of the 36 display faces here ship
5048                    // only one. Asking for the family serves whatever it has, and the
5049                    // heading's own font-weight still applies.
5050                    link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, '+')}&display=swap`;
5051                    document.head.appendChild(link);
5052                }
5053
5054                title.style.fontFamily = `'${fontName}', sans-serif`;
5055                setCookie('hero_font', fontName, 365);
5056
5057                const select = document.getElementById('hero-font-select');
5058                if (select) select.value = fontName;
5059            };
5060
5061            document.addEventListener('keydown', (e) => {
5062                // A rolling buffer of the last N keys. Tracking an index per code
5063                // instead needs a KMP-style backoff to survive a partial match
5064                // (three ArrowUps must fall back to step 2, not step 0 or 1).
5065                recentKeys.push(e.key);
5066                if (recentKeys.length > konamiCode.length) recentKeys.shift();
5067                const entered = (code) => recentKeys.length === code.length
5068                    && code.every((k, i) => k === recentKeys[i]);
5069
5070                // Forward Konami
5071                if (entered(konamiCode)) {
5072                    const wrapper = document.querySelector('.scene-selector-wrapper');
5073                    if (wrapper) {
5074                        wrapper.style.display = 'flex';
5075                        wrapper.style.opacity = '1';
5076                        wrapper.style.pointerEvents = 'auto';
5077                        populateDropdown();
5078
5079                        // Retro Level-Up Sound Effect
5080                        const ctx = new (window.AudioContext || window.webkitAudioContext)();
5081                        const playTone = (freq, start, duration) => {
5082                            const osc = ctx.createOscillator();
5083                            const gain = ctx.createGain();
5084                            osc.type = 'square';
5085                            osc.frequency.setValueAtTime(freq, start);
5086                            gain.gain.setValueAtTime(0.1, start);
5087                            gain.gain.exponentialRampToValueAtTime(0.01, start + duration);
5088                            osc.connect(gain);
5089                            gain.connect(ctx.destination);
5090                            osc.start(start);
5091                            osc.stop(start + duration);
5092                        };
5093                        playTone(523.25, ctx.currentTime, 0.1); // C5
5094                        setTimeout(() => playTone(659.25, ctx.currentTime, 0.1), 100); // E5
5095                        setTimeout(() => playTone(783.99, ctx.currentTime, 0.1), 200); // G5
5096                        setTimeout(() => playTone(1046.50, ctx.currentTime, 0.2), 300); // C6
5097                    }
5098                }
5099
5100                // Reverse Konami
5101                if (entered(reverseKonami)) {
5102                    const wrapper = document.getElementById('hero-font-wrapper');
5103                    if (wrapper) {
5104                        wrapper.style.display = 'flex';
5105                        wrapper.style.opacity = '1';
5106                        wrapper.style.pointerEvents = 'auto';
5107                        populateFontDropdown();
5108
5109                        // Reverse Level-Up Sound Effect (Inverted frequencies)
5110                        const ctx = new (window.AudioContext || window.webkitAudioContext)();
5111                        const playTone = (freq, start, duration) => {
5112                            const osc = ctx.createOscillator();
5113                            const gain = ctx.createGain();
5114                            osc.type = 'square';
5115                            osc.frequency.setValueAtTime(freq, start);
5116                            gain.gain.setValueAtTime(0.1, start);
5117                            gain.gain.exponentialRampToValueAtTime(0.01, start + duration);
5118                            osc.connect(gain);
5119                            gain.connect(ctx.destination);
5120                            osc.start(start);
5121                            osc.stop(start + duration);
5122                        };
5123
5124                        // Frequencies: C6, G5, E5, C5 (Reversed order)
5125                        playTone(1046.50, ctx.currentTime, 0.1);
5126                        setTimeout(() => playTone(783.99, ctx.currentTime, 0.1), 100);
5127                        setTimeout(() => playTone(659.25, ctx.currentTime, 0.1), 200);
5128                        setTimeout(() => playTone(523.25, ctx.currentTime, 0.2), 300);
5129                    }
5130                }
5131            });
5132
5133            // Apply saved font on load (default to Rajdhani)
5134            const savedFont = getCookie('hero_font') || 'Rajdhani';
5135            populateFontDropdown();
5136            setTimeout(() => window.updateHeroFont(savedFont), 100);
5137
5138            const onMouseMove = (e) => { mouseX = (e.clientX / window.innerWidth - 0.5) * 10; mouseY = -(e.clientY / window.innerHeight - 0.5) * 10; };
5139            window.addEventListener('mousemove', onMouseMove);
5140            window.addEventListener('resize', () => {
5141                camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
5142                renderer.setSize(window.innerWidth, window.innerHeight);
5143            });
5144
5145            function animate() {
5146                frameId = requestAnimationFrame(animate);
5147                const time = performance.now() * 0.001;
5148                if (currentConfig && currentConfig.update) {
5149                    currentConfig.update(currentData, time, { x: mouseX, y: mouseY });
5150                }
5151                camera.position.x += (mouseX * 0.1 - camera.position.x) * 0.05;
5152                camera.position.y += (mouseY * 0.1 - camera.position.y) * 0.05;
5153                camera.lookAt(0, 0, 0);
5154                renderer.render(scene, camera);
5155            }
5156
5157            window.setThemeCallback = (dark) => {
5158                const color = 0xffffff;
5159                if (currentData) {
5160                    if (currentData.mat) currentData.mat.color.setHex(color);
5161                    if (currentData.wire) currentData.wire.color.setHex(color);
5162                    if (currentData.lineMat) currentData.lineMat.color.setHex(color);
5163                    if (currentData.g1) currentData.g1.children.forEac
5163h(c => c.material.color.setHex(color));
5164                    if (currentData.g2) currentData.g2.children.forEach(c => c.material.color.setHex(color));
5165                }
5166            };
5167
5168            // Pause rendering when hero is scrolled off-screen
5169            new IntersectionObserver((entries) => {
5170                if (entries[0].isIntersecting) {
5171                    if (currentConfig && !frameId) animate();
5172                } else {
5173                    if (frameId) { cancelAnimationFrame(frameId); frameId = null; }
5174                }
5175            }, { threshold: 0 }).observe(canvas);
5176
5177            const initial = getCookie('hero_scene_v2') || 'Collage Wallpaper';
5178            window.switchHeroScene(initial);
5179        }
5180
5181        // ── Infinite Scroll Observer ──────────────────────────────
5182        let scrollAnchor, infiniteLoader;
5183        const observer = new IntersectionObserver((entries) => {
5184            if (entries[0].isIntersecting && !loading && hasMore) {
5185                fetchPage();
5186            }
5187        }, {
5188            rootMargin: '400px',
5189            threshold: 0.1
5190        });
5191
5192        let gamePage = 1;
5193        let gamesLoading = false;
5194        let hasMoreGames = true;
5195        window.gameMapping = null;
5196
5197        let fetchGamesPromise = null;
5198
5199        async function fetchGames(reset = false) {
5200            if (!reset && gamesLoading) return;
5201            // Serialize calls using a promise chain to completely prevent race conditions
5202            while (fetchGamesPromise) {
5203                await fetchGamesPromise;
5204            }
5205
5206            let resolveLock;
5207            fetchGamesPromise = new Promise(resolve => { resolveLock = resolve; });
5208
5209            try {
5210                await _doFetchGames(reset);
5211            } finally {
5212                fetchGamesPromise = null;
5213                resolveLock();
5214            }
5215        }
5216
5217        async function _doFetchGames(reset = false) {
5218            if (gamesLoading || (!hasMoreGames && !reset)) return;
5219            gamesLoading = true;
5220
5221            const nextBtn = document.querySelector('.scroll-btn.right');
5222            if (nextBtn && !reset) nextBtn.classList.add('loading-glow');
5223
5224            if (reset) {
5225                gamePage = 1;
5226                hasMoreGames = true;
5227                const gameContainer = document.getElementById("game-cards");
5228                if (gameContainer) {
5229                    const gameSkeletons = Array(11).fill(0).map(() => `
5230                    <div class="game-card skeleton-game">
5231                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
5232                        <div class="game-overlay">
5233                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
5234                        </div>
5235                    </div>
5236                `).join("");
5237                    gameContainer.innerHTML = gameSkeletons;
5238                }
5239            }
5240
5241            try {
5242                const gamesRes = await fetch(`/api/games?page=${gamePage}&limit=10`);
5243                const games = await gamesRes.json();
5244
5245                if (games.length < 10) hasMoreGames = false;
5246                gamePage++;
5247
5248                const filter = document.getElementById("game-filter");
5249                const container = document.getElementById("game-cards");
5250                if (!filter || !container) return;
5251
5252                // Load mapping once
5253                if (!window.gameMapping) {
5254                    try {
5255                        const mappingRes = await fetch("/static/games/mapping.json");
5256                        if (mappingRes.ok) window.gameMapping = await mappingRes.json();
5257                    } catch (e) { console.error("Mapping fetch error:", e); }
5258                }
5259                const mapping = window.gameMapping || {};
5260
5261                const isInitialReset = reset;
5262                // Initial population (reset)
5263                if (reset) {
5264                    const url = new URL(window.location);
5265                    const activeGame = url.searchParams.get("game") || "";
5266
5267                    // Reset dropdown options to just All Games, plus activeGame if applicable
5268                    filter.innerHTML = '<option value="">All Games</option>';
5269                    if (activeGame && activeGame.trim().toLowerCase() !== "all games") {
5270                        const opt = document.createElement("option");
5271                        opt.value = activeGame.trim();
5272                        opt.textContent = activeGame.trim();
5273                        filter.appendChild(opt);
5274                    }
5275
5276                    // Build a 2x2 collage of the top trending games' posters.
5277                    // Filter to games that have a mapped poster, take the first 4.
5278                    const collageGames = games
5279                        .filter(g => g.name && g.name.trim().toLowerCase() !== "all games")
5280                        .map(g => ({ name: g.name.trim(), img: (mapping[g.name.trim().toLowerCase()] || mapping[g.name.trim()] || "") }))
5281                        .filter(g => g.img)
5282                        .slice(0, 4);
5283
5284                    let allContent;
5285                    if (collageGames.length === 4) {
5286                        const cells = collageGames.map(g =>
5287                            `<div><img src="${g.img}
5287" alt="${g.name.replace(/"/g, '&quot;')}" loading="lazy" onload="this.classList.add('loaded')"></div>`
5288                        ).join("");
5289                        allContent = `<div class="all-card-grid">${cells}</div>`;
5290                    } else {
5291                        allContent = `<i class="fa-solid fa-border-all fa-2x" style="position: relative; z-index: 2; color: var(--color-primary);"></i>`;
5292                    }
5293
5294                    container.innerHTML = `<div class="game-card all-card ${activeGame === '' ? 'active' : ''}" data-game="" onclick="setGame('')">
5295                    ${allContent}
5296                    <div class="game-overlay">
5297                        <div class="game-title">All Games</div>
5298                    </div>
5299                </div>`;
5300                }
5301                reset = false;
5302
5303                // Append Games
5304                games.forEach(g => {
5305                    if (!g.name) return;
5306                    const cleanName = g.name.trim();
5307                    if (!cleanName || cleanName.toLowerCase() === "all games") return;
5308
5309                    // Add to dropdown if not present
5310                    const existingOpt = [...filter.options].find(opt => opt.value.trim().toLowerCase() === cleanName.toLowerCase());
5311                    if (!existingOpt) {
5312                        const opt = document.createElement("option");
5313                        opt.value = cleanName;
5314                        opt.textContent = `${cleanName} (${g.count || 0})`;
5315                        filter.appendChild(opt);
5316                    } else if (!existingOpt.textContent.includes('(')) {
5317                        existingOpt.textContent = `${cleanName} (${g.count || 0})`;
5318                    }
5319
5320                    // Add card if not already in container
5321                    if (![...container.querySelectorAll('.game-card')].some(c => (c.getAttribute('data-game') || "").trim().toLowerCase() === cleanName.toLowerCase())) {
5322                        const url = new URL(window.location);
5323                        const activeGame = url.searchParams.get("game") || "";
5324                        const isActive = cleanName.toLowerCase() === activeGame.trim().toLowerCase();
5325                        const imgPath = mapping ? (mapping[cleanName.toLowerCase()] || mapping[cleanName] || "") : "";
5326
5327                        const placeholderHtml = `
5328                        <div class="game-placeholder">
5329                            <i class="fa-solid fa-gamepad fa-2x"></i>
5330                        </div>
5331                    `;
5332                        let imgHtml = placeholderHtml;
5333                        if (imgPath) {
5334                            imgHtml += `<img src="${imgPath}" alt="${cleanName.replace(/"/g, '&quot;')}" loading="lazy" onload="this.classList.add('loaded')">`;
5335                        }
5336
5337                        container.insertAdjacentHTML("beforeend", `
5338                        <div class="game-card ${isActive ? 'active' : ''}" data-game="${cleanName.replace(/"/g, '&quot;')}" onclick="setGame('${cleanName.replace(/'/g, "\\'")}')">
5339                            ${imgHtml}
5340                            <div class="game-overlay">
5341                                <div class="game-title">${cleanName.replace(/"/g, '&quot;')}</div>
5342                            </div>
5343                        </div>
5344                    `);
5345                    }
5346                });
5347
5348                if (filter.customDropdown) {
5349                    filter.customDropdown.renderOptions();
5350                    filter.customDropdown.updateLabel();
5351                    setupGameDropdownInfiniteScroll();
5352                }
5353
5354                if (window.AOS) AOS.refresh();
5355
5356                // Scroll active game into view only on first load
5357                if (isInitialReset) {
5358                    setTimeout(() => {
5359                        const activeCard = container.querySelector('.game-card.active');
5360                        if (activeCard) {
5361                            scrollCarouselToCard(container, activeCard);
5362                        }
5363                    }, 100);
5364                }
5365            } catch (e) {
5366                console.error("fetchGames error:", e);
5367            } finally {
5368                gamesLoading = false;
5369                const nextBtn = document.querySelector('.scroll-btn.right');
5370                if (nextBtn) nextBtn.classList.remove('loading-glow');
5371            }
5372        }
5373
5374        async function searchCategories() {
5375            const q = getSearch();
5376            if (!q || q.length < 2) return;
5377
5378            try {
5379                const res = await fetch(`/api/search_categories?q=${encodeURIComponent(q)}`);
5380                const games = await res.json();
5381                const filter = document.getElementById("game-filter");
5382                if (!filter) return;
5383
5384                games.forEach(g => {
5385                    if (!g.name) return;
5386                    const cleanName = g.name.trim();
5387                    if (!cleanName || cleanName.toLowerCase() === "all games") return;
5388
5389                    const existingOpt = [...filter.options].find(opt => opt.value.trim().toLowerCase() === cleanName.toLowerCase());
5390                    if (!existingOpt) {
5391                        const opt = document.createElement("option");
5392                        opt.value = cleanName;
5393                        opt.textContent = `${cleanName} (${g.count || 0})`;
5394                        filter.appendChild(opt);
5395                    } else if (!existingOpt.textContent.includes('(')) {
5396                        existingOpt.textContent = `${cleanName} (${g.count || 0})`;
5397                    }
5398                });
5399                if (filter.customDropdown) {
5400                    filter.customDropdown.renderOptions();
5401                    filter.customDropdown.updateLabel();
5402                }
5403            } catch (e) {
5404                console.error("searchCategories error:", e);
5405            }
5406        }
5407
5408        document.getElementById("search").addEventListener("input", () => {
5409            clearTimeout(debounceTimer);
5410            debounceTimer = setTimeout(() => {
5411                reload();
5412                searchCategories();
5413            }, 500);
5414        });
5415
5416        const gameContainer = document.getElementById("game-cards");
5417        if (gameContainer) {
5418            gameContainer.addEventListener("scroll", () => {
5419                const { scrollLeft, scrollWidth, clientWidth } = gameContainer;
5420                if (scrollLeft + clientWidth >= scrollWidth - 200) {
5421                    fetchGames();
5422                }
5423            });
5424        }
5425
5426        // Scrolling the game filter dropdown's option list near the bottom fetches
5427        // the next page of games too — same lazy loading as the horizontal card
5428        // strip. Bound once (via the dataset flag) as soon as the custom dropdown
5429        // exists; the menu element itself survives renderOptions() re-renders.
5430        function setupGameDropdownInfiniteScroll() {
5431            const filter = document.getElementById("game-filter");
5432            const menu = filter && filter.customDropdown && filter.customDropdown.menu;
5433            if (!menu || menu.dataset.infiniteScrollBound) return;
5434            menu.dataset.infiniteScrollBound = "true";
5435            menu.addEventListener("scroll", () => {
5436                const { scrollTop, scrollHeight, clientHeight } = menu;
5437                if (scrollTop + clientHeight >= scrollHeight - 60) {
5438                    fetchGames();
5439                }
5440            });
5441        }
5442
5443        window.scrollGames = (direction) => {
5444            const container = document.getElementById("game-cards");
5445            if (!container) return;
5446            const scrollAmount = container.clientWidth * 0.8;
5447            container.scrollBy({
5448                left: direction * scrollAmount,
5449                behavior: 'smooth'
5450            });
5451        };
5452
5453        document.addEventListener('DOMContentLoaded', async () => {
5454            initHeroScene();
5455            await fetchGames(true);
5456
5457            scrollAnchor = document.getElementById('scroll-anchor');
5458            infiniteLoader = document.getElementById('infinite-loader');
5459
5460            await restoreFromURL();
5461
5462            // If restoreFromURL didn't call setGame (which calls reload/fetchPage), call it now
5463            if (!getGame()) {
5464                fetchPage(true);
5465            }
5466
5467            if (scrollAnchor) observer.observe(scrollAnchor);
5468
5469            if (window.setThemeCallback) {
5470                const isDark = document.body.classList.contains('dark-theme');
5471                setThemeCallback(isDark);
5472            }
5473        });
5474
5475        /* ── Tip Rotation Logic ── */
5476        const tips = [
5477            { text: "🎮 <b>Valorant Auto-pilot</b>: Pro members enjoy 100% automated Valorant highlight clipping.", link: "/settings#valorant" },
5478            { text: "<i class='fa-brands fa-discord'></i> <b>Send Clips To Discord</b>: Pro Members can send clips directly to their own discord servers.", link: "/settings#discord" },
5479            { text: "<i class='fa-regular fa-message'></i> <b>Customize The message</b>: Pro Members can customize what the message in chat will look like.", link: "/settings#general" },
5480            { text: "<i class='fa-solid fa-arrows-rotate'></i> <b>Auto Realign</b>: Fear that your clips might be mis-aligned ? worry no more. Turn on Auto Realignment in settings.", link: "/settings#general" },
5481            { text: "<i class='fa-solid fa-message'></i> <b>Preferred Title</b>
5481: If you stream in both formats (snips and normal stream) use this feature.", link: "/settings#general" },
5482            { text: "<i class='fa-solid fa-eye'></i> <b>Visible</b>: Don't want your channel to be show on home page? you can turn it off.", link: "/settings#general" },
5483            { text: "<i class='fa-solid fa-clock-rotate-left'></i> <b>Clip Start Offset</b>: Start every clip a few seconds early so you never miss the moment before the highlight.", link: "/settings#general" },
5484            { text: "<i class='fa-solid fa-filter'></i> <b>Stream Title Filter</b>: Streaming vertical and horizontal at the same time? Use this to make each channel only clip the right one.", link: "/settings#general" },
5485            { text: "<i class='fa-brands fa-youtube'></i> <b>Auto-Comment on VOD</b>: When your stream ends, StreamSnip can auto-post a comment on your VOD with links to every clip.", link: "/settings#general" },
5486            { text: "<i class='fa-solid fa-users-gear'></i> <b>Management Access</b>: Grant your mods or editors access to manage your clips without sharing your account.", link: "/settings#account" },
5487            { text: "<i class='fa-solid fa-tag'></i> <b>Require Clip Description</b>: Skip clips that don't come with a description — keeps your library clean.", link: "/settings#general" },
5488            { text: "<i class='fa-solid fa-wand-magic-sparkles'></i> <b>Smart Clip Timing</b>: Pro — automatically fine-tunes exactly when your clips start — no more early or late cuts.", link: "/settings#general" },
5489            { text: "<i class='fa-solid fa-table-cells'></i> <b>Live Grid</b>: Watching a group play together? Watch all the streamers at the same time.", link: "/grid" },
5490
5491        ];
5492
5493        function shuffleArray(array) {
5494            for (let i = array.length - 1; i > 0; i--) {
5495                const j = Math.floor(Math.random() * (i + 1));
5496                [array[i], array[j]] = [array[j], array[i]];
5497            }
5498        }
5499
5500        shuffleArray(tips);
5501
5502        let currentTipIndex = 0;
5503        let isTipPaused = false;
5504        const tipTextEl = document.getElementById("tip-text");
5505        const tipBannerEl = document.getElementById("tips-banner");
5506
5507        if (tipBannerEl) {
5508            tipBannerEl.addEventListener('mouseenter', () => isTipPaused = true);
5509            tipBannerEl.addEventListener('mouseleave', () => isTipPaused = false);
5510        }
5511
5512        function rotateTip() {
5513            if (!tipTextEl || isTipPaused) return;
5514
5515            tipTextEl.classList.add("tip-fade-transition");
5516
5517            setTimeout(() => {
5518                currentTipIndex = (currentTipIndex + 1) % tips.length;
5519                tipTextEl.innerHTML = tips[currentTipIndex].text;
5520                tipTextEl.classList.remove("tip-fade-transition");
5521            }, 400);
5522        }
5523
5524        function handleTipClick() {
5525            const link = tips[currentTipIndex].link;
5526            if (link) {
5527                if (link.startsWith('http')) {
5528                    window.open(link, '_blank');
5529                } else {
5530                    window.location.href = link;
5531                }
5532            }
5533        }
5534
5535        // Initialize rotation and set initial shuffled tip
5536        if (tipTextEl) {
5537            rotateTip();
5538            setInterval(rotateTip, 8000);
5539        }
5540
5541        /* ── Greeting Name: colour from profile picture ── */
5542        (function () {
5543            const nameEl = document.getElementById('hero-greeting-name');
5544            if (!nameEl) return;
5545            const pfp = nameEl.dataset.pfp;
5546            if (!pfp) return;
5547
5548            const img = new Image();
5549            img.crossOrigin = 'anonymous'; // must be set before src
5550            img.onload = function () {
5551                try {
5552                    const canvas = document.createElement('canvas');
5553                    canvas.width = 32; canvas.height = 32;
5554                    const ctx = canvas.getContext('2d');
5555                    ctx.drawImage(img, 0, 0, 32, 32);
5556                    const { data } = ctx.getImageData(0, 0, 32, 32);
5557
5558                    let bestR = 96, bestG = 165, bestB = 250, bestScore = -1;
5559
5560                    for (let i = 0; i < data.length; i += 4) {
5561                        if (data[i + 3] < 128) continue;
5562                        const r = data[i] / 255, g = data[i + 1] / 255, b = data[i + 2] / 255;
5563                        const max = Math.max(r, g, b), min = Math.min(r, g, b);
5564                        const l = (max + min) / 2;
5565                        const s = max === min ? 0 : (max - min) / (l < 0.5 ? max + min : 2 - max - min);
5566                        if (l < 0.12 || l > 0.92 || s < 0.1) continue;
5567                        const score = s * (1 - Math.abs(l - 0.5) * 1.8);
5568                        if (score > bestScore) {
5569                            bestScore = score;
5570                            bestR = data[i]; bestG = data[i + 1]; bestB = data[i + 2];
5571                        }
5572                    }
5573
5574                    // If no vibrant pixel found (e.g. greyscale avatar), keep default
5575                    if (bestScore < 0.05) return;
5576
5577                    // Boost saturation: push colour further from grey
5578                    const mid = (bestR + bestG + bestB) / 3;
5579                    const boost = 1.6;
5580                    const clamp = (v) => Math.min(255, Math.max(0, Math.round(v)));
5581                    const r2 = clamp(mid + (bestR - mid) * boost);
5582                    const g2 = clamp(mid + (bestG - mid) * boost);
5583                    const b2 = clamp(mid + (bestB - mid) * boost);
5584
5585                    // Lighter variant for gradient midpoint
5586                    const lighten = (c) => clamp(c * 0.55 + 255 * 0.45);
5587                    const c1 = `rgb(${r2},${g2},${b2})`;
5588                    const c2 = `rgb(${lighten(r2)},${lighten(g2)},${lighten(b2)})`;
5589                    nameEl.style.backgroundImage = `linear-gradient(135deg, ${c1} 0%, ${c2} 50%, ${c1} 100%)`;
5590                } catch (_) { /* CORS blocked — keep default CSS gradient */ }
5591            };
5592            img.src = pfp;
5593        })();
5594
5595        /* ── Logged-In Time Greeting ── */
5596        (function () {
5597            const el = document.getElementById('hero-greeting-label');
5598            if (!el) return;
5599            const GREETINGS = {
5600                morning:   ["Good morning", "Morning, legend", "Another day to make moments", "The highlights don't clip themselves ☀️", "Ready to make today's highlight reel?", "You showed up. That's half the battle."],
5601                afternoon: ["Good afternoon", "Afternoon grind, let's go", "Making the most of the day?", "Clips don't make themselves", "Still going strong", "In the zone?"],
5602                evening:   ["Good evening", "Prime time is here", "The night's about to get good", "Your audience is ready when you are", "Best clips come from late streams", "Time to give your chat something to clip"],
5603                night:     ["Still up?", "The real ones stream at night", "Night owl energy, respect", "While the world sleeps, you create", "Legends don't have a bedtime", "You never sleep, do you? 👀"]
5604            };
5605            const h = new Date().getHours();
5606            const period = h >= 5 && h < 12 ? 'morning' : h >= 12 && h < 17 ? 'afternoon' : h >= 17 && h < 21 ? 'evening' : 'night';
5607            const variants = GREETINGS[period];
5608            el.textContent = variants[Math.floor(Math.random() * variants.length)];
5609        })();
5610    </script>
5610
5611    
5612  
5612<script>
5613  (function(){
5614    var s=document.createElement('script');
5615    s.async=true;
5616    s.src='https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js';
5617    s.onload=function(){
5618      if(!window.webVitals)return;
5619      function sv(m){
5620        try{navigator.sendBeacon('/api/vitals',JSON.stringify({n:m.name,v:m.value,p:location.pathname}));}catch(e){}
5621      }
5622      webVitals.onFCP(sv);webVitals.onLCP(sv);webVitals.onCLS(sv);webVitals.onINP(sv);webVitals.onTTFB(sv);
5623    };
5624    document.head.appendChild(s);
5625  })();
5626  </script>
5626
5627
5628  <!-- ========= TERMINAL COPY BUTTONS =========
5629       Adds a "Copy" button to every .terminal-container (command/code blocks on
5630       get-started, guides, yt-dlp builder…). Replaces the undiscoverable
5631       click-anywhere-to-copy with an obvious affordance + copied-state feedback.
5632       Terminals are always dark, so this light-on-glass button reads in both themes. -->
5633  <style>
5634    .terminal-container { position: relative; }
5635    .ss-term-copy {
5636      display: inline-flex;
5637      align-items: center;
5638      gap: 6px;
5639      padding: 4px 9px;
5640      font-size: 0.7rem;
5641      font-weight: 600;
5642      line-height: 1;
5643      font-family: inherit;
5644      color: #cbd5e1;
5645      background: rgba(255, 255, 255, 0.08);
5646      border: 1px solid rgba(255, 255, 255, 0.14);
5647      border-radius: var(--radius-sm);
5648      cursor: pointer;
5649      transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
5650    }
5651    .ss-term-copy:hover { background: rgba(255, 255, 255, 0.16); color: #fff; }
5652    .ss-term-copy:active { transform: scale(0.94); }
5653    .ss-term-copy.copied {
5654      color: #4ade80;
5655      border-color: rgba(74, 222, 128, 0.4);
5656      background: rgba(74, 222, 128, 0.14);
5657    }
5658    /* Floating variant for terminals with no title bar — reserve room so the
5659       button never overlaps the first line of the command. */
5660    .ss-term-copy--float { position: absolute; top: 8px; right: 8px; z-index: 3; }
5661    .terminal-container.has-floating-copy .terminal-body { padding-top: 2.5rem; }
5662  </style>
5663  
5663<script>
5664    (function () {
5665      function addCopyButtons() {
5666        document.querySelectorAll('.terminal-container').forEach(function (term) {
5667          if (term.querySelector('.ss-term-copy')) return;          // already enhanced
5668          var body = term.querySelector('.terminal-body');
5669          if (!body) return;
5670          var header = term.querySelector('.terminal-header');
5671
5672          var btn = document.createElement('button');
5673          btn.type = 'button';
5674          btn.className = 'ss-term-copy';
5675          btn.setAttribute('aria-label', 'Copy to clipboard');
5676          btn.innerHTML = '<i class="fa-solid fa-copy"></i><span>Copy</span>';
5677
5678          if (header) {
5679            // sit at the right of the title bar; only self-align when the header
5680            // has no existing right-aligned element (e.g. a "Nightbot" label).
5681            if (!header.querySelector('.ms-auto')) btn.style.marginLeft = 'auto';
5682            header.appendChild(btn);
5683          } else {
5684            btn.classList.add('ss-term-copy--float');
5685            term.classList.add('has-floating-copy');
5686            term.appendChild(btn);
5687          }
5688
5689          btn.addEventListener('click', function (e) {
5690            e.stopPropagation();
5691            var text = (body.innerText || '').trim();
5692            navigator.clipboard.writeText(text).then(function () {
5693              btn.classList.add('copied');
5694              btn.innerHTML = '<i class="fa-solid fa-check"></i><span>Copied</span>';
5695              setTimeout(function () {
5696                btn.classList.remove('copied');
5697                btn.innerHTML = '<i class="fa-solid fa-copy"></i><span>Copy</span>';
5698              }, 1800);
5699            }).catch(function () {});
5700          });
5701        });
5702      }
5703      document.addEventListener('DOMContentLoaded', addCopyButtons);
5704      if (document.readyState !== 'loading') addCopyButtons();
5705    })();
5706  </script>
5706
5707
5708  <!-- ========= ADMIN PALETTE DROPDOWN (wiring) =========
5709       Lets an admin preview accent palettes live from the navbar. The choice is
5710       stored in localStorage (client-side only — does NOT change the CSS file or
5711       affect other users) and re-applied pre-paint by the head script. -->
5712  <style>
5713    .palette-dot {
5714      display: inline-block;
5715      width: 12px;
5716      height: 12px;
5717      border-radius: 50%;
5718      margin-inline-end: 8px;
5719      vertical-align: middle;
5720      box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
5721    }
5722  </style>
5723  
5723<script>
5724    (function () {
5725      function apply(h, s, l, rgb) {
5726        var st = document.documentElement.style;
5727        if (h === null) {
5728          st.removeProperty('--accent-h'); st.removeProperty('--accent-s');
5729          st.removeProperty('--accent-l'); st.removeProperty('--color-primary-rgb');
5730          localStorage.removeItem('ss_palette');
5731          return;
5732        }
5733        st.setProperty('--accent-h', h); st.setProperty('--accent-s', s);
5734        st.setProperty('--accent-l', l); st.setProperty('--color-primary-rgb', rgb);
5735        localStorage.setItem('ss_palette', JSON.stringify({ h: h, s: s, l: l, rgb: rgb }));
5736      }
5737      function wire() {
5738        document.querySelectorAll('.palette-opt').forEach(function (opt) {
5739          if (opt._wired) return; opt._wired = true;
5740          opt.addEventListener('click', function (e) {
5741            e.preventDefault();
5742            if (opt.dataset.reset) apply(null);
5743            else apply(opt.dataset.h, opt.dataset.s, opt.dataset.l, opt.dataset.rgb);
5744          });
5745        });
5746      }
5747      document.addEventListener('DOMContentLoaded', wire);
5748      if (document.readyState !== 'loading') wire();
5749    })();
5750  </script>
5750
5751</body>
5752
5753</html>

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.