PageSourceSearch

https://streamsnip.com/

html streamsnip.com collected 2026-10-02 01:44:43 UTC 263,347 bytes, 5,944 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    // Palette choice — 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 — free, with no limits." />
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 — free, with no limits." />
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 — free, with no limits." />
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: 0;
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    /* Tuck the tips banner into the banner row's 5rem bottom margin. Only when that
1604       row is rendered: it's hidden for channels that already have clips, and the
1605       pull-up would then drag the tips banner into the hero. */
1606    .hero-banners-container + .tips-banner-wrapper {
1607        margin-top: -2.5rem;
1608    }
1609
1610    .tips-banner {
1611        background: var(--glass-bg);
1612        backdrop-filter: blur(16px);
1613        -webkit-backdrop-filter: blur(16px);
1614        border: 1px solid var(--glass-border);
1615        border-radius: var(--radius-full);
1616        padding: 0.6rem 1.75rem;
1617        display: flex;
1618        align-items: center;
1619        gap: 0.85rem;
1620        box-shadow: var(--glass-shadow);
1621        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;
1622        cursor: pointer;
1623        max-width: 90%;
1624        user-select: none;
1625    }
1626
1627    .tips-banner:hover {
1628        transform: translateY(-4px) scale(1.02);
1629        border-color: var(--color-primary);
1630        box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.3);
1631        background: var(--glass-bg-subtle);
1632    }
1633
1634    .tip-icon {
1635        color: var(--color-primary);
1636        font-size: 1.1rem;
1637    }
1638
1639    .tip-text {
1640        font-size: 0.95rem;
1641        font-weight: 600;
1642        color: var(--color-text);
1643        white-space: nowrap;
1644        overflow: hidden;
1645        text-overflow: ellipsis;
1646        transition: all 0.4s ease;
1647    }
1648
1649    .tip-fade-transition {
1650        opacity: 0;
1651        transform: translateY(10px);
1652    }
1653
1654    @media (max-width: 768px) {
1655        .tip-text {
1656            font-size: 0.8rem;
1657        }
1658
1659        .tips-banner-wrapper,
1660        .hero-banners-container + .tips-banner-wrapper {
1661            margin-top: 0;
1662            margin-bottom: 2rem;
1663        }
1664
1665        .tips-banner {
1666            padding: 0.5rem 1.25rem;
1667        }
1668    }
1669
1670    /* ── Bottom Hero Loader ── */
1671    .hero-bottom-loader {
1672        position: absolute;
1673        bottom: 0;
1674        left: 0;
1675        width: 100%;
1676        height: 5px;
1677        background: linear-gradient(90deg,
1678                transparent 0%,
1679                var(--color-primary) 50%,
1680                transparent 100%);
1681        background-size: 200% 100%;
1682        animation: loading-slide 2s infinite linear;
1683        z-index: 20;
1684        opacity: 0;
1685        transition: opacity 0.5s ease;
1686        pointer-events: none;
1687    }
1688
1689    .hero-bottom-loader.active {
1690        opacity: 1;
1691    }
1692
1693    @keyframes loading-slide {
1694        from {
1695            background-position: 200% 0;
1696        }
1697
1698        to {
1699            background-position: -200% 0;
1700        }
1701    }
1702
1703    /* Both hero selectors are pinned to the bottom edge of .hero-wrapper, which
1704       clips with overflow:hidden. The shared .custom-dropdown-menu opens downward
1705       (top: calc(100% + 6px)), so the menu is cut off — severely on the shorter
1706       logged-in hero (min-height:45vh), where all but the first option are
1707       unclickable. Open these two upward instead. */
1708    .scene-selector-wrapper .custom-dropdown-menu,
1709    .font-selector-wrapper .custom-dropdown-menu {
1710        top: auto;
1711        bottom: calc(100% + 6px);
1712        /* The chip sits 2rem above the hero floor, so the space above it is
1713           roughly 45vh - 76px on the logged-in hero. Cap the menu to fit there;
1714           the 280px default only applies once the viewport is tall enough. */
1715        max-height: min(280px, 30vh);
1716    }
1717
1718    /* ── Hero Font Selector (Mirrors Scene Selector) ── */
1719    .font-selector-wrapper {
1720        position: absolute;
1721        bottom: 2rem;
1722        inset-inline-start: 2rem;
1723        z-index: 10;
1724        display: flex;
1725        align-items: center;
1726        gap: 0.75rem;
1727        background: var(--glass-bg);
1728        backdrop-filter: blur(16px);
1729        -webkit-backdrop-filter: blur(16px);
1730        border: 1px solid var(--glass-border);
1731        padding: 0.5rem 1rem;
1732        border-radius: var(--radius-full);
1733        box-shadow: var(--glass-shadow);
1734        transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
1735        opacity: 0;
1736        display: none;
1737        pointer-events: none;
1738    }
1739
1740    .font-selector-wrapper:hover {
1741        opacity: 1;
1742        transform: translateY(-2px);
1743    }
1744
1745    .font-selector-wrapper i {
1746        color: var(--color-primary);
1747        font-size: 0.9rem;
1748    }
1749
1750    #hero-font-select {
1751        background: transparent;
1752        border: none;
1753        color: var(--color-text);
1754        font-size: 0.85rem;
1755        font-weight: 600;
1756        cursor: pointer;
1757        outline: none;
1758        padding-inline-end: 1.5rem;
1759        appearance: none;
1760        -webkit-appearance: none;
1761        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");
1762        background-repeat: no-repeat;
1763        background-position: right center;
1764        background-size: 1rem;
1765    }
1766
1767    @media (max-width: 768px) {
1768        .font-selector-wrapper {
1769            bottom: 1rem;
1770            inset-inline-start: 1rem;
1771            padding: 0.4rem 0.8rem;
1772        }
1773    }
1774
1775    /* ── Game Cards ── */
1776    .game-cards-wrapper {
1777        position: relative;
1778        margin-bottom: 1.5rem;
1779    }
1780
1781    .game-cards-container {
1782        display: flex;
1783        gap: 1.25rem;
1784        margin-top: 1rem;
1785        overflow-x: auto;
1786        overflow-y: visible;
1787        /* Allow growth */
1788        padding: 1.5rem 1rem 1.5rem 1rem;
1789        /* Increased top padding to prevent clipping on hover */
1790        scrollbar-width: thin;
1791        scrollbar-color: var(--color-primary) transparent;
1792        scroll-behavior: smooth;
1793        -webkit-overflow-scrolling: touch;
1794        scroll-snap-type: x proximity;
1795    }
1796
1797    .game-cards-container::after {
1798        content: '';
1799        flex: 0 0 1rem;
1800        /* Spacer at the end */
1801    }
1802
1803    .game-cards-container::-webkit-scrollbar {
1804        height: 4px;
1805    }
1806
1807    .game-cards-container::-webkit-scrollbar-thumb {
1808        background: var(--color-primary);
1809        border-radius: 10px;
1810    }
1811
1812    .game-card {
1813        flex: 0 0 140px;
1814        aspect-ratio: 3/4;
1815        background: var(--glass-bg);
1816        border: 2px solid var(--glass-border);
1817        border-radius: var(--radius-md);
1818        overflow: hidden;
1819        position: relative;
1820        cursor: pointer;
1821        transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1822        will-change: transform, border-color, box-shadow;
1823        transform: translateZ(0);
1824        display: flex;
1825        flex-direction: column;
1826        justify-content: flex-end;
1827        scroll-snap-align: start;
1828    }
1829
1830    .game-card img {
1831        position: absolute;
1832        top: 0;
1833        left: 0;
1834        width: 100%;
1835        height: 100%;
1836        object-fit: cover;
1837        transition: transform 0.5s ease, opacity 0.5s ease;
1838        will-change: transform, opacity;
1839        z-index: 1;
1840        opacity: 0;
1841    }
1842
1843    .game-card img.loaded {
1844        opacity: 1;
1845    }
1846
1847    .game-placeholder {
1848        position: absolute;
1849        top: 0;
1850        left: 0;
1851        width: 100%;
1852        height: 100%;
1853        display: flex;
1854        align-items: center;
1855        justify-content: center;
1856        background: var(--glass-bg-subtle);
1857        color: var(--color-text-muted);
1858        z-index: 0;
1859        opacity: 0.5;
1860    }
1861
1862    .game-card .game-overlay {
1863        position: relative;
1864        z-index: 2;
1865        background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
1866        padding: 0.75rem 0.5rem;
1867        width: 100%;
1868    }
1869
1870    .game-card .game-title {
1871        color: white;
1872        font-size: 0.8rem;
1873        font-weight: 700;
1874        text-align: center;
1875        white-space: nowrap;
1876        overflow: hidden;
1877        text-overflow: ellipsis;
1878        text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
1879    }
1880
1881    .game-card:hover {
1882        transform: translateY(-8px) scale(1.05);
1883        border-color: var(--color-primary);
1884        box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
1885    }
1886
1887    .game-card:hover img {
1888        transform: scale(1.1);
1889    }
1890
1891    .game-card.active {
1892        border-color: var(--color-primary);
1893        box-shadow: 0 0 0 3px var(--color-primary);
1894    }
1895
1896    .game-card.active .game-overlay {
1897        background: var(--color-primary);
1898    }
1899
1900    .game-card.all-card {
1901        justify-content: center;
1902        align-items: center;
1903        background: var(--glass-bg-subtle);
1904    }
1905
1906    .game-card.all-card i {
1907        font-size: 2rem;
1908        color: var(--color-primary);
1909        margin-bottom: 0.5rem;
1910    }
1911
1912    .game-card.all-card .all-card-grid {
1913        position: absolute;
1914        inset: 0;
1915        display: grid;
1916        grid-template-columns: 1fr 1fr;
1917        grid-template-rows: 1fr 1fr;
1918        z-index: 1;
1919        background: var(--glass-bg-subtle);
1920    }
1921
1922    .game-card.all-card .all-card-grid > div {
1923        position: relative;
1924        overflow: hidden;
1925        background: var(--glass-bg-subtle);
1926    }
1927
1928    .game-card.all-card .all-card-grid img {
1929        position: absolute;
1930        inset: 0;
1931        width: 100%;
1932        height: 100%;
1933        object-fit: cover;
1934        opacity: 0;
1935        transition: opacity 0.4s ease;
1936    }
1937
1938    .game-card.all-card .all-card-grid img.loaded {
1939        opacity: 1;
1940    }
1941
1942    .scroll-btn {
1943        position: absolute;
1944        top: 50%;
1945        transform: translateY(-50%);
1946        width: 40px;
1947        height: 40px;
1948        border-radius: 50%;
1949        background: var(--glass-bg);
1950        backdrop-filter: blur(12px);
1951        -webkit-backdrop-filter: blur(12px);
1952        border: 1px solid var(--glass-border);
1953        color: var(--color-text);
1954        display: flex;
1955        align-items: center;
1956        justify-content: center;
1957        cursor: pointer;
1958        z-index: 10;
1959        transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1960        opacity: 0;
1961        pointer-events: none;
1962        box-shadow: var(--glass-shadow);
1963    }
1964
1965    @keyframes pulse-glow {
1966        0% {
1967            box-shadow: 0 0 0 0 hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.6);
1968            border-color: var(--color-primary);
1969        }
1970
1971        70% {
1972            box-shadow: 0 0 0 10px hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0);
1973            border-color: var(--color-primary);
1974        }
1975
1976        100% {
1977            box-shadow: 0 0 0 0 hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0);
1978        }
1979    }
1980
1981    .scroll-btn.loading-glow {
1982        opacity: 1 !important;
1983        pointer-events: auto !important;
1984        animation: pulse-glow 1.5s infinite;
1985        color: var(--color-primary) !important;
1986        background: var(--glass-bg-hover) !important;
1987        border-color: var(--color-primary) !important;
1988    }
1989
1990    .game-cards-wrapper:hover .scroll-btn {
1991        opacity: 1;
1992        pointer-events: auto;
1993    }
1994
1995    .scroll-btn:hover {
1996        background: var(--color-primary);
1997        color: white;
1998        border-color: var(--color-primary);
1999        box-shadow: 0 6px 16px -4px hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.4);
2000        transform: translateY(-50%) scale(1.1);
2001    }
2002
2003    .scroll-btn.left {
2004        left: -20px;
2005    }
2006
2007    .scroll-btn.right {
2008        right: -20px;
2009    }
2010
2011    @media (max-width: 1200px) {
2012        .scroll-btn.left {
2013            left: 5px;
2014        }
2015
2016        .scroll-btn.right {
2017            right: 5px;
2018        }
2019    }
2020
2021    @media (max-width: 991px) {
2022        .scroll-btn {
2023            display: none !important;
2024        }
2025    }
2026
2027    /* Skeleton utilities (.skeleton / .skeleton-text) now live in style.css. */
2028
2029    /* ── Logged-In Greeting ── */
2030    /* No text-align here on purpose: the greeting inherits it from
2031       .hero-content, so it centres in the stacked layout and sits on the
2032       leading edge in the wide two-column hero. */
2033    .hero-greeting-wrapper {
2034        margin-block-start: 2.5rem;
2035        margin-block-end: 2rem;
2036    }
2037
2038    .hero-greeting-label {
2039        font-size: 0.9rem;
2040        font-weight: 700;
2041        letter-spacing: 3px;
2042        text-transform: uppercase;
2043        color: var(--color-primary);
2044        margin-bottom: 0.75rem;
2045        min-height: 1.4rem;
2046    }
2047
2048    .hero-greeting-name {
2049        font-family: 'Rajdhani', sans-serif;
2050        font-size: clamp(2.5rem, 8vw, 5rem);
2051        font-weight: 700;
2052        line-height: 1.1;
2053        text-transform: uppercase;
2054        letter-spacing: -0.02em;
2055        background-image: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 50%, var(--color-primary-hover) 100%);
2056        -webkit-background-clip: text !important;
2057        background-clip: text !important;
2058        -webkit-text-fill-color: transparent !important;
2059        filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.1));
2060    }
2061
2062
2063    @media (max-width: 768px) {
2064        .hero-greeting-name {
2065            font-size: clamp(2rem, 10vw, 3.5rem);
2066        }
2067        .hero-greeting-wrapper {
2068            margin-block-end: 1.5rem;
2069        }
2070    }
2071</style>
2072<link rel="stylesheet" href="/static/css/clip-demo.css?v=b0a465d1c2">
2073
2074<!-- ========= STRUCTURED DATA: WebApplication (homepage only) ========= -->
2075<script type="application/ld+json">
2076{
2077  "@context": "https://schema.org",
2078  "@type": "WebApplication",
2079  "@id": "https://streamsnip.com/#app",
2080  "name": "StreamSnip",
2081  "url": "https://streamsnip.com",
2082  "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.",
2083  "applicationCategory": "MultimediaApplication",
2084  "operatingSystem": "Web",
2085  "browserRequirements": "Requires JavaScript. Works in any modern browser.",
2086  "inLanguage": "en",
2087  "featureList": [
2088    "Instant YouTube livestream clipping via chat command",
2089    "Nightbot integration with !clip command",
2090    "Discord clip notifications",
2091    "Clip feed and public export pages",
2092    "Chrome extension for clip management",
2093    "Viewer-driven clipping without streamer intervention"
2094  ],
2095  "offers": [
2096    {
2097      "@type": "Offer",
2098      "name": "Free Plan",
2099      "price": "0",
2100      "priceCurrency": "USD",
2101      "description": "Every StreamSnip feature — unlimited clips, Discord integration, downloads, and more — free for everyone."
2102    }
2103  ],
2104  "publisher": {
2105    "@id": "https://streamsnip.com/#organization"
2106  },
2107  "screenshot": "https://streamsnip.com/static/logo.png"
2108}
2109</script>
2109
2110
2111<!-- Mirrors the visible "How to clip a YouTube livestream" section below. Keep the two in sync. -->
2112<script type="application/ld+json">
2113{
2114  "@context": "https://schema.org",
2115  "@type": "HowTo",
2116  "name": "How to clip a YouTube livestream",
2117  "description": "Add a !clip chat command to your YouTube livestream so viewers capture your highlights in real time.",
2118  "totalTime": "PT5M",
2119  "step": [
2120    {
2121      "@type": "HowToStep",
2122      "position": 1,
2123      "name": "Add the !clip command",
2124      "text": "Connect your channel and add the StreamSnip !clip command to your YouTube chat with Nightbot in a couple of minutes.",
2125      "url": "https://streamsnip.com/get-started"
2126    },
2127    {
2128      "@type": "HowToStep",
2129      "position": 2,
2130      "name": "Your viewers clip for you",
2131      "text": "Anyone in chat types !clip when something great happens. StreamSnip grabs the moment from your live VOD automatically.",
2132      "url": "https://streamsnip.com/clip-command-youtube"
2133    },
2134    {
2135      "@type": "HowToStep",
2136      "position": 3,
2137      "name": "Clips appear instantly",
2138      "text": "Every clip lands in your StreamSnip feed and your Discord, ready to download, share to Shorts, or browse later.",
2139      "url": "https://streamsnip.com/guides"
2140    }
2141  ]
2142}
2143</script>
2143
2144
2145</head>
2146
2147<body>
2148  
2148<script>
2149    // Mirror the early <html> dark-theme class onto <body> so existing `body.dark-theme` rules apply before first paint.
2150    if (document.documentElement.classList.contains('dark-theme')) document.body.classList.add('dark-theme');
2151  </script>
2151
2152
2153  <!-- ssIsTypingTarget / ssRegisterShortcuts. Not deferred: page scripts that bind
2154       shortcuts at parse time call into it. -->
2155  
2155<script src="/static/js/shortcuts.js?v=c98906de16"></script>
2155
2156  <!-- ssConfirm — themed replacement for window.confirm(). -->
2157  
2157<script src="/static/js/confirm.js?v=8a2fd43037" defer></script>
2157
2158
2159  <!-- ===== Reusable clapper loader (single source of truth) =====
2160       Server-rendered: <div data-clap-loader data-size="48" data-label="…"></div> (auto-hydrated).
2161       JS-injected:     window.clapLoaderHTML({size, label, inline}) / window.makeClapLoader(...). -->
2162  <template id="ss-clap-tpl">
2163    <svg class="ss-clap" viewBox="0 0 3334 3334" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
2164      <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"/>
2165      <path class="ss-clap-board" fill-rule="evenodd"
2166        transform="matrix(0.95517403, -0.29604402, 0.29604402, 0.95517403, -456.95786825, 588.0155987)"
2167        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
21670.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"/>
2168    </svg>
2169  </template>
2170  
2170<script>
2171    (function () {
2172      window.makeClapLoader = function (opts) {
2173        opts = opts || {};
2174        const tpl = document.getElementById('ss-clap-tpl');
2175        const box = document.createElement('div');
2176        box.className = 'clap-loader-box' + (opts.inline ? ' inline' : '');
2177        if (tpl && tpl.content.firstElementChild) {
2178          const svg = tpl.content.firstElementChild.cloneNode(true);
2179          const size = opts.size || 48;
2180          svg.style.width = size + 'px';
2181          svg.style.height = size + 'px';
2182          box.appendChild(svg);
2183        }
2184        if (opts.label) {
2185          const l = document.createElement('div');
2186          l.className = 'clap-loader-label';
2187          l.textContent = opts.label;
2188          box.appendChild(l);
2189        }
2190        return box;
2191      };
2192      window.clapLoaderHTML = function (opts) {
2193        const el = window.makeClapLoader(opts);
2194        return el ? el.outerHTML : '';
2195      };
2196      // Just the clapper SVG (no box/label) for replacing inline <i> spinners inside text.
2197      window.clapSvgHTML = function (size) {
2198        const tpl = document.getElementById('ss-clap-tpl');
2199        if (!tpl || !tpl.content.firstElementChild) return '';
2200        const svg = tpl.content.firstElementChild.cloneNode(true);
2201        const s = size || 20;
2202        svg.style.width = s + 'px'; svg.style.height = s + 'px';
2203        svg.style.display = 'inline-block'; svg.style.verticalAlign = 'middle'; svg.style.marginRight = '.45em';
2204        return svg.outerHTML;
2205      };
2206      function hydrate(root) {
2207        (root || document).querySelectorAll('[data-clap-loader]:not([data-clap-done])').forEach(function (el) {
2208          el.setAttribute('data-clap-done', '1');
2209          el.appendChild(window.makeClapLoader({
2210            size: parseInt(el.getAttribute('data-size'), 10) || 48,
2211            label: el.getAttribute('data-label') || '',
2212            inline: el.hasAttribute('data-inline')
2213          }));
2214        });
2215      }
2216      window.hydrateClapLoaders = hydrate;
2217      if (document.readyState === 'loading') {
2218        document.addEventListener('DOMContentLoaded', function () { hydrate(); });
2219      } else { hydrate(); }
2220    })();
2221  </script>
2221
2222
2223  <!-- ========= NAVBAR ========= -->
2224  <header class="fixed-top glass-nav">
2225    <div class="container">
2226      <nav class="navbar navbar-expand-lg navbar-dark" id="navbar_id">
2227        <a class="navbar-brand d-flex align-items-center" href="/" aria-label="StreamSnip home">
2228          <div class="me-2 site_icon" id="site_icon" style="width:36px;height:36px;flex-shrink:0;overflow:visible;cursor:pointer;">
2229            <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;" aria-hidden="true" focusable="false">
2230              <style>
2231                @keyframes _nbl_clap {
2232                  0%   { transform: rotate(-17deg); animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
2233                  18%  { transform: rotate(-57deg); animation-timing-function: linear; }
2234                  38%  { transform: rotate(-57deg); animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); }
2235                  51%  { transform: rotate(0deg);   animation-timing-function: cubic-bezier(0, 0, 0.3, 1); }
2236                  58%  { transform: rotate(-8deg);  animation-timing-function: cubic-bezier(0.25, 1, 0.5, 1); }
2237                  66%  { transform: rotate(0deg);   animation-timing-function: linear; }
2238                  80%  { transform: rotate(0deg);   animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
2239                  100% { transform: rotate(-17deg); }
2240                }
2241                #_nbl_stick {
2242                  transform-origin: 958px 614px;
2243                  transform: rotate(-17deg);
2244                }
2245                #_nbl_stick._nbl_play {
2246                  animation: _nbl_clap 2.6s linear 1 forwards;
2247                }
2248              </style>
2249              <!-- Punch the hinge hole through the icon so the stick can't be seen through it
2250                   as it claps. SVG mask (true transparency for the glass navbar); region is set
2251                   huge so the swinging stick is never clipped. Circle = the board hole in
2252                   user space (cx 640, cy 891, r 112 of the 3334 viewBox). -->
2253              <mask id="_nbl_holemask" maskUnits="userSpaceOnUse" x="-2000" y="-2000" width="8000" height="8000">
2254                <rect x="-2000" y="-2000" width="8000" height="8000" fill="#fff"/>
2255                <circle cx="640" cy="891" r="112" fill="#000"/>
2256              </mask>
2257              <g mask="url(#_nbl_holemask)">
2258              <path id="_nbl_stick" fill-rule="evenodd" paint-order="stroke fill"
2259                transform="matrix(0.999958, -0.009206, 0.009206, 0.999958, 0.019399, 0.012944)"
2260                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"/>
2261              <path id="_nbl_clapper" fill-rule="evenodd" paint-order="stroke fill"
2262                transform="matrix(0.95517403, -0.29604402, 0.29604402, 0.95517403, -456.95786825, 588.0155987)"
2263                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 
2263C 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
22630.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"/>
2264              </g>
2265            </svg>
2266          </div>
2267          <span class="fw-bold tracking-tight d-none d-sm-inline">StreamSnip</span>
2268        </a>
2269        <button class="navbar-toggler border-0 shadow-none d-lg-none d-flex align-items-center justify-content-center"
2270          type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
2271          aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"
2272          style="width: 44px; height: 44px; padding: 0;">
2273          <div class="hamburger-icon">
2274            <span></span>
2275            <span></span>
2276            <span></span>
2277          </div>
2278        </button>
2279
2280        <div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
2281          <ul class="navbar-nav align-items-lg-center gap-1">
2282            <li class="nav-item">
2283              <a class="nav-link px-3 highlight-link d-flex align-items-center" href="/snips" title="View Clips Feed">
2284                <i class="fa-solid fa-play me-2" aria-hidden="true"></i> Snips
2285              </a>
2286            </li>
2287
2288            <li class="nav-item">
2289              <a class="nav-link px-3 highlight-link d-flex align-items-center" href="/get-started"
2290                title="How to use StreamSnip">
2291                <i class="fa-solid fa-rocket me-2 d-lg-none" aria-hidden="true"></i> Get Started
2292              </a>
2293            </li>
2294
2295            <li class="nav-item dropdown">
2296              <a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="resourcesDropdown"
2297                role="button" data-bs-toggle="dropdown" aria-expanded="false" title="More Resources">
2298                <i class="fa-solid fa-folder-open me-2 d-lg-none" aria-hidden="true"></i> Resources
2299              </a>
2300              <ul class="dropdown-menu dropdown-menu-end glass" aria-labelledby="resourcesDropdown">
2301                <li><a class="dropdown-item" href="/settings#discord"><i class="fa-solid fa-link"
2302                      aria-hidden="true"></i> Connect
2303                    Discord</a></li>
2304                <li><a class="dropdown-item"
2305                    href="https://chromewebstore.google.com/detail/stream
2305snip/jpnlipcmklpfniaheflfnkomjdkhiani"><i
2306                      class="fa-solid fa-puzzle-piece" aria-hidden="true"></i> Chrome Extension</a></li>
2307                <li><a class="dropdown-item" href="/stats"><i class="fa-solid fa-chart-line" aria-hidden="true"></i>
2308                    Dashboard Stats</a>
2309                </li>
2310                <li><a class="dropdown-item" href="/all_games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>
2311                    All Games</a></li>
2312                <li><a class="dropdown-item" href="/guides"><i class="fa-solid fa-book" aria-hidden="true"></i>
2313                    Guides</a></li>
2314                <li><a class="dropdown-item" href="/ytdlp-builder"><i class="fa-solid fa-terminal" aria-hidden="true"></i>
2315                    yt-dlp Builder</a></li>
2316                <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>
2317                <li><a class="dropdown-item" href="/valorant-pov"><i class="fa-solid fa-crosshairs" aria-hidden="true"></i> Valorant POV Finder</a></li>
2318                <li><a class="dropdown-item" href="/overlays"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> Stream Overlays</a></li>
2319                <li><a class="dropdown-item" href="https://streamsnip.cronitorstatus.com" target="_blank"><i
2320                      class="fa-solid fa-signal" aria-hidden="true"></i> System Status</a></li>
2321              </ul>
2322
2323            </li>
2324
2325            <li class="nav-item">
2326              <a class="nav-link px-3 d-flex align-items-center" href="#" id="beamer-whats-new" title="What's New">
2327                <i class="fa-solid fa-bell me-2 d-lg-none" aria-hidden="true"></i> <span>What's New</span>
2328              </a>
2329            </li>
2330
2331            <li class="nav-item">
2332              <a class="nav-link px-3 d-flex align-items-center" href="https://discord.gg/2XVBWK99Vy"
2333                title="Join our Discord Community" aria-label="Discord Community">
2334                <i class="fa-brands fa-discord me-2" aria-hidden="true"></i> <span class="d-lg-none">Discord</span>
2335              </a>
2336            </li>
2337
2338            <li class="nav-item">
2339              <a class="nav-link px-3 d-flex align-items-center" href="#" id="report-bug-btn" role="button"
2340                title="Report a bug" aria-label="Report a bug" onclick="return false;">
2341                <i class="fa-solid fa-bug me-2" aria-hidden="true"></i> <span class="d-lg-none">Report a bug</span>
2342              </a>
2343            </li>
2344
2345            
2346
2347            <li class="nav-item">
2348              <a class="nav-link px-3 d-flex align-items-center" href="#" id="theme_button" onclick="toggleDarkTheme()"
2349                title="Toggle Light/Dark Theme" aria-label="Toggle Theme">
2350                <i class="fa-solid fa-moon fa-fw me-2" aria-hidden="true"></i> <span class="d-lg-none">Toggle Theme</span>
2351              </a>
2352            </li>
2353
2354            
2355            <li class="nav-item d-lg-none">
2356              <a class="nav-link px-3 d-flex align-items-center" href="/login?next=/">
2357                <i class="fa-solid fa-sign-in me-2" aria-hidden="true"></i> Login
2358              </a>
2359            </li>
2360            <li class="nav-item ms-lg-2 d-none d-lg-flex align-items-center">
2361              <a class="btn btn-primary btn-sm rounded-pill px-4" href="/login?next=/">Login <i
2362                  class="fa-solid fa-sign-in ms-1" aria-hidden="true"></i></a>
2363            </li>
2364            
2365          </ul>
2366        </div>
2367
2368      </nav>
2369      <style>
2370        /* Modern Hamburger Menu */
2371        .hamburger-icon {
2372          width: 22px;
2373          height: 16px;
2374          position: relative;
2375          display: flex;
2376          flex-direction: column;
2377          justify-content: space-between;
2378        }
2379
2380        .hamburger-icon span {
2381          display: block;
2382          height: 2px;
2383          width: 100%;
2384          background-color: var(--color-text);
2385          border-radius: 4px;
2386          transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease;
2387          transform-origin: center center;
2388        }
2389
2390        .navbar-toggler[aria-expanded="true"] .hamburger-icon span:nth-child(1) {
2391          transform: translateY(7px) rotate(45deg);
2392        }
2393
2394        .navbar-toggler[aria-expanded="true"] .hamburger-icon span:nth-child(2) {
2395          opacity: 0;
2396          transform: scale(0);
2397        }
2398
2399        .navbar-toggler[aria-expanded="true"] .hamburger-icon span:nth-child(3) {
2400          transform: translateY(-7px) rotate(-45deg);
2401        }
2402
2403        /* Mobile Navbar Fix */
2404        @media (max-width: 991px) {
2405          .navbar-nav {
2406            align-items: flex-start !important;
2407            padding-top: 1rem;
2408          }
2409
2410          .navbar-nav .nav-item {
2411            width: 100%;
2412          }
2413
2414          .navbar-nav .nav-link {
2415            width: 100%;
2416            padding: 0.75rem 1rem !important;
2417          }
2418
2419          .navbar-collapse {
2420            background: var(--glass-bg) !important;
2421            backdrop-filter: blur(24px);
2422            -webkit-backdrop-filter: blur(24px);
2423            border-radius: var(--radius-lg);
2424            margin-top: 0.75rem;
2425            padding: 0.5rem;
2426            border: 1px solid var(--glass-border);
2427            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
2428          }
2429
2430          body .navbar-nav .dropdown-menu {
2431            background: var(--glass-bg) !important;
2432            background-color: var(--glass-bg) !important;
2433            backdrop-filter: blur(12px) !important;
2434            -webkit-backdrop-filter: blur(12px) !important;
2435            border: 1px solid var(--glass-border) !important;
2436            border-inline-start: 3px solid var(--color-primary) !important;
2437            margin-block: 0.5rem !important;
2438            margin-inline: 1rem 0 !important;
2439            padding: 0.5rem !important;
2440            box-shadow: none !important;
2441            position: static !important;
2442            float: none !important;
2443            width: calc(100% - 1rem) !important;
2444            min-width: 0 !important;
2445            transform: none !important;
2446            opacity: 0 !important;
2447            visibility: hidden !important;
2448            display: none !important;
2449          }
2450
2451          body .navbar-nav .dropdown-menu.show {
2452            display: block !important;
2453            opacity: 1 !important;
2454            visibility: visible !important;
2455          }
2456
2457          .dropdown-item {
2458            padding: 0.5rem 0.75rem !important;
2459            border-radius: var(--radius-sm);
2460            font-size: 0.9rem;
2461          }
2462        }
2463
2464        /* Beamer panel uses the nav link as its trigger */
2465        #beamerSelector {
2466          display: none !important;
2467          border: 1px solid var(--color-primary);
2468        }
2469      </style>
2470  </header>
2471
2472  <!-- ========= ALERT CONTAINER ========= -->
2473  <style>
2474    #custom-alerts-container {
2475      position: fixed;
2476      bottom: 1.5rem;
2477      right: 1.5rem;
2478      z-index: 99999;
2479      display: flex;
2480      flex-direction: column;
2481      align-items: flex-end;
2482      gap: 0.5rem;
2483      pointer-events: none;
2484      isolation: isolate;
2485    }
2486
2487    @media (max-width: 767.98px) {
2488      #custom-alerts-container {
2489        right: 50%;
2490        transform: translateX(50%);
2491        align-items: center;
2492        width: 100%;
2493        padding: 0 1rem;
2494      }
2495    }
2496
2497    .custom-toast {
2498      pointer-events: auto;
2499      min-width: 250px;
2500      max-width: 100%;
2501      background: rgba(255, 255, 255, 0.9);
2502      backdrop-filter: blur(12px);
2503      -webkit-backdrop-filter: blur(12px);
2504      border: 1px solid rgba(255, 255, 255, 0.5);
2505      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
2506      border-radius: var(--radius-btn);
2507      padding: 1rem 1.25rem;
2508      position: relative;
2509      overflow: visible;
2510      display: flex;
2511      align-items: center;
2512      gap: 0.75rem;
2513      transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
2514    }
2515
2516    .dark-theme .custom-toast {
2517      background: hsl(var(--accent-h) 26% 12% / 0.85);
2518      border: 1px solid rgba(255, 255, 255, 0.1);
2519      color: var(--color-text);
2520    }
2521
2522    .custom-toast.toast-danger {
2523      border-inline-start: 4px solid var(--color-danger);
2524    }
2525
2526    .custom-toast.toast-success {
2527      border-inline-start: 4px solid var(--color-success);
2528    }
2529
2530    .custom-toast.toast-warning {
2531      border-inline-start: 4px solid var(--color-warning);
2532    }
2533
2534    .custom-toast.toast-info {
2535      border-inline-start: 4px solid var(--color-info);
2536    }
2537
2538    .custom-toast .toast-icon {
2539      font-size: 1.25rem;
2540      flex-shrink: 0;
2541    }
2542
2543    .custom-toast.toast-danger .toast-icon {
2544      color: var(--color-danger);
2545    }
2546
2547    .custom-toast.toast-success .toast-icon {
2548      color: var(--color-success);
2549    }
2550
2551    .custom-toast.toast-warning .toast-icon {
2552      color: var(--color-warning);
2553    }
2554
2555    .custom-toast.toast-info .toast-icon {
2556      color: var(--color-info);
2557    }
2558
2559    .custom-toast .toast-message {
2560      flex-grow: 1;
2561      word-break: break-word;
2562      font-size: 0.95rem;
2563      line-height: 1.4;
2564    }
2565
2566    .custom-toast .toast-action {
2567      flex-shrink: 0;
2568      border: 1px solid var(--glass-border);
2569      background: transparent;
2570      color: inherit;
2571      font: inherit;
2572      font-size: 0.85rem;
2573      font-weight: 600;
2574      padding: 0.35rem 0.75rem;
2575      border-radius: var(--radius-sm, 0.375rem);
2576      cursor: pointer;
2577      transition: background-color var(--transition-fast);
2578    }
2579
2580    .custom-toast .toast-action:hover {
2581      background: var(--overlay-hover, rgba(127, 127, 127, 0.14));
2582    }
2583  </style>
2584  <div id="custom-alerts-container" role="status" aria-live="polite" aria-atomic="false"></div>
2585
2586  
2587
2588  <!-- ========= MAIN CONTENT ========= -->
2589  <main class="content">
2590<div id="home-splash" aria-hidden="true">
2591    <div class="home-splash-logo">
2592        <img class="home-splash-board" alt="">
2593        <img class="home-splash-stick" alt="">
2594        <div class="home-splash-hole"></div>
2595    </div>
2596</div>
2597<script>
2598    (function () {
2599        var root = document.documentElement;
2600        if (!root.classList.contains('show-splash')) return;
2601        var el = document.getElementById('home-splash');
2602        var t;
2603        function dismiss() {
2604            if (!root.classList.contains('show-splash')) return;
2605            root.classList.add('splash-hiding');
2606            setTimeout(function () {
2607                root.classList.remove('show-splash', 'splash-hiding', 'splash-play');
2608                if (el) el.remove();
2609            }, 450); // matches the opacity transition
2610        }
2611        function play() {
2612            if (root.classList.contains('splash-play')) return;
2613            root.classList.add('splash-play');     // starts the clap from the idle (rest) frame
2614            // Fade out just after the strike + shake settle (~1.15s after the clap begins).
2615            t = setTimeout(dismiss, 1150);
2616        }
2617        if (el) el.addEventListener('click', function () { if (t) clearTimeout(t); dismiss(); });
2618
2619        // Reveal + clap only once BOTH the board and stick are fully decoded (paint-ready).
2620        // The board is a much heavier SVG than the stick, so waiting on load events alone
2621        // let the simple stick paint a frame before the board; img.decode() avoids that so
2622        // they appear together and the clap always starts from the idle frame.
2623        var dark = root.classList.contains('dark-theme');
2624        var boardEl = el.querySelector('.home-splash-board');
2625        var stickEl = el.querySelector('.home-splash-stick');
2626        boardEl.src = dark ? '/static/clapper-board-no-stroke.svg' : '/static/clapper-board-black.svg';
2627        stickEl.src = dark ? '/static/stick-arm-no-stroke.svg' : '/static/stick-arm-black.svg';
2628        var fired = false;
2629        function go() { if (fired) return; fired = true; play(); }
2630        Promise.all([boardEl, stickEl].map(function (im) {
2631            return (im.decode ? im.decode() : Promise.resolve()).catch(function () {});
2632        })).then(go);
2633        // Safety: never wait forever if an asset stalls.
2634        setTimeout(go, 800);
2635    })();
2636</script>
2636
2637
2638<div class="hero-wrapper mb-5">
2639    <canvas id="hero-canvas"></canvas>
2640    <div class="hero-content">
2641        <div class="hero-col-main">
2642        
2643        <h1 class="hero-title">Stream Clipping <br class="d-none d-lg-block"> Simplified</h1>
2644        <p class="hero-subtitle">Capture your best live moments instantly with a single command. Fully automated. Completely free.</p>
2645        
2646
2647        <div class="hero-cta-row d-flex flex-column flex-sm-row gap-3 align-items-center mb-4 mb-md-5">
2648            
2649                <a href="/get-started" class="btn btn-primary btn-lg py-2 py-md-3 rounded-pill hero-btn">
2650                    Get Started Free <i class="fa-solid fa-arrow-right ms-2"></i>
2651                </a>
2652                <a href="/snips" class="btn btn-snips-hero btn-lg py-2 py-md-3 rounded-pill hero-btn">
2653                    <i class="fa-solid fa-play me-2"></i> Watch Snips
2654                </a>
2655            
2656        </div>
2657
2658        <!-- Boasting Stats Pill -->
2659        <div class="stats-pill-wrapper">
2660            <div id="stats-pill" class="hero-stats-boast shadow-lg">
2661                <div id="today_count" class="fs-5 fs-md-3 fw-bold text-gradient-primary"></div>
2662                <div id="last_clip_time"
2663                    class="text-xs text-md-small text-muted opacity-75 letter-spacing-wide text-uppercase"
2664                    style="font-size: 0.7rem;"></div>
2665            </div>
2666        </div>
2667        </div>
2668
2669        <div class="hero-col-side">
2670            
2671            <!-- The same three steps the crawler-only section spells out, condensed
2672                 so signed-out visitors can actually see how clipping works. -->
2673            <div class="hero-side-card">
2674                <div class="hero-step">
2675                    <span class="hero-step-num">1</span>
2676                    <div>
2677                        <div class="hero-step-title">Add the !clip command</div>
2678                        <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>
2679                    </div>
2680                </div>
2681                <div class="hero-step">
2682                    <span class="hero-step-num">2</span>
2683                    <div>
2684                        <div class="hero-step-title">Your viewers clip for you</div>
2685                        <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>
2686                    </div>
2687                </div>
2688                <div class="hero-step">
2689                    <span class="hero-step-num">3</span>
2690                    <div>
2691                        <div class="hero-step-title">Clips appear instantly</div>
2692                        <p class="hero-step-text">Every clip lands in your feed and your Discord, ready to download or share.</p>
2693                    </div>
2694                </div>
2695            </div>
2696            
2697            
2698            <!-- The command itself, so a streamer can copy it without leaving
2699                 the landing page. Also shown to signed-in users whose channel has
2700                 no clips yet. layout.html attaches the Copy button. -->
2701            <div class="hero-side-card hero-command-card">
2702                <p class="hero-command-label">Paste this into your YouTube chat to add the command:</p>
2703                <div class="terminal-container">
2704                    <div class="terminal-header">
2705                        <div class="terminal-dot dot-red"></div>
2706                        <div class="terminal-dot dot-yellow"></div>
2707                        <div class="terminal-dot dot-green"></div>
2708                        <span class="small opacity-50 ms-auto font-monospace" style="font-size: 0.7rem;">Nightbot</span>
2709                    </div>
2710                    <div class="terminal-body">
2711                        <pre>!addcom !clip $(urlfetch https://streamsnip.com/clip/$(chatid)/$(querystring))</pre>
2712                    </div>
2713                </div>
2714            </div>
2715            
2716
2717            <!-- Kick integration announcement -->
2718            <div class="hero-side-card">
2719                <div class="hero-kick-title">
2720                    <img src="/static/kick-logo.svg" alt="Kick">
2721                    <span>integration is now <span style="color:#53fc18;">ON</span></span>
2722                </div>
2723                <p class="hero-kick-text">
2724                    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, made with Kick's native clip feature (<code>/clip</code>). Any queries? <a href="/guides#kick-guide" class="text-decoration-none fw-semibold">Read guide</a> / <a href="/contactus" class="text-decoration-none fw-semibold">Contact support</a>.
2725                </p>
2726            </div>
2727
2728        </div>
2729    </div>
2730
2731
2732    <div class="scene-selector-wrapper" title="Change Hero Background">
2733        <i class="fa-solid fa-wand-magic-sparkles"></i>
2734        <select id="hero-scene-select" onchange="switchHeroScene(this.value)">
2735            <!-- Scenes will be populated by JS -->
2736        </select>
2737    </div>
2738
2739
2740    <!-- Bottom Loading Gradient -->
2741    <div id="hero-loader" class="hero-bottom-loader"></div>
2742
2743    <!-- Hero Font Selector (Mirrors Scene Selector) -->
2744    <div class="font-selector-wrapper" id="hero-font-wrapper">
2745        <i class="fa-solid fa-font"></i>
2746        <select id="hero-font-select" onchange="updateHeroFont(this.value)">
2747            <!-- Fonts will be populated by JS -->
2748        </select>
2749    </div>
2750</div>
2751
2752
2753
2754
2755<div class="container">
2756    
2757    <!-- Premium Banners: only for visitors and streamers who haven't been clipped yet -->
2758    <div class="hero-banners-container">
2759        <div class="hero-banner-card position-relative overflow-hidden d-flex align-items-center justify-content-center text-center p-4"
2760            style="min-height: 315px;">
2761            <div>
2762                <i class="fa-solid fa-unlock fs-1 text-primary mb-3 d-block"></i>
2763                <h3 class="fw-bold mb-2">StreamSnip is free</h3>
2764                <p class="text-muted mb-0">Every feature, no plans, no limits.</p>
2765            </div>
2766        </div>
2767        <div class="hero-banner-card clip-demo-card overflow-hidden">
2768            <div class="cd" id="clip-demo" role="region" aria-label="How to set up the !clip command">
2769                <svg width="0" height="0" style="position:absolute" aria-hidden="true">
2770                    <symbol id="cd-i-wrench" viewBox="0 0 24 24"><path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"/></symbol>
2771                </svg>
2772                <div class="cd-narr" aria-live="polite">
2773                    <div class="cd-eyebrow cd-swap">Step 1 of 3</div>
2774                    <h3 class="cd-headline cd-swap">Paste the command into your live chat</h3>
2775                    <p class="cd-sub cd-swap">Nightbot must be in your channel. Send this once as the owner or a mod.</p>
2776                    <div class="cd-controls">
2777                        <div class="cd-steps">
2778                            <button type="button" class="cd-step on" data-step="0" aria-pressed="true" aria-label="Step 1: add the command">1</button>
2779                            <button type="button" class="cd-step" data-step="1" aria-pressed="false" aria-label="Step 2: viewers type !clip">2</button>
2780                            <button type="button" class="cd-step" data-step="2" aria-pressed="false" aria-label="Step 3: the clip appears">3</button>
2781                        </div>
2782                        <button type="button" class="cd-copy"><i class="fa-regular fa-copy"></i><span class="cd-copy-label">Copy command</span></button>
2783                    </div>
2784                </div>
2785
2786                <div class="cd-yt" aria-hidden="true">
2787                    <div class="cd-yt-head">
2788                        Top chat<svg viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>
2789                        <span class="cd-sp"></span>
2790                        <svg viewBox="0 0 24 24">
2790<path d="M12 16.5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zm0-6a1.5 1.5 0 110 3 1.5 1.5 0 010-3zm0-6a1.5 1.5 0 110 3 1.5 1.5 0 010-3z"/></svg>
2791                    </div>
2792                    <div class="cd-log"></div>
2793                    <div class="cd-input">
2794                        <div class="cd-who"><span class="cd-av"></span><span class="cd-who-name"></span></div>
2795                        <div class="cd-field"><span class="cd-typed"></span><span class="cd-caret"></span></div>
2796                        <div class="cd-tools">
2797                            <svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 18a8 8 0 110-16 8 8 0 010 16zm-3.5-9a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm7 0a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM12 17.5c2.3 0 4.3-1.5 5.1-3.5H6.9c.8 2 2.8 3.5 5.1 3.5z"/></svg>
2798                            <span class="cd-sp"></span>
2799                            <span class="cd-count">0/200</span>
2800                            <span class="cd-send"><svg viewBox="0 0 24 24"><path d="M2 21l21-9L2 3v7l15 2-15 2z"/></svg></span>
2801                        </div>
2802                    </div>
2803                </div>
2804
2805                <div class="cd-card" aria-hidden="true">
2806                    <div class="cd-thumb">
2807                        <img src="/static/demo/clip-thumb.jpg" alt="" loading="lazy" width="640" height="360">
2808                        <div class="cd-overlay">
2809                            <span class="cd-ob del"><i class="fa-regular fa-trash-can"></i></span>
2810                            <span class="cd-ob dl"><i class="fa-solid fa-download"></i><i class="fa-solid fa-caret-down"></i></span>
2811                        </div>
2812                        <span class="cd-hms">01:42:07</span>
2813                    </div>
2814                    <div class="cd-cb">
2815                        <div class="cd-cb-title">insane clutch <i class="fa-solid fa-pencil"></i></div>
2816                        <div class="cd-cb-author"><span class="cd-av" style="background-image:url(/static/demo/suraj.jpg)"></span>@suraj_ag</div>
2817                        <div class="cd-tags">
2818                            <span class="cd-tag"><i class="fa-solid fa-gamepad"></i>Valorant</span>
2819                            <span class="cd-tag"><i class="fa-solid fa-hashtag"></i>2XE6127</span>
2820                            <span class="cd-tag"><i class="fa-solid fa-clock"></i>just now</span>
2821                        </div>
2822                        <div class="cd-socials"><i class="fa-brands fa-discord"></i><i class="fa-brands fa-youtube"></i></div>
2823                        <div class="cd-foot">
2824                            <span class="cd-pov">Alt POV</span>
2825                            <span class="cd-sq round"><i class="fa-solid fa-arrows-rotate"></i></span>
2826                            <span class="cd-sq round"><i class="fa-solid fa-share-nodes"></i></span>
2827                            <span class="cd-sq"><i class="fa-solid fa-plus"></i></span>
2828                            <span class="cd-chk"></span>
2829                        </div>
2830                    </div>
2831                </div>
2832                <div class="cd-flash"></div>
2833            </div>
2834        </div>
2835    </div>
2836    
2837    <!-- Smart Tips Banner -->
2838    <div class="tips-banner-wrapper" data-aos="fade-up" data-aos-delay="200">
2839        <div id="tips-banner" class="tips-banner" onclick="handleTipClick()">
2840            <i class="tip-icon fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
2841            <span id="tip-text" class="tip-text">Everyone can auto clip Valorant highlights.</span>
2842        </div>
2843    </div>
2844
2845    <div id="search-console" class="row">
2846        <div class="col-md-12">
2847            <div class="command-bar glass" id="searchnsort" style="position: relative; z-index: 50;">
2848                <div class="search-wrapper">
2849                    <i class="fa-solid fa-magnifying-glass text-muted" aria-hidden="true"></i>
2850                    <input type="text" id="search" class="search" placeholder="Search for channels..."
2851                        aria-label="Search for channels">
2852                </div>
2853
2854                <div class="d-flex align-items-center gap-2 border-start border-secondary border-opacity-25 ps-3">
2855                    <span class="text-muted small fw-bold text-nowrap d-none d-lg-block"
2856                        style="opacity: 0.7;">Game:</span>
2857                    <select id="game-filter" onchange="setGame(this.value, true)" aria-label="Filter by Game"
2858                        data-container="body">
2859                        <option value="">All Games</option>
2860                    </select>
2861                </div>
2862
2863                <div class="d-flex align-items-center gap-2 border-start border-secondary border-opacity-25 ps-3">
2864                    <span class="text-muted small fw-bold text-nowrap d-none d-lg-block" style="opacity: 0.7;">Sort
2865                        by:</span>
2866                    <select id="sortby" onchange="reload()" aria-label="Sort channels by" data-container="body">
2867                        
2868                        <option value="sub_count">Sub Count</option>
2869                        <option value="default">Latest Clip Time</option>
2870                        
2871                        <option value="alphabetical">Alphabetical</option>
2872                        <option value="clip_count">Clip Count</option>
2873                        <option value="first_clip">First Clip Time</option>
2874                    </select>
2875
2876                    <a id="invert-btn" class="invert-btn" href="#" onclick="toggleInverse(); return false;"
2877                        aria-label="Invert Sort Direction" title="Invert Sort Order">
2878                        <i class="fa-solid fa-arrows-up-down" aria-hidden="true"></i>
2879                    </a>
2880                </div>
2881            </div>
2882            <div class="game-cards-wrapper" data-aos="fade-up" data-aos-delay="300">
2883                <button class="scroll-btn left" onclick="scrollGames(-1)" aria-label="Scroll Left">
2884                    <i class="fa-solid fa-chevron-left"></i>
2885                </button>
2886                <div id="game-cards" class="game-cards-container">
2887                    
2888                    <div class="game-card skeleton-game">
2889                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2890                        <div class="game-overlay">
2891                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2892                        </div>
2893                    </div>
2894                    
2895                    <div class="game-card skeleton-game">
2896                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2897                        <div class="game-overlay">
2898                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2899                        </div>
2900                    </div>
2901                    
2902                    <div class="game-card skeleton-game">
2903                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2904                        <div class="game-overlay">
2905                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2906                        </div>
2907                    </div>
2908                    
2909                    <div class="game-card skeleton-game">
2910                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2911                        <div class="game-overlay">
2912                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2913                        </div>
2914                    </div>
2915                    
2916                    <div class="game-card skeleton-game">
2917                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2918                        <div class="game-overlay">
2919                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2920                        </div>
2921                    </div>
2922                    
2923                    <div class="game-card skeleton-game">
2924                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2925                        <div class="game-overlay">
2926                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2927                        </div>
2928                    </div>
2929                    
2930                    <div class="game-card skeleton-game">
2931                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2932                        <div class="game-overlay">
2933                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2934                        </div>
2935                    </div>
2936                    
2937                    <div class="game-card skeleton-game">
2938                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2939                        <div class="game-overlay">
2940                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2941                        </div>
2942                    </div>
2943                    
2944                    <div class="game-card skeleton-game">
2945                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2946                        <div class="game-overlay">
2947                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2948                        </div>
2949                    </div>
2950                    
2951                    <div class="game-card skeleton-game">
2952                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2953                        <div class="game-overlay">
2954                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2955                        </div>
2956                    </div>
2957                    
2958                    <div class="game-card skeleton-game">
2959                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
2960                        <div class="game-overlay">
2961                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
2962                        </div>
2963                    </div>
2964                    
2965                </div>
2966                <button class="scroll-btn right" onclick="scrollGames(1)" aria-label="Scroll Right">
2967                    <i class="fa-solid fa-chevron-right"></i>
2968                </button>
2969            </div>
2970        </div>
2971    </div>
2972
2973    <div class="row">
2974        <div class="col-md-12">
2975            <div id="loading-line" class="" style="height: 10px; border-radius: var(--radius-full);">
2976                <div class="" role="" style="width: 100%;" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
2977                </div>
2978            </div>
2979        </div>
2980    </div>
2981    <div id="clips-container" class="row">
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 class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3050            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3051                <div class="position-relative h-100 overflow-hidden">
3052                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3053                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3054                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3055                    </div>
3056                </div>
3057            </div>
3058        </div>
3059        
3060        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3061            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3062                <div class="position-relative h-100 overflow-hidden">
3063                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3064                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3065                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3066                    </div>
3067                </div>
3068            </div>
3069        </div>
3070        
3071        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3072            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3073                <div class="position-relative h-100 overflow-hidden">
3074                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3075                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3076                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3077                    </div>
3078                </div>
3079            </div>
3080        </div>
3081        
3082        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3083            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3084                <div class="position-relative h-100 overflow-hidden">
3085                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3086                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3087                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3088                    </div>
3089                </div>
3090            </div>
3091        </div>
3092        
3093        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3094            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3095                <div class="position-relative h-100 overflow-hidden">
3096                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3097                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3098                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3099                    </div>
3100                </div>
3101            </div>
3102        </div>
3103        
3104        <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
3105            <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
3106                <div class="position-relative h-100 overflow-hidden">
3107                    <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
3108                    <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
3109                        <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
3110                    </div>
3111                </div>
3112            </div>
3113        </div>
3114        
3115    </div>
3116
3117    <!-- Infinite Scroll Anchor -->
3118    <div id="scroll-anchor" class="d-flex justify-content-center py-5" style="min-height: 100px;">
3119        <div id="infinite-loader" style="opacity: 0; transition: opacity var(--transition-base);"
3120            data-clap-loader data-size="40"></div>
3121    </div>
3122    </main>
3123
3124  <!-- ========= FOOTER ========= -->
3125  <footer class="py-3 border-top border-opacity-10" style="position: relative; z-index: 2;">
3126
3127    <div class="container">
3128      <div class="row align-items-center">
3129        <div class="col-md-6">© 2026&nbsp;Suraj&nbsp;Bhari</div>
3130        <nav class="col-md-6 text-md-end">
3131          <a href="/privacy-policy">Privacy&nbsp;Policy</a> |
3132          <a href="/aboutus">About&nbsp;Us</a> |
3133          <a href="/contactus">Contact&nbsp;Us</a> |
3134          <a href="/tnc">Terms&nbsp;&amp;&nbsp;Conditions</a>
3135        </nav>
3136      </div>
3137      <div class="row mt-2" aria-hidden="true">
3138        <div class="col-12 text-md-end small" style="color: transparent; user-select: none;">
3139          StreamSnip is original, copyrighted work. Use of any content, code, or design from this site
3140          for AI/ML training, RAG indexing, generative reproduction, or to "rebuild" or "clone" the site
3141          is not permitted.
3142        </div>
3143      </div>
3144    </div>
3145  </footer>
3146
3147  <!-- ========= DOWNLOAD QUEUE ========= -->
3148<style>
3149  #dq-panel { font-family: inherit; }
3150  .dq-row {
3151    border-radius: var(--radius-md);
3152    padding: 0.4rem 0.6rem;
3153    position: relative;
3154    overflow: hidden;
3155    flex-shrink: 0;          /* don't compress — let the body scroll instead */
3156    transition: background 0.2s ease;
3157  }
3158  .dq-row-pending  { background: rgba(255,255,255,0.04); }
3159  .dq-row-downloading { background: var(--primary-08); border: 1px solid hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.2); }
3160  .dq-row-done     { background: rgba(34,197,94,0.08);  border: 1px solid rgba(34,197,94,0.2); }
3161  .dq-row-error    { background: rgba(239,68,68,0.08);  border: 1px solid rgba(239,68,68,0.2); }
3162  .dq-label {
3163    font-size: 0.75rem;
3164    color: var(--color-text);
3165    white-space: nowrap;
3166    overflow: hidden;
3167    text-overflow: ellipsis;
3168    max-width: 100%;
3169  }
3170  .dq-sub    { font-size: 0.65rem;
3170 margin-top: 1px; }
3171  .dq-detail { font-size: 0.6rem; margin-top: 1px; color: var(--color-text-muted); opacity: 0.7; letter-spacing: 0.02em; }
3172  .dq-actions {
3173    display: flex;
3174    align-items: center;
3175    justify-content: space-between;
3176    gap: 4px;
3177    margin-top: 3px;
3178  }
3179  .dq-btn {
3180    font-size: 0.65rem;
3181    padding: 2px 6px;
3182    border-radius: 4px;
3183    border: none;
3184    cursor: pointer;
3185    line-height: 1.4;
3186    text-decoration: none;
3187    display: inline-flex;
3188    align-items: center;
3189    gap: 3px;
3190  }
3191  .dq-btn-dl   { background: rgba(34,197,94,0.2);  color: #86efac; }
3192  .dq-btn-dl:hover { background: rgba(34,197,94,0.35); color: #86efac; }
3193  .dq-btn-retry { background: rgba(239,68,68,0.2); color: #fca5a5; }
3194  .dq-btn-retry:hover { background: rgba(239,68,68,0.35); color: #fca5a5; }
3195  .dq-btn-rm   { background: none; color: var(--color-text-muted); opacity: 0.6; }
3196  .dq-btn-rm:hover { opacity: 1; }
3197  #dq-body::-webkit-scrollbar { width: 4px; }
3198  #dq-body::-webkit-scrollbar-thumb { background: var(--color-primary); border-radius: 4px; }
3199  /* downloading row indicator */
3200  .bouncing-gradient {
3201    background: var(--primary-12);
3202    border-inline-start: 3px solid var(--color-primary) !important;
3203  }
3204</style>
3205
3206<div id="dq-panel" style="display:none;position:fixed;bottom:1.5rem;inset-inline-start:1.5rem;z-index:1030;width:260px;">
3207  <div class="glass" style="border-radius:var(--radius-lg);overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.3);">
3208    <!-- Header -->
3209    <div id="dq-header"
3210         onclick="DownloadQueue._toggleCollapse()"
3211         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;">
3212      <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;">
3213        <i class="fa-solid fa-download" style="color:var(--color-primary);font-size:0.7rem;" aria-hidden="true"></i>
3214        <span id="dq-title">Downloads</span>
3215      </span>
3216      <div style="display:flex;align-items:center;gap:6px;">
3217        <a href="/downloads"
3218           onclick="event.stopPropagation();"
3219           style="font-size:0.6rem;color:var(--color-text-muted);padding:1px 4px;opacity:0.65;line-height:1;text-decoration:none;"
3220           title="Open the downloads page">view all</a>
3221        <button id="dq-clear-btn"
3222                onclick="event.stopPropagation();DownloadQueue.clearAll()"
3223                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;"
3224                title="Cancel all queued items">clear all</button>
3225        <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;" aria-hidden="true"></i>
3226      </div>
3227    </div>
3228    <!-- Item list -->
3229    <div id="dq-body"
3230         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;">
3231    </div>
3232  </div>
3233</div>
3234
3235<script>
3236/* =================== TAB BADGE =================== */
3237/* One owner of document.title, because two of them fight: whichever captures the
3238   base title second captures the other's "(2) " prefix and bakes it in for good.
3239   Anything that finishes while the tab is hidden counts in here; coming back to
3240   the tab is what clears it. */
3241window.TabBadge = (() => {
3242  let base  = null;
3243  let count = 0;
3244
3245  function _base() {
3246    if (base === null) base = document.title.replace(/^\(\d+\)\s+/, '');
3247    return base;
3248  }
3249
3250  function set(n) {
3251    count = Math.max(0, n | 0);
3252    document.title = count ? `(${count}) ${_base()}` : _base();
3253  }
3254
3255  function bump(n) { set(count + (n || 1)); }
3256  function clear()  { set(0); }
3257
3258  document.addEventListener('visibilitychange', () => {
3259    if (!document.hidden) clear();
3260  });
3261
3262  return { set, bump, clear, get count() { return count; } };
3263})();
3264
3265/* =================== SHARE =================== */
3266/* On a phone, copying a link to the clipboard is the long way round to the thing
3267   people actually want — the OS share sheet, one tap to WhatsApp or a Discord DM.
3268   On desktop the sheet is a worse clipboard (Windows routes it through a half-empty
3269   apps list), so desktop keeps copying. Coarse pointer AND a phone-width screen:
3270   a touchscreen laptop is not a phone, and neither is a tablet in landscape.
3271   Call this from a click handler — navigator.share requires a user gesture. */
3272window.shareOrCopy = function (url, opts) {
3273  opts = opts || {};
3274  const onCopied = opts.onCopied || function () { send_alert('Link copied to clipboard!', 'success'); };
3275  const onFailed = opts.onFailed || function () { send_alert('Failed to copy link.', 'danger'); };
3276  const isPhone = window.matchMedia('(pointer: coarse) and (max-width: 820px)').matches;
3277
3278  if (isPhone && navigator.share) {
3279    const payload = { url: url };
3280    if (opts.title) payload.title = opts.title;
3281    // A dismissed sheet rejects with AbortError — that's a decision, not a failure.
3282    return navigator.share(payload).catch(function (err) {
3283      if (err && err.name === 'AbortError') return;
3284      navigator.clipboard.writeText(url).then(onCopied).catch(onFailed);
3285    });
3286  }
3287  return navigator.clipboard.writeText(url).then(onCopied).catch(onFailed);
3288};
3289
3290/* =================== DOWNLOAD QUEUE =================== */
3291window.DownloadQueue = (() => {
3292  const STORAGE_KEY  = 'ss_download_queue';
3293  const COLLAPSE_KEY = 'ss_dq_collapsed';
3294  const NOTIFY_KEY   = 'ss_dq_downloads_started';
3295
3296  /* A cut can take minutes and people tab away; the badge in the corner only
3297     helps someone already looking at it. Ask for notification permission on the
3298     second download rather than the first — one download is not yet a habit, and
3299     a prompt on the very first click reads as a shakedown (Chrome also holds a
3300     denial against the origin for good). The ask rides the click that triggered
3301     it, so Firefox's user-gesture requirement is satisfied. */
3302  function _maybeAskNotify() {
3303    if (!('Notification' in window) || Notification.permission !== 'default') return;
3304    let started = 0;
3305    try { started = parseInt(localStorage.getItem(NOTIFY_KEY) || '0', 10) || 0; } catch { return; }
3306    started++;
3307    try { localStorage.setItem(NOTIFY_KEY, String(started)); } catch {}
3308    if (started < 2) return;
3309    try { Notification.requestPermission(); } catch {}
3310  }
3311
3312  function _notify(item) {
3313    if (!document.hidden) return;
3314    TabBadge.bump();
3315    if (!('Notification' in window) || Notification.permission !== 'granted') return;
3316    try {
3317      const n = new Notification(
3318        item.status === 'done' ? 'Your clip is ready' : 'Clip download failed',
3319        {
3320          body: item.label || '',
3321          icon: '/static/logo.png',
3322          // Same clip re-firing replaces its own notification instead of stacking.
3323          tag:  'ss-dq-' + item.id,
3324        }
3325      );
3326      n.onclick = () => { window.focus(); n.close(); };
3327    } catch {}
3328  }
3329
3330  function _load() {
3331    try {
3332      const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
3333      const SIX_HOURS = 6 * 60 * 60 * 1000;
3334      const now = Date.now();
3335      const fresh = raw.filter(item => {
3336        if ((item.status === 'done' || item.status === 'error') && item.doneAt) {
3337          return now - item.doneAt < SIX_HOURS;
3338        }
3339        return true;
3340      });
3341      if (fresh.length !== raw.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
3342      return fresh;
3343    } catch { return []; }
3344  }
3345
3346  function _save(queue) {
3347    localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
3348  }
3349
3350  function _esc(s) {
3351    return String(s)
3352      .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
3353  }
3354
3355  function _isCollapsed() {
3356    return localStorage.getItem(COLLAPSE_KEY) === 'true';
3357  }
3358
3359  function _toggleCollapse() {
3360    const next = !_isCollapsed();
3361    localStorage.setItem(COLLAPSE_KEY, next);
3362    _applyCollapse(next);
3363  }
3364
3365  function _applyCollapse(collapsed) {
3366    const body    = document.getElementById('dq-body');
3367    const chevron = document.getElementById('dq-chevron');
3368    if (body)    body.style.display      = collapsed ? 'none'  : 'flex';
3369    if (chevron) chevron.style.transform = collapsed ? 'rotate(-90deg)' : '';
3370  }
3371
3372  function _formatDetail(item) {
3373    const parts = [];
3374    // Use stored human-readable label when available (e.g. "720p · MP4")
3375    if (item.detail) {
3376      parts.push(item.detail);
3377    } else {
3378      // Fallback for items added before detail was stored
3379      try {
3380        const p   = new URLSearchParams(item.qs);
3381        const fid = p.get('format_id') || '';
3382        const ext = (p.get('ext') || '').toUpperCase();
3383        if (fid.startsWith('custom-')) parts.push(fid.replace('custom-', '') + 'p');
3384        else if (fid) parts.push(fid);
3385        if (ext) parts.push(ext);
3386      } catch {}
3387    }
3388    // Always append clip duration when start/end are set
3389    try {
3390      const p  = new URLSearchParams(item.qs);
3391      const t1 = parseInt(p.get('start_time')), t2 = parseInt(p.get('end_time'));
3392      if (!isNaN(t1) && !isNaN(t2) && t2 > t1) {
3393        const dur = t2 - t1;
3394        const mm  = String(Math.floor(dur / 60)).padStart(2, '0');
3395        const ss  = String(dur % 60).padStart(2, '0');
3396        parts.push(`${mm}:${ss} clip`);
3397      }
3398    } catch {}
3399    return parts.join(' · ');
3400  }
3401
3402  function _render() {
3403    const queue  = _load();
3404    const panel  = document.getElementById('dq-panel');
3405    const body   = document.getElementById('dq-body');
3406    const title  = document.getElementById('dq-title');
3407    if (!panel || !body) return;
3408
3409    panel.style.display = queue.length === 0 ? 'none' : 'block';
3410    if (title) title.textContent = `Downloads (${queue.length})`;
3411    const clearBtn = document.getElementById('dq-clear-btn');
3412    if (clearBtn) clearBtn.style.display = queue.length > 0 ? 'inline' : 'none';
3413
3414    body.innerHTML = '';
3415    // Sort: downloading first, then pending, then error, then done (done: most recent on top)
3416    const _statusOrder = { downloading: 0, pending: 1, error: 2, done: 3 };
3417    const sorted = queue
3418      .map((item, i) => ({ item, i }))
3419      .sort((a, b) => {
3420        const diff = (_statusOrder[a.item.status] ?? 9) - (_statusOrder[b.item.status] ?? 9);
3421        if (diff !== 0) return diff;
3422        if (a.item.status === 'done') return b.i - a.i; // newest done first
3423        return a.i - b.i;
3424      })
3425      .map(({ item }) => item);
3426    sorted.forEach(item => {
3427      const row = document.createElement('div');
3428      row.className = `dq-row dq-row-${item.status}`;
3429      row.id = `dq-row-${CSS.escape(item.id)}`;
3430
3431      const labelEl = document.createElement('div');
3432      labelEl.className = 'dq-label';
3433      labelEl.title = item.label;
3434
3435      if (item.status === 'pending') {
3436        labelEl.textContent = item.label;
3437        const actions = document.createElement('div');
3438        actions.className = 'dq-actions';
3439
3440        const sub = document.createElement('div');
3441        sub.className = 'dq-sub';
3442        sub.style.color = 'var(--color-text-muted)';
3443        sub.textContent = 'queued';
3444
3445        const rmBtn = document.createElement('button');
3446        rmBtn.className = 'dq-btn dq-btn-rm';
3447        rmBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
3448        rmBtn.title = 'Cancel';
3449        rmBtn.setAttribute('aria-label', 'Cancel ' + (item.label || 'this download'));
3450        rmBtn.onclick = () => remove(item.id);
3451
3452        actions.appendChild(sub);
3453        actions.appendChild(rmBtn);
3454        row.appendChild(labelEl);
3455        const det0 = _formatDetail(item);
3456        if (det0) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det0; row.appendChild(d); }
3457        row.appendChild(actions);
3458
3459      } else if (item.status === 'downloading') {
3460        row.classList.add('bouncing-gradient');
3461        labelEl.textContent = item.label;
3462        const actions = document.createElement('div');
3463        actions.className = 'dq-actions';
3464
3465        const sub = document.createElement('div');
3466        sub.className = 'dq-sub';
3467        sub.style.color = 'var(--color-primary)';
3468        sub.textContent = 'downloading…';
3469
3470        const rmBtn = document.createElement('button');
3471        rmBtn.className = 'dq-btn dq-btn-rm';
3472        rmBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
3473        rmBtn.title = 'Cancel';
3474        rmBtn.setAttribute('aria-label', 'Cancel ' + (item.label || 'this download'));
3475        rmBtn.onclick = () => remove(item.id);
3476
3477        actions.appendChild(sub);
3478        actions.appendChild(rmBtn);
3479        row.appendChild(labelEl);
3480        const det1 = _formatDetail(item);
3481        if (det1) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det1; row.appendChild(d); }
3482        row.appendChild(actions);
3483
3484      } else if (item.status === 'done') {
3485        labelEl.innerHTML = '<i class="fa-solid fa-check me-1" style="color:#4ade80"></i>' + _esc(item.label);
3486        const actions = document.createElement('div');
3487        actions.className = 'dq-actions';
3488
3489        const sub = document.createElement('div');
3490        sub.className = 'dq-sub';
3491        sub.style.color = '#4ade80';
3492        sub.textContent = 'ready';
3493
3494        const dlBtn = document.createElement('a');
3495        dlBtn.href = `/video/${item.id}?${item.qs}`;
3496        dlBtn.className = 'dq-btn dq-btn-dl';
3497        dlBtn.innerHTML = '<i class="fa-solid fa-download" aria-hidden="true"></i>';
3498        dlBtn.title = 'Save file';
3499        dlBtn.setAttribute('aria-label', 'Save ' + (item.label || 'this download'));
3500
3501        const rmBtn = document.createElement('button');
3502        rmBtn.className = 'dq-btn dq-btn-rm';
3503        rmBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
3504        rmBtn.title = 'Remove from the list';
3505        rmBtn.setAttribute('aria-label', 'Remove ' + (item.label || 'this download') + ' from the list');
3506        rmBtn.onclick = () => remove(item.id);
3507
3508        actions.appendChild(sub);
3509        actions.appendChild(dlBtn);
3510        actions.appendChild(rmBtn);
3511        row.appendChild(labelEl);
3512        const det2 = _formatDetail(item);
3513        if (det2) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det2; row.appendChild(d); }
3514        row.appendChild(actions);
3515
3516      } else if (item.status === 'error') {
3517        labelEl.innerHTML = '<i class="fa-solid fa-xmark me-1" style="color:#f87171"></i>' + _esc(item.label);
3518        const actions = document.createElement('div');
3519        actions.className = 'dq-actions';
3520
3521        const sub = document.createElement('div');
3522        sub.className = 'dq-sub';
3523        sub.style.color = '#f87171';
3524        sub.textContent = (item.error || 'Error').substring(0, 38);
3525
3526        const retryBtn = document.createElement('button');
3527        retryBtn.className = 'dq-btn dq-btn-retry';
3528        retryBtn.innerHTML = '<i class="fa-solid fa-rotate-right" aria-hidden="true"></i> retry';
3529        retryBtn.setAttribute('aria-label', 'Retry ' + (item.label || 'this download'));
3530        retryBtn.onclick = () => retry(item.id);
3531
3532        const rmBtn = document.createElement('button');
3533        rmBtn.className = 'dq-btn dq-btn-rm';
3534        rmBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
3535        rmBtn.title = 'Remove from the list';
3536        rmBtn.setAttribute('aria-label', 'Remove ' + (item.label || 'this download') + ' from the list');
3537        rmBtn.onclick = () => remove(item.id);
3538
3539        actions.appendChild(sub);
3540        actions.appendChild(retryBtn);
3541        actions.appendChild(rmBtn);
3542        row.appendChild(labelEl);
3543        const det3 = _formatDetail(item);
3544        if (det3) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det3; row.appendChild(d); }
3545        row.appendChild(actions);
3546      }
3547
3548      body.appendChild(row);
3549    });
3550
3551    _applyCollapse(_isCollapsed());
3552  }
3553
3554  /* ---------- public mutators ---------- */
3555
3556  function add(id, label, qs, detail) {
3557    const queue = _load();
3558    // One row per clip id — _settle/_pollJob/_triggerDownload all key off the
3559    // (assumed-unique) id. Re-download an already-finished clip and we must
3560    // re-activate its existing row, not push a duplicate: a duplicate id makes
3561    // _settle update the wrong row, leaving the new one stuck 'downloading' and
3562    // re-firing the save dialog on every page load.
3563    const existing = queue.find(i => i.id === id);
3564    if (existing) {
3565      if (existing.status === 'pending' || existing.status === 'downloading') return;
3566      existing.status = 'pending';
3567      existing.label  = label;
3568      existing.qs     = qs;
3569      existing.detail = detail || '';
3570      delete existing.error;
3571      delete existing.doneAt;
3572    } else {
3573      queue.push({ id, label, qs, detail: detail || '', status: 'pending' });
3574    }
3575    if (typeof gtag === 'function') gtag('event', 'clip_download', { clip_id: id, source: 'youtube' });
3576    _maybeAskNotify();
3577    _save(queue);
3578    _render();
3579    _kickProcessor();
3580  }
3581
3582  function remove(id) {
3583    _save(_load().filter(i => i.id !== id));
3584    _render();
3585  }
3586
3587  function clearAll() {
3588    _save([]);
3589    _render();
3590  }
3591
3592  function retry(id) {
3593    const queue = _load();
3594    const item  = queue.find(i => i.id === id);
3595    if (item) { item.status = 'pending'; delete item.error; }
3596    _save(queue);
3597    _render();
3598    _kickProcessor();
3599  }
3600
3601  /* ---------- processor ---------- */
3602
3603  let _processing = false;
3604
3605  function _kickProcessor() {
3606    if (!_processing) _process();
3607  }
3608
3609  function _process() {
3610    const queue = _load();
3611    const next  = queue.find(i => i.status === 'pending');
3612    if (!next) { _processing = false; return; }
3613    _processing = true;
3614    next.status = 'downloading';
3615    _save(queue);
3616    _render();
3617    document.dispatchEvent(new CustomEvent('dq:downloading', { detail: { id: next.id, qs: next.qs } }));
3618
3619    fetch(`/download/${next.id}?${next.qs}`, { method: 'POST' })
3620      .then(res => res.json())
3621      .then(({ job_id }) => _pollJob(job_id, next))
3622      .catch(() => _settle(next, 'error', 'Could not start download'))
3623      .finally(() => { _processing = false; _kickProcessor(); });
3624  }
3625
3626  function _pollJob(jobId, item) {
3627    return new Promise(resolve => {
3628      const tick = () => {
3629        // Stop polling if the item was removed from the queue mid-flight,
3630        // so a stuck/long-lived job doesn't leave an orphaned 2s loop running.
3631        if (!_load().some(i => i.id === item.id)) { resolve(); return; }
3632        fetch(`/jobs/${jobId}`)
3633          .then(r => r.json())
3634          .then(d => {
3635            if (d.status === 'succeeded') { _settle(item, 'done'); _triggerDownload(item); resolve(); }
3636            else if (d.status === 'failed' || d.status === 'unknown' || d.status === 'forbidden')
3637                 { _settle(item, 'error', d.error || 'Download failed'); resolve(); }
3638            else setTimeout(tick, 2000); // queued | doing
3639          })
3640          .catch(() => { _settle(item, 'error', 'Lost connection to job'); resolve(); });
3641      };
3642      tick();
3643    });
3644  }
3645
3646  function _settle(next, status, error) {
3647    const q = _load();
3648    const item = q.find(i => i.id === next.id);
3649    if (!item) return;
3650    item.status = status;
3651    if (error) item.error = error;
3652    item.doneAt = Date.now();
3653    _save(q);
3654    _render();
3655    _notify(item);
3656    document.dispatchEvent(new CustomEvent('dq:settled', { detail: { id: item.id, status: item.status } }));
3657  }
3658
3659  function _triggerDownload(item) {
3660    const a = document.createElement('a');
3661    a.href = `/video/${item.id}?${item.qs}`;
3662    a.style.display = 'none';
3663    document.body.appendChild(a);
3664    a.click();
3665    document.body.removeChild(a);
3666  }
3667
3668  /* ---------- init ---------- */
3669
3670  function _init() {
3671    const queue = _load();
3672    // Self-heal duplicate clip-id rows the old add() left in storage — they broke
3673    // _settle's find-by-id and stranded rows on 'downloading' (re-prompting a save
3674    // on every load). Collapse to one row per id (keep the latest), then reset any
3675    // row left 'downloading' by a previous page load back to 'pending'.
3676    const byId = new Map();
3677    queue.forEach(item => byId.set(item.id, item));
3678    const deduped = [...byId.values()];
3679    let changed = deduped.length !== queue.length;
3680    deduped.forEach(item => {
3681      if (item.status === 'downloading') { item.status = 'pending'; changed = true; }
3682    });
3683    if (changed) _save(deduped);
3684    _render();
3685    _kickProcessor();
3686  }
3687
3688  return { add, remove, retry, clearAll, _render, _load, _save, _toggleCollapse, _applyCollapse, _kickProcessor, _process, _init };
3689})();
3690
3691document.addEventListener('DOMContentLoaded', () => DownloadQueue._init());
3692</script>
3692
3693
3694  <!-- ========= SCRIPTS ========= -->
3695  
3695<script src="https://code.jquery.com/jquery-3.7.1.min.js"
3696    integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous" defer></script>
3696
3697
3698  
3698<script
3699    src="https://cdn.jsdelivr.net/gh/xcash/[email protected]/dist/latest/bootstrap-autocomplete.min.js"
3700    defer></script>
3700
3701  
3701<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
3702    integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"
3703    defer></script>
3703
3704  
3704<script src="/static/dropdown.js?v=ccddf8d66c" defer></script>
3704
3705  
3705<script defer>
3706    /* A nav menu's frosted blur is a no-op while it lives inside .glass-nav: the header
3707       is a backdrop root (backdrop-filter + view-transition-name), so a descendant's
3708       backdrop-filter has no page pixels to sample. Lift the menu out to <body> while
3709       it's open and anchor it to the toggle — the header is fixed, so that anchor never
3710       moves and no scroll repositioning is needed. Expanded navbar only; collapsed, the
3711       menu renders inline as an accordion and must stay where it is. */
3712    (function () {
3713      const desktop = window.matchMedia('(min-width: 992px)');
3714      const pairs = [];
3715
3716      function place(menu, toggle) {
3717        const rect = toggle.getBoundingClientRect();
3718        menu.style.setProperty('top', `${rect.bottom + 8}px`, 'important');
3719        menu.style.setProperty('right', `${document.documentElement.clientWidth - rect.right}px`, 'important');
3720      }
3721
3722      for (const toggle of document.querySelectorAll('header.glass-nav [data-bs-toggle="dropdown"]')) {
3723        const menu = toggle.parentElement.querySelector('.dropdown-menu');
3724        if (!menu) continue;
3725        const home = menu.parentElement;
3726        pairs.push([menu, toggle]);
3727
3728        toggle.addEventListener('show.bs.dropdown', () => {
3729          if (!desktop.matches) return;
3730          document.body.appendChild(menu);
3731          menu.classList.add('dropdown-menu-detached');
3732          place(menu, toggle);
3733        });
3734
3735        toggle.addEventListener('hidden.bs.dropdown', () => {
3736          if (!menu.classList.contains('dropdown-menu-detached')) return;
3737          menu.classList.remove('dropdown-menu-detached');
3738          menu.style.removeProperty('top');
3739          menu.style.removeProperty('right');
3740          home.appendChild(menu);
3741        });
3742      }
3743
3744      window.addEventListener('resize', () => {
3745        for (const [menu, toggle] of pairs) {
3746          if (!menu.classList.contains('dropdown-menu-detached')) continue;
3747          // Crossing into the collapsed navbar mid-open: close it so it reattaches.
3748          if (desktop.matches) place(menu, toggle);
3749          else bootstrap.Dropdown.getInstance(toggle)?.hide();
3750        }
3751      });
3752    })();
3753  </script>
3753
3754  
3754<script>
3755    var beamer_config = {
3756      product_id: 'MzmSqYwo78692',
3757      user_id: "anonymous",
3758      selector: '#beamer-whats-new',
3759      display: "light"
3760    };
3761  </script>
3761
3762  
3762<script type="text/javascript" src="https://app.getbeamer.com/js/beamer-embed.js" defer="defer"></script>
3762
3763  
3763<script defer>
3764    /* =================== THEME =================== */
3765    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
3766    const storageKey = 'darkTheme';
3767    var isHome = false;
3768    // iOS fixed background causes scroll jank — use scroll attachment instead
3769    const _isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
3770      (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
3771    function setTheme(dark) {
3772      document.documentElement.classList.toggle('dark-theme', dark);
3773      document.body.classList.toggle('dark-theme', dark);
3774
3775      // No colors are hardcoded here. Toggling the `.dark-theme` class (above) swaps the
3776      // token values in :root / .dark-theme, and `html, body { background: var(--color-canvas) }`
3777      // repaints the page background. Single source of truth = the CSS tokens in style.css.
3778      const themeBtnIcon = dark ? 'fa-moon' : 'fa-sun';
3779      document.getElementById('theme_button').innerHTML = `<i class="fa-solid ${themeBtnIcon} fa-fw me-2" aria-hidden="true"></i><span class="d-lg-none">Toggle Theme</span>`;
3780      document.body.style.backgroundAttachment = '';
3781
3782
3783      // navbar / site‑icon color tweaks
3784      const navbar = document.getElementById('navbar_id');
3785      navbar.classList.toggle('navbar-dark', dark);
3786      navbar.classList.toggle('navbar-light', !dark);
3787
3788
3789      document.querySelectorAll('.site_icon').forEach(icon => {
3790        icon.src = dark ? '/static/logo.svg' : '/static/logo-grey.svg';
3791      });
3792
3793      // bulk toggle of dark‑theme utility class
3794      const selectors = ['.navbar-collapse', '.form-control', '.table', 'a', 'select', 'ul', '.card', '.dropdown-item', 'thead', 'footer', '.alert'];
3795      selectors.forEach(sel => document.querySelectorAll(sel).forEach(el => el.classList.toggle('dark-theme', dark)));
3796
3797      // Force mobile navbar theme-sync (hard-lock) - ONLY ON MOBILE
3798      if (window.innerWidth <= 991) {
3799        const collapse = document.querySelector('.navbar-collapse');
3800        if (collapse) {
3801          const bg = 'var(--color-bg)';
3802          const txt = 'var(--color-text)';
3803          collapse.style.setProperty('background', bg, 'important');
3804          collapse.querySelectorAll('.nav-link').forEach(link => {
3805            link.style.setProperty('color', txt, 'important');
3806          });
3807        }
3808      } else {
3809        // Reset on desktop
3810        const collapse = document.querySelector('.navbar-collapse');
3811        if (collapse) {
3812          collapse.style.removeProperty('background');
3813          collapse.querySelectorAll('.nav-link').forEach(link => {
3814            link.style.removeProperty('color');
3815          });
3816        }
3817      }
3818
3819      // google sign‑in buttons (if present)
3820      document.querySelectorAll('.google_sign_in').forEach(img => {
3821        const darkSrc = img.id === 'sign_in' ? '/static/sign_in_dark.png' : '/static/sign_up_dark.png';
3822        const lightSrc = img.id === 'sign_in' ? '/static/sign_in_light.png' : '/static/sign_up_light.png';
3823        img.src = dark ? darkSrc : lightSrc;
3824      });
3825      localStorage.setItem(storageKey, dark);
3826      if (typeof setThemeCallback === 'function') {
3827        setThemeCallback(dark);
3828      }
3829
3830      // Update Beamer theme on-the-fly
3831      if (window.Beamer && typeof Beamer.update === 'function') {
3832        Beamer.update({ display: dark ? 'dark' : 'light' });
3833      }
3834    }
3835
3836    function initTheme() {
3837      const stored = localStorage.getItem(storageKey);
3838      const dark = stored ? stored === 'true' : prefersDark.matches || !window.matchMedia; // default dark on small devices
3839
3840      setTheme(dark);
3841    }
3842
3843    function toggleDarkTheme() {
3844      setTheme(!document.body.classList.contains('dark-theme'));
3845    }
3846    function timeSince(time) {
3847      const current = new Date();
3848      const elapsed = (current - new Date(time)) / 1000; // seconds
3849      const secPerMinute = 60;
3850      const secPerHour = secPerMinute * 60;
3851      const secPerDay = secPerHour * 24;
3852      const secPerMonth = secPerDay * 30;
3853      const secPerYear = secPerDay * 365;
3854
3855      if (elapsed < secPerMinute) {
3856        return Math.floor(elapsed) === 1
3857          ? "1 second ago"
3858          : `${Math.floor(elapsed)} seconds ago`;
3859      } else if (elapsed < secPerHour) {
3860        return Math.floor(elapsed / 60) === 1
3861          ? "1 minute ago"
3862          : `${Math.floor(elapsed / 60)} minutes ago`;
3863      } else if (elapsed < secPerDay) {
3864        return Math.floor(elapsed / 3600) === 1
3865          ? "1 hour ago"
3866          : `${Math.floor(elapsed / 3600)} hours ago`;
3867      } else if (elapsed < secPerMonth) {
3868        return Math.floor(elapsed / 86400) === 1
3869          ? "~ 1 day ago"
3870          : `~ ${Math.floor(elapsed / 86400)} days ago`;
3871      } else if (elapsed < secPerYear) {
3872        return Math.floor(elapsed / 2592000) === 1
3873          ? "~ 1 month ago"
3874          : `~ ${Math.floor(elapsed / 2592000)} months ago`;
3875      } else {
3876        return Math.floor(elapsed / 31536000) === 1
3877          ? "~ 1 year ago"
3878          : `~ ${Math.floor(elapsed / 31536000)} years ago`;
3879      }
3880    }
3881
3882    /* =================== MINI-STATS =================== */
3883    let mini_stats_update_interval = null;
3884
3885    async function fetchMiniStats() {
3886      if (document.hidden) return;
3887      const url = "/mini_stats";
3888      let res;
3889      try { res = await fetch(url); } catch { return; }
3890      if (!res.ok) return;
3891
3892      const { last_clip, today_count } = await res.json();
3893
3894      // Update UI immediately on fetch
3895      updateMiniStats(last_clip, today_count);
3896
3897      if (mini_stats_update_interval) {
3898        clearInterval(mini_stats_update_interval);
3899      }
3900
3901      mini_stats_update_interval = setInterval(() => {
3902        updateMiniStats(last_clip, today_count);
3903      }, 5 * 60 * 1000); // 5 minutes
3904
3905      isFirstLoad = false;
3906      return { last_clip, today_count };
3907    }
3908
3909    function updateMiniStats(last_clip, today_count) {
3910      const todayEl = document.getElementById("today_count");
3911      const lastEl = document.getElementById("last_clip_time");
3912      const statsPill = document.getElementById("stats-pill");
3913
3914      if (todayEl) {
3915        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`;
3916      }
3917
3918      if (lastEl) {
3919        if (last_clip) {
3920          const ts = timeSince(last_clip.time);
3921          lastEl.textContent = `Latest Clip: ${ts}`;
3922        } else {
3923          lastEl.textContent = `Start the first clip!`;
3924        }
3925      }
3926
3927      // Update stats pill visibility if it exists on the page (home page)
3928      if (statsPill) {
3929        if (today_count > 0) {
3930          statsPill.style.setProperty("display", "inline-flex", "important");
3931        } else {
3932          statsPill.style.setProperty("display", "none", "important");
3933        }
3934      }
3935    }
3936
3937
3938    function showMembershipAlerts() {
3939      
3940
3941    
3942    
3943    
3944    }
3945    /* =================== ALERTS =================== */
3946    // `action` — optional { label, onClick } that renders a button inside the toast,
3947    // for offering an undo instead of making the user hunt for the reverse operation.
3948    function send_alert(message, level, href, duration = 5000, action = null) {
3949      // Ensure a default level if none is provided
3950      if (!level) {
3951        level = 'info';
3952      }
3953
3954      // Create or get the custom alerts container
3955      let customAlertsContainer = document.getElementById('custom-alerts-container');
3956      if (!customAlertsContainer) {
3957        customAlertsContainer = document.createElement('div');
3958        customAlertsContainer.id = 'custom-alerts-container';
3959        customAlertsContainer.setAttribute('role', 'status');
3960        customAlertsContainer.setAttribute('aria-live', 'polite');
3961        customAlertsContainer.setAttribute('aria-atomic', 'false');
3962        document.body.appendChild(customAlertsContainer);
3963      }
3964
3965      // Determine the icon based on the alert level
3966      let iconClass = '';
3967      switch (level) {
3968        case 'danger':
3969          iconClass = 'fa-solid fa-circle-exclamation';
3970          break;
3971        case 'success':
3972          iconClass = 'fa-solid fa-circle-check';
3973          break;
3974        case 'warning':
3975          iconClass = 'fa-solid fa-triangle-exclamation';
3976          break;
3977        case 'info':
3978          iconClass = 'fa-solid fa-circle-info';
3979          break;
3980        default:
3981          iconClass = 'fa-solid fa-bell';
3982          break;
3983      }
3984
3985      // Create the alert element
3986      const alertElement = document.createElement('div');
3987      alertElement.className = `alert custom-toast toast-${level} m-0`;
3988
3989      let messageBody = message;
3990      if (href) {
3991        messageBody = `<a href="${href}" class="alert-link text-decoration-none">${message}</a>`;
3992      }
3993
3994      alertElement.innerHTML = `
3995        <i class="${iconClass} toast-icon" aria-hidden="true"></i>
3996        <div class="toast-message">${messageBody}</div>
3997      `;
3998
3999      // Apply initial animation styles for entry
4000      alertElement.style.opacity = '0';
4001      alertElement.style.transform = 'translateY(20px)';
4002
4003      // Append the new alert to the custom container
4004      customAlertsContainer.appendChild(alertElement);
4005
4006      // Trigger the entry animation after appending
4007      setTimeout(() => {
4008        alertElement.style.opacity = '1';
4009        alertElement.style.transform = 'translateY(0) translateX(0)';
4010      }, 10);
4011
4012      const dismissAlert = () => {
4013        alertElement.style.opacity = '0';
4014        alertElement.style.transform = 'scale(0.9)';
4015        setTimeout(() => {
4016          if (alertElement.parentNode) {
4017            alertElement.parentNode.removeChild(alertElement);
4018          }
4019        }, 400); // Wait for transition
4020      };
4021
4022      let dismissTimeout;
4023
4024      const startDismissTimer = () => {
4025        dismissTimeout = setTimeout(() => {
4026          dismissAlert();
4027        }, duration);
4028      };
4029
4030      startDismissTimer();
4031
4032      if (action && action.label) {
4033        const actionBtn = document.createElement('button');
4034        actionBtn.type = 'button';
4035        actionBtn.className = 'toast-action';
4036        actionBtn.textContent = action.label;
4037        actionBtn.addEventListener('click', () => {
4038          clearTimeout(dismissTimeout);
4039          dismissAlert();
4040          if (typeof action.onClick === 'function') action.onClick();
4041        });
4042        alertElement.appendChild(actionBtn);
4043      }
4044
4045      // Desktop hover pause
4046      alertElement.addEventListener('mouseenter', () => {
4047        clearTimeout(dismissTimeout);
4048        alertElement.style.opacity = '1';
4049        alertElement.style.transform = 'translateY(0) translateX(0)';
4050      });
4051
4052      alertElement.addEventListener('mouseleave', () => {
4053        startDismissTimer();
4054      });
4055
4056      // Swipe to dismiss logic for mobile
4057      let startX = 0;
4058      let currentX = 0;
4059
4060      alertElement.addEventListener('touchstart', (e) => {
4061        startX = e.touches[0].clientX;
4062        alertElement.style.transition = 'none'; // Disable transition for 1:1 finger tracking
4063        clearTimeout(dismissTimeout);
4064      }, { passive: true });
4065
4066      alertElement.addEventListener('touchmove', (e) => {
4067        currentX = e.touches[0].clientX;
4068        const diffX = currentX - startX;
4069        // Move the toast with finger
4070        alertElement.style.transform = `translateX(${diffX}px)`;
4071        // Fade out slightly as it moves away
4072        alertElement.style.opacity = Math.max(0, 1 - Math.abs(diffX) / window.innerWidth);
4073      }, { passive: true });
4074
4075      alertElement.addEventListener('touchend', (e) => {
4076        const diffX = currentX - startX;
4077        // Restore transition
4078        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)';
4079
4080        if (Math.abs(diffX) > 80) {
4081          // Swipe threshold reached, fly off screen and dismiss
4082          const direction = diffX > 0 ? 1 : -1;
4083          alertElement.style.transform = `translateX(${direction * window.innerWidth}px)`;
4084          alertElement.style.opacity = '0';
4085          setTimeout(() => {
4086            if (alertElement.parentNode) {
4087              alertElement.parentNode.removeChild(alertElement);
4088            }
4089          }, 400);
4090        } else {
4091          // Snap back
4092          alertElement.style.transform = 'translateY(0) translateX(0)';
4093          alertElement.style.opacity = '1';
4094          startDismissTimer(); // Resume dismiss timer
4095        }
4096      });
4097    }
4098
4099
4100    /* =================== INIT =================== */
4101    document.addEventListener('DOMContentLoaded', () => {
4102      initTheme();
4103      fetchMiniStats();
4104      let miniStatsTimer = setInterval(fetchMiniStats, 60_000);
4105
4106      document.addEventListener('visibilitychange', () => {
4107        if (document.hidden) {
4108          clearInterval(miniStatsTimer);
4109          miniStatsTimer = null;
4110        } else {
4111          fetchMiniStats();
4112          miniStatsTimer = miniStatsTimer || setInterval(fetchMiniStats, 60_000);
4113        }
4114      });
4115      if (typeof showMembershipAlerts === 'function') {
4116        showMembershipAlerts();
4117      }
4118
4119
4120
4121      // Navbar logo hover clap
4122      (function() {
4123        var wrap  = document.getElementById('site_icon');
4124        var svg   = document.getElementById('_nbl_svg');
4125        var stick = document.getElementById('_nbl_stick');
4126        if (!wrap || !svg || !stick) return;
4127        var busy = false;
4128        wrap.addEventListener('mouseenter', function() {
4129          if (busy) return;
4130          busy = true;
4131          stick.classList.add('_nbl_play');
4132          svg.classList.add('_nbl_play');
4133          setTimeout(function() {
4134            stick.classList.remove('_nbl_play');
4135            svg.classList.remove('_nbl_play');
4136            busy = false;
4137          }, 2600);
4138        });
4139      })();
4140
4141      // Auto-collapse burger menu on outside click
4142      document.addEventListener('click', function (event) {
4143        const navbarCollapse = document.getElementById('navbarSupportedContent');
4144        const navbarToggler = document.querySelector('.navbar-toggler');
4145        if (navbarCollapse && navbarCollapse.classList.contains('show')) {
4146          if (!navbarCollapse.contains(event.target) && !navbarToggler.contains(event.target)) {
4147            new bootstrap.Collapse(navbarCollapse).hide();
4148          }
4149        }
4150      });
4151
4152      // Service Worker Registration for PWA
4153      if ('serviceWorker' in navigator) {
4154        window.addEventListener('load', () => {
4155          navigator.serviceWorker.register('/static/sw.js')
4156            .then(reg => console.log('[PWA] Service Worker registered:', reg.scope))
4157            .catch(err => console.log('[PWA] Service Worker registration failed:', err));
4158        });
4159      }
4160    });
4161
4162  </script>
4162
4163  
4164    
4164<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
4164
4165    
4165<script src="/static/js/clip-demo.js?v=dec7a5a9cf" defer></script>
4165
4166    
4167  
4168    
4168<script>
4169        let page = 1;
4170        let hasMore = true;
4171        let loading = false;
4172        let inverse = false;
4173        let firstLoad = true;
4174        let debounceTimer;
4175        let pendingReload = false; // queued while a fetch is in-flight
4176
4177        const GAME_CANONICAL_MAPPING = {
4178            "lies of p": "Lies of P",
4179            "minecraft": "Minecraft",
4180            "pubg mobile": "PUBG Mobile",
4181            "battlegrounds mobile india": "PUBG Mobile",
4182            "bgmi": "PUBG Mobile",
4183            "counter-strike 2": "Counter-Strike",
4184            "counter-strike: global offensive": "Counter-Strike",
4185            "grand theft auto v": "GTA V",
4186            "gta 5": "GTA V",
4187        };
4188
4189        function get_canonical_game_name(game_name) {
4190            if (!game_name) return game_name;
4191            const low_name = game_name.toLowerCase().trim();
4192            if (GAME_CANONICAL_MAPPING[low_name]) return GAME_CANONICAL_MAPPING[low_name];
4193            if (low_name.includes(":")) {
4194                const base = low_name.split(":")[0].trim();
4195                if (GAME_CANONICAL_MAPPING[base]) return GAME_CANONICAL_MAPPING[base];
4196            }
4197            return game_name;
4198        }
4199
4200        const container = document.getElementById("clips-container");
4201        const pageWrapper = document.getElementById("loading-line");
4202        const heroLoader = document.getElementById("hero-loader");
4203
4204        window.delayLoader = false;
4205
4206        function getSortBy() { return document.getElementById("sortby").value; }
4207        function getSearch() { return document.getElementById("search").value.trim(); }
4208        function getGame() { return document.getElementById("game-filter").value; }
4209
4210        function scrollCarouselToCard(container, card, behavior = 'smooth') {
4211            if (!container || !card) return;
4212            const left = card.offsetLeft - (container.clientWidth / 2) + (card.clientWidth / 2);
4213            container.scrollTo({ left: left, behavior: behavior });
4214        }
4215
4216        async function setGame(gameName, fromDropdown = false) {
4217            const current = getGame();
4218            let target = (gameName || "").trim();
4219            if (target.toLowerCase() === "all games") {
4220                target = "";
4221            }
4222
4223            // If clicking the same card, reset to "All"
4224            if (!fromDropdown && current === target && target !== "") {
4225                target = "";
4226            }
4227
4228            const filterEl = document.getElementById("game-filter");
4229            if (filterEl) {
4230                const targetClean = target.toLowerCase();
4231                const matchedOpt = [...filterEl.options].find(opt => opt.value.trim().toLowerCase() === targetClean);
4232                if (matchedOpt) {
4233                    filterEl.value = matchedOpt.value;
4234                } else {
4235                    filterEl.value = target;
4236                }
4237                if (filterEl.customDropdown) {
4238                    filterEl.customDropdown.updateLabel();
4239                    filterEl.customDropdown.syncSelectedClass();
4240                }
4241            }
4242            reload();
4243
4244            // Carousel Sync
4245            const container = document.getElementById("game-cards");
4246            if (!container) return;
4247
4248            if (target === "") {
4249                const allCard = container.querySelector('.game-card.all-card');
4250                if (allCard) {
4251                    container.querySelectorAll('.game-card').forEach(c => c.classList.remove('active'));
4252                    allCard.classList.add('active');
4253                    scrollCarouselToCard(container, allCard);
4254                }
4255                return;
4256            }
4257
4258            // Search for card, fetch more if not found
4259            const findCard = () => [...container.querySelectorAll('.game-card')].find(c => (c.getAttribute('data-game') || "").trim().toLowerCase() === target.toLowerCase());
4260            let card = findCard();
4261            let safety = 0;
4262            while (!card && hasMoreGames && safety < 50) { // Safety limit of 50 pages
4263                await fetchGames();
4264                card = findCard();
4265                safety++;
4266            }
4267
4268            if (card) {
4269                container.querySelectorAll('.game-card').forEach(c => c.classList.remove('active'));
4270                card.classList.add('active');
4271                scrollCarouselToCard(container, card);
4272            }
4273        }
4274        window.setGame = setGame; // Ensure it's globally accessible for onchange
4275
4276
4277        function setCookie(name, value, days) {
4278            const d = new Date();
4279            d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
4280            const expires = "expires=" + d.toUTCString();
4281            document.cookie = name + "=" + value + ";" + expires + ";path=/";
4282        }
4283
4284        function getCookie(name) {
4285            const nameEQ = name + "=";
4286            const ca = document.cookie.split(';');
4287            for (let i = 0; i < ca.length; i++) {
4288                let c = ca[i];
4289                while (c.charAt(0) == ' ') c = c.substring(1, c.length);
4290                if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
4291            }
4292            return null;
4293        }
4294
4295        function syncURL() {
4296            const url = new URL(window.location);
4297            const q = getSearch();
4298            const s = getSortBy();
4299            const g = getGame();
4300            q ? url.searchParams.set("q", q) : url.searchParams.delete("q");
4301            s !== "default" ? url.searchParams.set("sortby", s) : url.searchParams.delete("sortby");
4302            g ? url.searchParams.set("game", g) : url.searchParams.delete("game");
4303            inverse ? url.searchParams.set("inverse", "true") : url.searchParams.delete("inverse");
4304
4305            // Persist sortby in cookies    
4306            setCookie('sortby', s, 365);
4307            setCookie('inverse', inverse ? "true" : "false", 365);
4308
4309            window.history.replaceState({}, "", url);
4310        }
4311
4312        function lockControls() {
4313            // Never disable search — users must be able to keep typing freely
4314            document.getElementById("sortby").setAttribute("disabled", "");
4315            document.getElementById("searchnsort").classList.add("searching-pulse");
4316        }
4317
4318        function unlockControls() {
4319            document.getElementById("sortby").removeAttribute("disabled");
4320            document.getElementById("searchnsort").classList.remove("searching-pulse");
4321            // If the user typed a new query while the fetch was in-flight, run it now
4322            if (pendingReload) { pendingReload = false; reload(); }
4323        }
4324
4325        function cardHTML(ch) {
4326            const currentGame = getGame();
4327            const exportLink = currentGame ? `${ch.link}?g=${encodeURIComponent(currentGame)}` : ch.link;
4328            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>' : "";
4329            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>' : "";
4330            const title = ch.deleted ? `DELETED CHANNEL (${ch.id})` : ch.name;
4331            const tooltip = `Last Clip - ${ch.last_clip.timesince}\n${ch.sub_count} Subscribers\nFirst Clip - ${ch.first_clip_timesince}\n${ch.clip_count} Clips`;
4332
4333            const premiumClass = ch.is_premium ? ' premium-glow' : '';
4334
4335            return `<div class="col-6 col-md-4 col-xl-2 mb-4">
4336            <a href="${exportLink}" title="${tooltip}" class="d-block h-100${premiumClass}" onclick="this.querySelector('.card-img-top').style.viewTransitionName='channel-hero'">
4337                <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative hover-lift no-hover-effect">
4338                    ${pinIcon}${premiumIcon}
4339                    <div class="position-relative h-100 overflow-hidden">
4340                        <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"
4341                             onerror="this.src='https://i.pinimg.com/564x/3b/27/a8/3b27a87fcf7d90ae564be23d7a37f778.jpg'">
4342                        <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));">
4343                            <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>
4344                        </div>
4345                    </div>
4346                </div>
4347            </a>
4348        </div>`;
4349        }
4350
4351        async function fetchPage(reset = false) {
4352            if (!hasMore && !reset) return;
4353            if (loading) {
4354                // Don't stack fetches — remember to re-run once the current one settles
4355                if (reset) pendingReload = true;
4356                return;
4357            }
4358
4359            loading = true;
4360            lockControls();
4361
4362            if (reset) {
4363                page = 1;
4364                hasMore = true;
4365                pageWrapper.classList.add("bouncing-gradient-rgb");
4366                if (heroLoader) heroLoader.classList.add("active");
4367            } else if (infiniteLoader) {
4368                infiniteLoader.style.opacity = "1";
4369            }
4370
4371            // Add a small delay for smoother transitions
4372            await new Promise(r => setTimeout(r, 400));
4373
4374            const params = new URLSearchParams({
4375                page, limit: 50, sortby: getSortBy(), inverse: inverse ? "true" : "false",
4376            });
4377            const q = getSearch();
4378            if (q) params.set("q", q);
4379            const g = getGame();
4380            if (g) params.set("game", g);
4381
4382            // Show Skeletons on Reset
4383            if (reset) {
4384                const skeletons = Array(12).fill(0).map(() => `
4385                <div class="col-6 col-md-4 col-xl-2 mb-4 skeleton-wrapper">
4386                    <div class="card h-100 border-0 shadow-sm overflow-hidden position-relative">
4387                        <div class="position-relative h-100 overflow-hidden">
4388                            <div class="skeleton" style="aspect-ratio: 1/1; width: 100%;"></div>
4389                            <div class="position-absolute bottom-0 start-0 end-0 p-2 text-center" style="z-index: 5;">
4390                                <div class="skeleton skeleton-text" style="height: 1.32rem; width: 70%; margin-bottom: 0;"></div>
4391                            </div>
4392                        </div>
4393                    </div>
4394                </div>
4395            `).join("");
4396                container.innerHTML = skeletons;
4397            }
4398
4399            try {
4400                const res = await fetch(`/api/channels?${params}`);
4401                const data = await res.json();
4402
4403                hasMore = data.has_more;
4404                page = data.page + 1;
4405
4406                if (reset) {
4407                    container.innerHTML = "";
4408                    // Skeletons are already there, we replace them
4409                }
4410                container.insertAdjacentHTML("beforeend", data.channels.map(cardHTML).join(""));
4411
4412            } catch (e) {
4413                console.error("fetchPage error:", e);
4414            } finally {
4415                loading = false;
4416                unlockControls();
4417
4418                if (pageWrapper) pageWrapper.classList.remove("bouncing-gradient-rgb");
4419                if (heroLoader) heroLoader.classList.remove("active");
4420                if (infiniteLoader) infiniteLoader.style.opacity = "0";
4421
4422                if (reset) {
4423                    document.body.classList.add('loaded');
4424                }
4425            }
4426        }
4427
4428        function reload() { syncURL(); fetchPage(true); }
4429        function toggleInverse() {
4430            inverse = !inverse;
4431            document.getElementById("invert-btn").classList.toggle("active", inverse);
4432            reload();
4433        }
4434
4435
4436
4437        async function restoreFromURL() {
4438            const url = new URL(window.location);
4439            const q = url.searchParams.get("q") || "";
4440            const defaultSort = "sub_count";
4441            const sortby = url.searchParams.get("sortby") || getCookie('sortby') || defaultSort;
4442            const rawGame = url.searchParams.get("game") || "";
4443            const game = get_canonical_game_name(rawGame);
4444            inverse = url.searchParams.get("inverse") === "true";
4445
4446            document.getElementById("invert-btn").classList.toggle("active", inverse);
4447            document.getElementById("search").value = q;
4448            document.getElementById("sortby").value = sortby;
4449
4450            if (game) {
4451                const filter = document.getElementById("game-filter");
4452                const gameClean = game.trim();
4453                if (gameClean.toLowerCase() !== "all games" && ![...filter.options].some(opt => opt.value.trim().toLowerCase() === gameClean.toLowerCase())) {
4454                    const opt = document.createElement("option");
4455                    opt.value = gameClean;
4456                    opt.textContent = gameClean;
4457                    filter.appendChild(opt);
4458                    if (filter.customDropdown) {
4459                        filter.customDropdown.renderOptions();
4460                        filter.customDropdown.updateLabel();
4461                    }
4462                }
4463                await setGame(game, true);
4464            } else {
4465                const filterEl = document.getElementById("game-filter");
4466                if (filterEl) {
4467                    filterEl.value = "";
4468                    if (filterEl.customDropdown) {
4469                        filterEl.customDropdown.updateLabel();
4470                        filterEl.customDropdown.syncSelectedClass();
4471                    }
4472                }
4473            }
4474        }
4475
4476        window.addEventListener("popstate", () => { restoreFromURL(); fetchPage(true); });
4477
4478        /* ── Hero Scene Manager: 25 Premium Triple-Digit Aesthetics ── */
4479        let currentHeroScene = null;
4480        const sceneRegistry = {};
4481
4482        function registerScene(name, config) {
4483            sceneRegistry[name] = config;
4484        }
4485
4486        /* ── Scene Definitions ── */
4487
4488        registerScene('Kinetic Shard', {
4489            init: (scene, camera, color) => {
4490                const geo = new THREE.IcosahedronGeometry(8, 0);
4491                const mat = new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.1, shininess: 100, flatShading: true });
4492                const shard = new THREE.Mesh(geo, mat);
4493                const wire = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color, transparent: true, op
4493acity: 0.3 }));
4494                shard.add(wire);
4495                scene.add(shard);
4496                camera.position.z = 25;
4497                const dl = new THREE.DirectionalLight(0xffffff, 1.2);
4498                dl.position.set(10, 10, 10); scene.add(dl);
4499                const pl = new THREE.PointLight(0xffffff, 0.8);
4500                pl.position.set(-10, -10, 5); scene.add(pl);
4501                scene.add(new THREE.AmbientLight(0xffffff, 0.4));
4502                return { shard, mat, wire };
4503            },
4504            update: (data, time, mouse) => {
4505                data.shard.rotation.y += 0.015;
4506                data.shard.rotation.x += 0.008;
4507                const pulse = 1 + Math.sin(time * 1.5) * 0.1;
4508                data.shard.scale.setScalar(pulse);
4509            }
4510        });
4511
4512        registerScene('Cosmic Flow', {
4513            init: (scene, camera, color) => {
4514                const count = 3000;
4515                const pos = new Float32Array(count * 3);
4516                for (let i = 0; i < count; i++) {
4517                    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;
4518                }
4519                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4520                const mat = new THREE.PointsMaterial({ color, size: 0.05, transparent: true, opacity: 0.6 });
4521                const points = new THREE.Points(geo, mat); scene.add(points);
4522                camera.position.z = 30;
4523                return { points, mat, geo, count };
4524            },
4525            update: (data, time) => {
4526                const p = data.geo.attributes.position.array;
4527                for (let i = 0; i < data.count; i++) {
4528                    p[i * 3] += Math.sin(time + p[i * 3 + 1] * 0.1) * 0.02;
4529                    p[i * 3 + 1] += Math.cos(time + p[i * 3] * 0.1) * 0.02;
4530                }
4531                data.geo.attributes.position.needsUpdate = true;
4532            }
4533        });
4534
4535        registerScene('Neural Plexus', {
4536            init: (scene, camera, color) => {
4537                const count = 100;
4538                const pos = new Float32Array(count * 3);
4539                const vels = new Float32Array(count * 3);
4540                for (let i = 0; i < count; i++) {
4541                    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;
4542                    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;
4543                }
4544                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4545                const mat = new THREE.PointsMaterial({ color, size: 0.1 });
4546                const points = new THREE.Points(geo, mat); scene.add(points);
4547                const lineMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.2 });
4548                const lineGeo = new THREE.BufferGeometry();
4549                lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(600), 3));
4550                const lines = new THREE.LineSegments(lineGeo, lineMat); scene.add(lines);
4551                camera.position.z = 25;
4552                return { points, geo, vels, count, lines, lineMat, lineGeo };
4553            },
4554            update: (data) => {
4555                const p = data.geo.attributes.position.array;
4556                for (let i = 0; i < data.count; i++) {
4557                    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];
4558                    if (Math.abs(p[i * 3]) > 20) data.vels[i * 3] *= -1;
4559                    if (Math.abs(p[i * 3 + 1]) > 15) data.vels[i * 3 + 1] *= -1;
4560                }
4561                data.geo.attributes.position.needsUpdate = true;
4562            }
4563        });
4564
4565        registerScene('Cyber Grid', {
4566            init: (scene, camera, color) => {
4567                const grid = new THREE.GridHelper(100, 40, color, color);
4568                grid.position.y = -5; grid.material.transparent = true; grid.material.opacity = 0.3;
4569                scene.add(grid);
4570                camera.position.z = 15;
4571                return { grid };
4572            },
4573            update: (data, time) => { data.grid.position.z = (time * 2) % 2.5; }
4574        });
4575
4576        registerScene('Glass Cubes', {
4577            init: (scene, camera, color) => {
4578                const cubes = [];
4579                for (let i = 0; i < 15; i++) {
4580                    const m = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshPhongMaterial({ color, transparent: true, op
4580acity: 0.1 }));
4581                    m.position.set((Math.random() - 0.5) * 30, (Math.random() - 0.5) * 20, (Math.random() - 0.5) * 15);
4582                    scene.add(m); cubes.push(m);
4583                }
4584                scene.add(new THREE.AmbientLight(0xffffff, 0.5));
4585                camera.position.z = 25;
4586                return { cubes, mat: cubes[0].material };
4587            },
4588            update: (data) => { data.cubes.forEach(c => { c.rotation.x += 0.01; c.rotation.y += 0.01; }); }
4589        });
4590
4591        registerScene('Aura Waves', {
4592            init: (scene, camera, color) => {
4593                const geo = new THREE.PlaneGeometry(60, 40, 20, 20);
4594                const mat = new THREE.MeshPhongMaterial({ color, wireframe: true, transparent: true, opacity: 0.15 });
4595                const plane = new THREE.Mesh(geo, mat); plane.rotation.x = -Math.PI * 0.3;
4596                scene.add(plane); camera.position.z = 30;
4597                return { geo, mat };
4598            },
4599            update: (data, time) => {
4600                const p = data.geo.attributes.position.array;
4601                for (let i = 0; i < p.length; i += 3) p[i + 2] = Math.sin(p[i] * 0.2 + time) * 2;
4602                data.geo.attributes.position.needsUpdate = true;
4603            }
4604        });
4605
4606        registerScene('Galaxy Spiral', {
4607            init: (scene, camera, color) => {
4608                const count = 3000;
4609                const pos = new Float32Array(count * 3);
4610                for (let i = 0; i < count; i++) {
4611                    const r = Math.random() * 20, a = r * 0.5 + (i % 3) * (Math.PI * 2 / 3);
4612                    pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = (Math.random() - 0.5) * 2; pos[i * 3 + 2] = Math.sin(a) * r;
4613                }
4614                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4615                const mat = new THREE.PointsMaterial({ color, size: 0.05, transparent: true, opacity: 0.6 });
4616                const g = new THREE.Points(geo, mat); g.rotation.x = 0.5;
4617                scene.add(g); camera.position.z = 25;
4618                return { g, mat };
4619            },
4620            update: (data) => { data.g.rotation.y += 0.002; }
4621        });
4622
4623        registerScene('Octa Swarm', {
4624            init: (scene, camera, color) => {
4625                const s = [];
4626                for (let i = 0; i < 20; i++) {
4627                    const m = new THREE.Mesh(new THREE.OctahedronGeometry(1), new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.2 }));
4628                    m.position.set((Math.random() - 0.5) * 40, (Math.random() - 0.5) * 30, (Math.random() - 0.5) * 20);
4629                    scene.add(m); s.push(m);
4630                }
4631                scene.add(new THREE.AmbientLight(0xffffff, 0.5));
4632                camera.position.z = 25;
4633                return { s, mat: s[0].material };
4634            },
4635            update: (data) => { data.s.forEach(m => { m.rotation.y += 0.01; m.position.y += Math.sin(performance.now() * 0.001) * 0.01; }); }
4636        });
4637
4638        registerScene('Atomic Gimbal', {
4639            init: (scene, camera, color) => {
4640                const rings = [];
4641                for (let i = 0; i < 3; i++) {
4642                    const r = new THREE.Mesh(new THREE.TorusGeometry(8 + i * 4, 0.05, 16, 100), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.4 }));
4643                    r.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
4644                    scene.add(r); rings.push(r);
4645                }
4646                camera.position.z = 35;
4647                return { rings };
4648            },
4649            update: (data) => { data.rings.forEach(r => { r.rotation.x += 0.01; r.rotation.y += 0.005; }); }
4650        });
4651
4652        registerScene('Hyper Jump', {
4653            init: (scene, camera, color) => {
4654                const count = 1000;
4655                const pos = new Float32Array(count * 3);
4656                for (let i = 0; i < count; i++) {
4657                    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;
4658                }
4659                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4660                const mat = new THREE.PointsMaterial({ color, size: 0.05, transparent: true, op
4660acity: 0.8 });
4661                const s = new THREE.Points(geo, mat); scene.add(s);
4662                camera.position.z = 5;
4663                return { geo, count, mat };
4664            },
4665            update: (data) => {
4666                const p = data.geo.attributes.position.array;
4667                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; }
4668                data.geo.attributes.position.needsUpdate = true;
4669            }
4670        });
4671
4672        registerScene('DNA Helix', {
4673            init: (scene, camera, color) => {
4674                const g1 = new THREE.Group(), g2 = new THREE.Group();
4675                for (let i = 0; i < 60; i++) {
4676                    const y = (i - 30) * 0.4, a = i * 0.3;
4677                    const s1 = new THREE.Mesh(new THREE.SphereGeometry(0.2), new THREE.MeshBasicMaterial({ color }));
4678                    s1.position.set(Math.cos(a) * 3, y, Math.sin(a) * 3); g1.add(s1);
4679                    const s2 = s1.clone(); s2.position.set(Math.cos(a + Math.PI) * 3, y, Math.sin(a + Math.PI) * 3); g2.add(s2);
4680                }
4681                scene.add(g1); scene.add(g2); camera.position.z = 30;
4682                return { g1, g2 };
4683            },
4684            update: (data) => { data.g1.rotation.y += 0.02; data.g2.rotation.y += 0.02; }
4685        });
4686
4687        registerScene('Digital Rain', {
4688            init: (scene, camera, color) => {
4689                const count = 1500;
4690                const pos = new Float32Array(count * 3);
4691                for (let i = 0; i < count; i++) {
4692                    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;
4693                }
4694                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4695                const mat = new THREE.PointsMaterial({ color, size: 0.1, transparent: true, opacity: 0.6 });
4696                const r = new THREE.Points(geo, mat); scene.add(r); camera.position.z = 25;
4697                return { geo, count, mat };
4698            },
4699            update: (data) => {
4700                const p = data.geo.attributes.position.array;
4701                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; }
4702                data.geo.attributes.position.needsUpdate = true;
4703            }
4704        });
4705
4706        registerScene('Floating Prisms', {
4707            init: (scene, camera, color) => {
4708                const p = [];
4709                for (let i = 0; i < 15; i++) {
4710                    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 }));
4711                    const r = 10 + Math.random() * 20;
4712                    const a = Math.random() * Math.PI * 2;
4713                    m.position.set(Math.cos(a) * r, (Math.random() - 0.5) * 20, Math.sin(a) * r);
4714                    scene.add(m); p.push({ m, r, a, s: 0.005 + Math.random() * 0.01 });
4715                }
4716                const pl = new THREE.PointLight(0xffffff, 1);
4717                pl.position.set(5, 5, 5); scene.add(pl);
4718                camera.position.z = 35;
4719                return { p };
4720            },
4721            update: (data, time) => {
4722                data.p.forEach(o => {
4723                    o.a += o.s;
4724                    o.m.position.x = Math.cos(o.a) * o.r;
4725                    o.m.position.z = Math.sin(o.a) * o.r;
4726                    o.m.rotation.y += 0.02;
4727                    o.m.position.y += Math.sin(time + o.r) * 0.05;
4728                });
4729            }
4730        });
4731
4732        registerScene('Abstract Bokeh', {
4733            init: (scene, camera, color) => {
4734                const b = [];
4735                for (let i = 0; i < 8; i++) {
4736                    const m = new THREE.Mesh(new THREE.SphereGeometry(Math.random() * 4 + 2, 32, 32), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.05 }));
4737                    m.position.set((Math.random() - 0.5) * 40, (Math.random() - 0.5) * 30, (Math.random() - 0.5) * 20);
4738                    scene.add(m); b.push({ m, v: new THREE.Vector3((Math.random() - 0.5) * 0.01, (Math.random() - 0.5) * 0.01, 0) });
4739                }
4740                camera.position.z = 30;
4741                return { b };
4742            },
4743            update: (data, time) => {
4744                data.b.forEach((o, i) => {
4745                    o.m.position.add(o.v);
4746                    if (Math.abs(o.m.position.x) > 25) o.v.x *= -1;
4747                    if (Math.abs(o.m.position.y) > 20) o.v.y *= -1;
4748                    o.m.material.opacity = 0.05 + Math.sin(time + i) * 0.03;
4749                });
4750            }
4751        });
4752
4753        registerScene('Neon Horizon', {
4754            init: (scene, camera, color) => {
4755                const g = new THREE.GridHelper(100, 20, color, color);
4756                g.rotation.x = Math.PI * 0.5; g.position.z = -10;
4757                scene.add(g); camera.position.y = 2; camera.position.z = 10;
4758                return { g };
4759            },
4760            update: (data) => { data.g.position.y = (data.g.position.y - 0.05) % 5; }
4761        });
4762
4763        registerScene('Static Noise', {
4764            init: (scene, camera, color) => {
4765                const count = 5000;
4766                const pos = new Float32Array(count * 3);
4767                for (let i = 0; i < count; i++) {
4768                    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;
4769                }
4770                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4771                const mat = new THREE.PointsMaterial({ color, size: 0.01 });
4772                const d = new THREE.Points(geo, mat); scene.add(d); camera.position.z = 25;
4773                return { geo, count, mat };
4774            },
4775            update: (data) => {
4776                const p = data.geo.attributes.position.array;
4777                for (let i = 0; i < data.count * 3; i++) p[i] += (Math.random() - 0.5) * 0.3;
4778                data.geo.attributes.position.needsUpdate = true;
4779            }
4780        });
4781
4782        registerScene('Pulse Field', {
4783            init: (scene, camera, color) => {
4784                const count = 400;
4785                const pos = new Float32Array(count * 3);
4786                for (let i = 0; i < count; i++) {
4787                    const a = Math.random() * Math.PI * 2, r = 5 + Math.random() * 15;
4788                    pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = Math.sin(a) * r; pos[i * 3 + 2] = (Math.random() - 0.5) * 10;
4789                }
4790                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4791                const mat = new THREE.PointsMaterial({ color, size: 0.1 });
4792                const p = new THREE.Points(geo, mat); scene.add(p); camera.position.z = 30;
4793                return { p, mat };
4794            },
4795            update: (data, time) => { data.p.scale.setScalar(1 + Math.sin(time) * 0.2); }
4796        });
4797
4798        registerScene('Torus Knot', {
4799            init: (scene, camera, color) => {
4800                const g = new THREE.TorusKnotGeometry(6, 1.2, 100, 16);
4801                const m = new THREE.Mesh(g, new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.15, shininess: 100 }));
4802                scene.add(m);
4803                const pl = new THREE.PointLight(0xffffff, 1);
4804                pl.position.set(10, 10, 10); scene.add(pl);
4805                camera.position.z = 25; return { m, mat: m.material };
4806            },
4807            update: (data, time) => {
4808                data.m.rotation.x += 0.015;
4809                data.m.rotation.y += 0.02;
4810                data.m.rotation.z += 0.01;
4811                const s = 1 + Math.sin(time * 0.8) * 0.15;
4812                data.m.scale.setScalar(s);
4813            }
4814        });
4815
4816        registerScene('The Shimmer', {
4817            init: (scene, camera, color) => {
4818                const count = 2000;
4819                const pos = new Float32Array(count * 3);
4820                for (let i = 0; i < count; i++) {
4821                    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;
4822                }
4823                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4824                const mat = new THREE.PointsMaterial({ color, size: 0.05, transparent: true, opacity: 0.4 });
4825                const p = new THREE.Points(geo, mat); scene.add(p); camera.position.z = 25;
4826                return { mat };
4827            },
4828            update: (data, time) => { data.mat.opacity = 0.2 + Math.sin(time * 2) * 0.2; }
4829        });
4830
4831        registerScene('Spiral Vortex', {
4832            init: (scene, camera, color) => {
4833                const count = 1500;
4834                const pos = new Float32Array(count * 3);
4835                for (let i = 0; i < count; i++) {
4836                    const r = 5 + Math.random() * 20, a = Math.random() * Math.PI * 2;
4837                    pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = Math.sin(a) * r; pos[i * 3 + 2] = (Math.random() - 0.5) * 10;
4838                }
4839                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4840                const mat = new THREE.PointsMaterial({ color, size: 0.05 });
4841                const v = new THREE.Points(geo, mat); scene.add(v); camera.position.z = 30;
4842                return { v, mat };
4843            },
4844            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); }
4845        });
4846
4847        registerScene('Geometric Portal', {
4848            init: (scene, camera, color) => {
4849                const g = new THREE.Group();
4850                for (let i = 0; i < 15; i++) {
4851                    const m = new THREE.Mesh(new THREE.RingGeometry(i * 2.5, i * 2.5 + 0.1, 32), new THREE.MeshBasicMaterial({ color, transparent: true, op
4851acity: 1 - i / 15 }));
4852                    m.position.z = -i * 6; g.add(m);
4853                }
4854                scene.add(g); camera.position.z = 5; return { g };
4855            },
4856            update: (data) => { data.g.children.forEach(c => { c.position.z += 0.15; if (c.position.z > 5) c.position.z = -85; }); }
4857        });
4858
4859        registerScene('Floating Gems', {
4860            init: (scene, camera, color) => {
4861                const g = [];
4862                for (let i = 0; i < 15; i++) {
4863                    const m = new THREE.Mesh(new THREE.DodecahedronGeometry(1.5), new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.2, shininess: 100 }));
4864                    m.position.set((Math.random() - 0.5) * 35, (Math.random() - 0.5) * 25, (Math.random() - 0.5) * 15);
4865                    scene.add(m); g.push({ m, offset: Math.random() * Math.PI * 2, speed: 0.01 + Math.random() * 0.02 });
4866                }
4867                const pl = new THREE.PointLight(0xffffff, 1.2);
4868                pl.position.set(0, 10, 10); scene.add(pl);
4869                camera.position.z = 30; return { g };
4870            },
4871            update: (data, time) => {
4872                data.g.forEach(o => {
4873                    o.m.rotation.y += o.speed;
4874                    o.m.rotation.x += o.speed * 0.5;
4875                    o.m.position.y += Math.sin(time + o.offset) * 0.03;
4876                });
4877            }
4878        });
4879
4880        registerScene('The Matrix', {
4881            init: (scene, camera, color) => {
4882                const count = 1500;
4883                const pos = new Float32Array(count * 3);
4884                for (let i = 0; i < count; i++) {
4885                    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;
4886                }
4887                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4888                const mat = new THREE.PointsMaterial({ color, size: 0.08, transparent: true, opacity: 0.8 });
4889                const p = new THREE.Points(geo, mat); scene.add(p); camera.position.z = 25;
4890                return { geo, count, mat };
4891            },
4892            update: (data) => {
4893                const p = data.geo.attributes.position.array;
4894                for (let i = 0; i < data.count; i++) {
4895                    p[i * 3 + 1] -= (0.2 + Math.random() * 0.3);
4896                    if (p[i * 3 + 1] < -20) {
4897                        p[i * 3 + 1] = 20;
4898                        p[i * 3] = (Math.random() - 0.5) * 40; // Horizontal glitch reset
4899                    }
4900                }
4901                data.geo.attributes.position.needsUpdate = true;
4902            }
4903        });
4904
4905        registerScene('Thumbnail Matrix', {
4906            init: (scene, camera, color) => {
4907                const count = 300;
4908                const pointsList = [];
4909                const group = new THREE.Group();
4910                scene.add(group);
4911                camera.position.z = 5;
4912
4913                fetch('/static/latest_streams.json')
4914                    .then(res => res.json())
4915                    .then(streams => {
4916                        if (streams && streams.length > 0) {
4917                            const loader = new THREE.TextureLoader();
4918                            const picked = streams;
4919
4920                            picked.forEach(id => {
4921                                const tx = loader.load(`https://img.youtube.com/vi/${id}/mqdefault.jpg`);
4922                                const pCount = Math.floor(count / picked.length);
4923
4924                                const pos = new Float32Array(pCount * 3);
4925                                const vels = new Float32Array(pCount);
4926
4927                                for (let i = 0; i < pCount; i++) {
4928                                    pos[i * 3] = (Math.random() - 0.5) * 120;
4929                                    pos[i * 3 + 1] = (Math.random() - 0.5) * 120;
4930                                    pos[i * 3 + 2] = -10 - (Math.random() * 120);
4931                                    vels[i] = 0.01 + Math.random() * 0.03;
4932                                }
4933
4934                                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
4935                                const mat = new THREE.PointsMaterial({
4936                                    color: 0xffffff,
4937                                    size: 7.0,
4938                                    map: tx,
4939                                    transparent: true,
4940                                    opacity: 0.5
4941                                });
4942
4943                                const pts = new THREE.Points(geo, mat);
4944                                group.add(pts);
4945                                pointsList.push({ pts, geo, count: pCount, vels });
4946                            });
4947                        }
4948                    }).catch(e =>
4948 console.log('Thumbnail fetch error:', e));
4949
4950                return { pointsList };
4951            },
4952            update: (data) => {
4953                if (!data.pointsList) return;
4954                data.pointsList.forEach(obj => {
4955                    const p = obj.geo.attributes.position.array;
4956                    for (let i = 0; i < obj.count; i++) {
4957                        p[i * 3 + 2] += obj.vels[i];
4958                        if (p[i * 3 + 2] > 5) {
4959                            p[i * 3 + 2] = -120;
4960                            p[i * 3] = (Math.random() - 0.5) * 120;
4961                            p[i * 3 + 1] = (Math.random() - 0.5) * 120;
4962                        }
4963                    }
4964                    obj.geo.attributes.position.needsUpdate = true;
4965                });
4966            }
4967        });
4968
4969        registerScene('Collage Wallpaper', {
4970            init: (scene, camera, color) => {
4971                const loader = new THREE.TextureLoader();
4972                const texture = loader.load('/static/thumbnail_collage.png');
4973                texture.wrapS = THREE.RepeatWrapping;
4974                texture.wrapT = THREE.RepeatWrapping;
4975                texture.repeat.set(1.5, 1.5);
4976
4977                const isDark = document.body.classList.contains('dark-theme');
4978                const geo = new THREE.PlaneGeometry(120, 80);
4979                const mat = new THREE.MeshBasicMaterial({
4980                    map: texture,
4981                    transparent: true,
4982                    opacity: isDark ? 0.3 : 0.4
4983                });
4984                const m = new THREE.Mesh(geo, mat);
4985                m.position.z = -15;
4986                scene.add(m);
4987                camera.position.z = 25;
4988                return { m, texture, mat };
4989            },
4990            update: (data, time) => {
4991                data.texture.offset.x = time * 0.01;
4992                data.texture.offset.y = -(time * 0.005);
4993            }
4994        });
4995
4996        registerScene('Liquid Floor', {
4997            init: (scene, camera, color) => {
4998                const geo = new THREE.PlaneGeometry(80, 80, 30, 30);
4999                const mat = new THREE.MeshPhongMaterial({ color, wireframe: true, transparent: true, opacity: 0.1 });
5000                const f = new THREE.Mesh(geo, mat); f.rotation.x = -Math.PI * 0.5; f.position.y = -6;
5001                scene.add(f); scene.add(new THREE.AmbientLight(0xffffff, 0.5));
5002                camera.position.z = 20; camera.position.y = 2;
5003                return { f, geo, mat };
5004            },
5005            update: (data, time) => {
5006                const p = data.geo.attributes.position.array;
5007                for (let i = 0; i < p.length; i += 3) p[i + 2] = Math.sin(p[i] * 0.15 + time) * 2.5;
5008                data.geo.attributes.position.needsUpdate = true;
5009            }
5010        });
5011
5012
5013        registerScene('Micro Dust', {
5014            init: (scene, camera, color) => {
5015                const count = 4000;
5016                const pos = new Float32Array(count * 3);
5017                for (let i = 0; i < count; i++) {
5018                    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;
5019                }
5020                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
5021                const mat = new THREE.PointsMaterial({ color, size: 0.02, transparent: true, opacity: 0.4 });
5022                const d = new THREE.Points(geo, mat); scene.add(d); camera.position.z = 25;
5023                return { geo, count, mat };
5024            },
5025            update: (data, time) => {
5026                const p = data.geo.attributes.position.array;
5027                for (let i = 0; i < data.count; i++) {
5028                    p[i * 3] += Math.sin(time + i) * 0.01;
5029                    p[i * 3 + 1] += Math.cos(time + i) * 0.01;
5030                }
5031                data.geo.attributes.position.needsUpdate = true;
5032            }
5033        });
5034
5035        registerScene('Star field', {
5036            init: (scene, camera, color) => {
5037                const count = 1500;
5038                const pos = new Float32Array(count * 3);
5039                const vels = new Float32Array(count);
5040                for (let i = 0; i < count; i++) {
5041                    pos[i * 3] = (Math.random() - 0.5) * 20;
5042                    pos[i * 3 + 1] = (Math.random() - 0.5) * 20;
5043                    pos[i * 3 + 2] = (Math.random() - 1.0) * 15;
5044                    vels[i] = 0.004 + Math.random() * 0.008;
5045                }
5046                const geo = new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(pos, 3));
5047
5048                const loader = new THREE.TextureLoader();
5049                const texture = loader.load('/static/logo.svg');
5050
5051                const isDark = document.body.classList.contains('dark-theme');
5052                const mat = new THREE.PointsMaterial({
5053                    color: 0xffffff, // Keep logo original color
5054                    size: 0.1,
5055                    map: texture,
5056                    transparent: true,
5057                    alphaTest: 0.2,
5058                    opacity: isDark ? 0.6 : 0.8
5059                });
5060                const s = new THREE.Points(geo, mat); scene.add(s); camera.position.z = 5;
5061                return { s, geo, count, mat, vels };
5062            },
5063            update: (data, time, mouse) => {
5064                const p = data.geo.attributes.position.array;
5065                for (let i = 0; i < data.count; i++) {
5066                    p[i * 3 + 2] += data.vels[i];
5067                    if (p[i * 3 + 2] > 5) p[i * 3 + 2] = -15;
5068                }
5069                data.geo.attributes.position.needsUpdate = true;
5070            }
5071        });
5072
5073        /* ── Core Scene Engine ── */
5074
5075        function initHeroScene() {
5076            const canvas = document.getElementById('hero-canvas');
5077            if (!canvas) return;
5078
5079            // Skip canvas when GPU is unavailable or user prefers reduced motion
5080            if (document.documentElement.classList.contains('no-gpu') ||
5081                window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
5082                canvas.style.display = 'none';
5083
5084                // Static collage fallback when Collage Wallpaper scene is selected
5085                const selectedScene = getCookie('hero_scene_v2') || 'Collage Wallpaper';
5086                if (selectedScene === 'Collage Wallpaper') {
5087                    const isDark = document.body.classList.contains('dark-theme');
5088                    const img = document.createElement('img');
5089                    img.src = '/static/thumbnail_collage.png';
5090                    img.alt = '';
5091                    img.setAttribute('aria-hidden', 'true');
5092                    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;`;
5093                    canvas.parentElement.appendChild(img);
5094                }
5095
5096                // One-time GPU warning — no auto-dismiss, stored in localStorage
5097                if (!localStorage.getItem('no_gpu_warned')) {
5098                    let alertContainer = document.getElementById('custom-alerts-container');
5099                    if (!alertContainer) {
5100                        alertContainer = document.createElement('div');
5101                        alertContainer.id = 'custom-alerts-container';
5102                        document.body.appendChild(alertContainer);
5103                    }
5104                    const el = document.createElement('div');
5105                    el.className = 'alert custom-toast toast-warning m-0';
5106                    el.setAttribute('role', 'alert');
5107                    el.innerHTML = `
5108                        <i class="fa-solid fa-triangle-exclamation toast-icon"></i>
5109                        <div class="toast-message">Hardware acceleration is unavailable — animations have been disabled to keep the page smooth.</div>
5110                        <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">
5111                            <i class="fa-solid fa-xmark"></i>
5112                        </button>
5113                    `;
5114                    el.style.opacity = '0';
5115                    el.style.transform = 'translateY(20px)';
5116                    alertContainer.appendChild(el);
5117                    setTimeout(() => { el.style.opacity = '1'; el.style.transform = 'translateY(0)'; }, 10);
5118                    el.querySelector('button').addEventListener('click', () => {
5119                        localStorage.setItem('no_gpu_warned', '1');
5120                        el.style.opacity = '0';
5121                        el.style.transform = 'scale(0.9)';
5122                        setTimeout(() => el.parentNode && el.parentNode.removeChild(el), 400);
5123                    });
5124                }
5125
5126                return;
5127            }
5128
5129            if (typeof THREE === 'undefined') return;
5130
5131            const scene = new THREE.Scene();
5132            const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
5133            const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
5134            renderer.setSize(window.innerWidth, window.innerHeight);
5135            // Lower pixel ratio on mobile to reduce GPU work (~2-4× cheaper on high-DPR phones)
5136            renderer.setPixelRatio(window.innerWidth <= 768
5137                ? Math.min(window.devicePixelRatio, 1.5)
5138                : Math.min(window.devicePixelRatio, 2));
5139
5140            let currentData = null, currentConfig = null, frameId = null;
5141            let mouseX = 0, mouseY = 0;
5142
5143            const populateDropdown = () => {
5144                const select = document.getElementById('hero-scene-select');
5145                if (!select) return;
5146                select.innerHTML = '';
5147                Object.keys(sceneRegistry).sort().forEach(name => {
5148                    const opt = document.createElement('option');
5149                    opt.value = name; opt.textContent = name;
5150                    select.appendChild(opt);
5151                });
5152                const stored = getCookie('hero_scene_v2') || 'Collage Wallpaper';
5153                select.value = stored;
5154                return stored;
5155            };
5156
5157            window.switchHeroScene = (name) => {
5158                if (frameId) cancelAnimationFrame(frameId);
5159                scene.clear();
5160                const isDark = document.body.classList.contains('dark-theme');
5161                currentConfig = sceneRegistry[name];
5162                if (currentConfig) {
5163                    currentData = currentConfig.init(scene, camera);
5164                    setCookie('hero_scene_v2', name, 365);
5165                    animate();
5166                }
5167            };
5168
5169            // Konami Code Implementation
5170            const konamiCode = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
5171            const reverseKonami = ['a', 'b', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp'];
5172            const recentKeys = [];
5173
5174            const fontRegistry = [
5175                "Outfit", "Poppins", "Abril Fatface", "Alfa Slab One", "Anton", "Bangers",
5176                "Barlow Condensed", "Bevan", "Bowlby One SC", "Bungee", "Caveat", "Cinzel", "Rajdhani",
5177                "Comfortaa", "Creepster", "Dancing Script", "Gloria Hallelujah", "Indie Flower",
5178                "Josefin Sans", "Kaushan Script", "Lobster", "Luckiest Guy", "Monoton",
5179                "Orbitron", "Pacifico", "Permanent Marker", "Playfair Display", "Press Start 2P",
5180                "Righteous", "Roboto Mono", "Sacramento", "Shadows Into Light", "Special Elite",
5181                "Syncopate", "Titan One", "Vampiro One"
5182            ].sort();
5183
5184            const populateFontDropdown = () => {
5185                const select = document.getElementById('hero-font-select');
5186                if (!select) return;
5187                select.innerHTML = '';
5188                fontRegistry.forEach(font => {
5189                    const opt = document.createElement('option');
5190                    opt.value = font; opt.textContent = font;
5191                    opt.style.fontFamily = `'${font}', sans-serif`;
5192                    select.appendChild(opt);
5193                });
5194                const stored = getCookie('hero_font') || 'Rajdhani';
5195                select.value = stored;
5196                return stored;
5197            };
5198
5199            window.updateHeroFont = (fontName) => {
5200                // Logged-out heroes render .hero-title; logged-in ones render the
5201                // greeting name instead. Without the second selector the font
5202                // picker silently did nothing for signed-in users.
5203                const title = document.querySelector('.hero-title, .hero-greeting-name');
5204                if (!title) return;
5205
5206                // Load font dynamically if not already present
5207                const fontId = `font-${fontName.replace(/ /g, '-')}`;
5208                if (!document.getElementById(fontId)) {
5209                    const link = document.createElement('link');
5210                    link.id = fontId;
5211                    link.rel = 'stylesheet';
5212                    // No weight axis: Google Fonts CSS2 returns HTTP 400 when a family
5213                    // has no such weight, and 23 of the 36 display faces here ship
5214                    // only one. Asking for the family serves whatever it has, and the
5215                    // heading's own font-weight still applies.
5216                    link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, '+')}&display=swap`;
5217                    document.head.appendChild(link);
5218                }
5219
5220                title.style.fontFamily = `'${fontName}', sans-serif`;
5221                setCookie('hero_font', fontName, 365);
5222
5223                const select = document.getElementById('hero-font-select');
5224                if (select) select.value = fontName;
5225            };
5226
5227            document.addEventListener('keydown', (e) => {
5228                // A rolling buffer of the last N keys. Tracking an index per code
5229                // instead needs a KMP-style backoff to survive a partial match
5230                // (three ArrowUps must fall back to step 2, not step 0 or 1).
5231                recentKeys.push(e.key);
5232                if (recentKeys.length > konamiCode.length) recentKeys.shift();
5233                const entered = (code) => recentKeys.length === code.length
5234                    && code.every((k, i) => k === recentKeys[i]);
5235
5236                // Forward Konami
5237                if (entered(konamiCode)) {
5238                    const wrapper = document.querySelector('.scene-selector-wrapper');
5239                    if (wrapper) {
5240                        wrapper.style.display = 'flex';
5241                        wrapper.style.opacity = '1';
5242                        wrapper.style.pointerEvents = 'auto';
5243                        populateDropdown();
5244
5245                        // Retro Level-Up Sound Effect
5246                        const ctx = new (window.AudioContext || window.webkitAudioContext)();
5247                        const playTone = (freq, start, duration) => {
5248                            const osc = ctx.createOscillator();
5249                            const gain = ctx.createGain();
5250                            osc.type = 'square';
5251                            osc.frequency.setValueAtTime(freq, start);
5252                            gain.gain.setValueAtTime(0.1, start);
5253                            gain.gain.exponentialRampToValueAtTime(0.01, start + duration);
5254                            osc.connect(gain);
5255                            gain.connect(ctx.destination);
5256                            osc.start(start);
5257                            osc.stop(start + duration);
5258                        };
5259                        playTone(523.25, ctx.currentTime, 0.1); // C5
5260                        setTimeout(() => playTone(659.25, ctx.currentTime, 0.1), 100); // E5
5261                        setTimeout(() => playTone(783.99, ctx.currentTime, 0.1), 200); // G5
5262                        setTimeout(() => playTone(1046.50, ctx.currentTime, 0.2), 300); // C6
5263                    }
5264                }
5265
5266                // Reverse Konami
5267                if (entered(reverseKonami)) {
5268                    const wrapper = document.getElementById('hero-font-wrapper');
5269                    if (wrapper) {
5270                        wrapper.style.display = 'flex';
5271                        wrapper.style.opacity = '1';
5272                        wrapper.style.pointerEvents = 'auto';
5273                        populateFontDropdown();
5274
5275                        // Reverse Level-Up Sound Effect (Inverted frequencies)
5276                        const ctx = new (window.AudioContext || window.webkitAudioContext)();
5277                        const playTone = (freq, start, duration) => {
5278                            const osc = ctx.createOscillator();
5279                            const gain = ctx.createGain();
5280                            osc.type = 'square';
5281                            osc.frequency.setValueAtTime(freq, start);
5282                            gain.gain.setValueAtTime(0.1, start);
5283                            gain.gain.exponentialRampToValueAtTime(0.01, start + duration);
5284                            osc.connect(gain);
5285                            gain.connect(ctx.destination);
5286                            osc.start(start);
5287                            osc.stop(start + duration);
5288                        };
5289
5290                        // Frequencies: C6, G5, E5, C5 (Reversed order)
5291                        playTone(1046.50, ctx.currentTime, 0.1);
5292                        setTimeout(() => playTone(783.99, ctx.currentTime, 0.1), 100);
5293                        setTimeout(() => playTone(659.25, ctx.currentTime, 0.1), 200);
5294                        setTimeout(() => playTone(523.25, ctx.currentTime, 0.2), 300);
5295                    }
5296                }
5297            });
5298
5299            // Apply saved font on load (default to Rajdhani)
5300            const savedFont = getCookie('hero_font') || 'Rajdhani';
5301            populateFontDropdown();
5302            setTimeout(() => window.updateHeroFont(savedFont), 100);
5303
5304            const onMouseMove = (e) => { mouseX = (e.clientX / window.innerWidth - 0.5) * 10; mouseY = -(e.clientY / window.innerHeight - 0.5) * 10; };
5305            window.addEventListener('mousemove', onMouseMove);
5306            window.addEventListener('resize', () => {
5307                camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
5308                renderer.setSize(window.innerWidth, window.innerHeight);
5309            });
5310
5311            function animate() {
5312                frameId = requestAnimationFrame(animate);
5313                const time = performance.now() * 0.001;
5314                if (currentConfig && currentConfig.update) {
5315                    currentConfig.update(currentData, time, { x: mouseX, y: mouseY });
5316                }
5317                camera.position.x += (mouseX * 0.1 - camera.position.x) * 0.05;
5318                camera.position.y += (mouseY * 0.1 - camera.position.y) * 0.05;
5319                camera.lookAt(0, 0, 0);
5320                renderer.render(scene, camera);
5321            }
5322
5323            window.setThemeCallback = (dark) => {
5324                const color = 0xffffff;
5325                if (currentData) {
5326                    if (currentData.mat) currentData.mat.color.setHex(color);
5327                    if (currentData.wire) currentData.wire.color.setHex(color);
5328                    if (currentData.lineMat) currentData.lineMat.color.setHex(color);
5329                    if (currentData.g1) currentData.g1.children.forEac
5329h(c => c.material.color.setHex(color));
5330                    if (currentData.g2) currentData.g2.children.forEach(c => c.material.color.setHex(color));
5331                }
5332            };
5333
5334            // Pause rendering when hero is scrolled off-screen
5335            new IntersectionObserver((entries) => {
5336                if (entries[0].isIntersecting) {
5337                    if (currentConfig && !frameId) animate();
5338                } else {
5339                    if (frameId) { cancelAnimationFrame(frameId); frameId = null; }
5340                }
5341            }, { threshold: 0 }).observe(canvas);
5342
5343            const initial = getCookie('hero_scene_v2') || 'Collage Wallpaper';
5344            window.switchHeroScene(initial);
5345        }
5346
5347        // ── Infinite Scroll Observer ──────────────────────────────
5348        let scrollAnchor, infiniteLoader;
5349        const observer = new IntersectionObserver((entries) => {
5350            if (entries[0].isIntersecting && !loading && hasMore) {
5351                fetchPage();
5352            }
5353        }, {
5354            rootMargin: '400px',
5355            threshold: 0.1
5356        });
5357
5358        let gamePage = 1;
5359        let gamesLoading = false;
5360        let hasMoreGames = true;
5361        window.gameMapping = null;
5362
5363        let fetchGamesPromise = null;
5364
5365        async function fetchGames(reset = false) {
5366            if (!reset && gamesLoading) return;
5367            // Serialize calls using a promise chain to completely prevent race conditions
5368            while (fetchGamesPromise) {
5369                await fetchGamesPromise;
5370            }
5371
5372            let resolveLock;
5373            fetchGamesPromise = new Promise(resolve => { resolveLock = resolve; });
5374
5375            try {
5376                await _doFetchGames(reset);
5377            } finally {
5378                fetchGamesPromise = null;
5379                resolveLock();
5380            }
5381        }
5382
5383        async function _doFetchGames(reset = false) {
5384            if (gamesLoading || (!hasMoreGames && !reset)) return;
5385            gamesLoading = true;
5386
5387            const nextBtn = document.querySelector('.scroll-btn.right');
5388            if (nextBtn && !reset) nextBtn.classList.add('loading-glow');
5389
5390            if (reset) {
5391                gamePage = 1;
5392                hasMoreGames = true;
5393                const gameContainer = document.getElementById("game-cards");
5394                if (gameContainer) {
5395                    const gameSkeletons = Array(11).fill(0).map(() => `
5396                    <div class="game-card skeleton-game">
5397                        <div class="skeleton position-absolute" style="inset: 0; border-radius: 0;"></div>
5398                        <div class="game-overlay">
5399                            <div class="skeleton skeleton-text" style="height: 1.2rem; width: 75%; margin-bottom: 0;"></div>
5400                        </div>
5401                    </div>
5402                `).join("");
5403                    gameContainer.innerHTML = gameSkeletons;
5404                }
5405            }
5406
5407            try {
5408                const gamesRes = await fetch(`/api/games?page=${gamePage}&limit=10`);
5409                const games = await gamesRes.json();
5410
5411                if (games.length < 10) hasMoreGames = false;
5412                gamePage++;
5413
5414                const filter = document.getElementById("game-filter");
5415                const container = document.getElementById("game-cards");
5416                if (!filter || !container) return;
5417
5418                // Load mapping once
5419                if (!window.gameMapping) {
5420                    try {
5421                        const mappingRes = await fetch("/static/games/mapping.json");
5422                        if (mappingRes.ok) window.gameMapping = await mappingRes.json();
5423                    } catch (e) { console.error("Mapping fetch error:", e); }
5424                }
5425                const mapping = window.gameMapping || {};
5426
5427                const isInitialReset = reset;
5428                // Initial population (reset)
5429                if (reset) {
5430                    const url = new URL(window.location);
5431                    const activeGame = url.searchParams.get("game") || "";
5432
5433                    // Reset dropdown options to just All Games, plus activeGame if applicable
5434                    filter.innerHTML = '<option value="">All Games</option>';
5435                    if (activeGame && activeGame.trim().toLowerCase() !== "all games") {
5436                        const opt = document.createElement("option");
5437                        opt.value = activeGame.trim();
5438                        opt.textContent = activeGame.trim();
5439                        filter.appendChild(opt);
5440                    }
5441
5442                    // Build a 2x2 collage of the top trending games' posters.
5443                    // Filter to games that have a mapped poster, take the first 4.
5444                    const collageGames = games
5445                        .filter(g => g.name && g.name.trim().toLowerCase() !== "all games")
5446                        .map(g => ({ name: g.name.trim(), img: (mapping[g.name.trim().toLowerCase()] || mapping[g.name.trim()] || "") }))
5447                        .filter(g => g.img)
5448                        .slice(0, 4);
5449
5450                    let allContent;
5451                    if (collageGames.length === 4) {
5452                        const cells = collageGames.map(g =>
5453                            `<div><img src="${g.img}
5453" alt="${g.name.replace(/"/g, '&quot;')}" loading="lazy" onload="this.classList.add('loaded')"></div>`
5454                        ).join("");
5455                        allContent = `<div class="all-card-grid">${cells}</div>`;
5456                    } else {
5457                        allContent = `<i class="fa-solid fa-border-all fa-2x" style="position: relative; z-index: 2; color: var(--color-primary);"></i>`;
5458                    }
5459
5460                    container.innerHTML = `<div class="game-card all-card ${activeGame === '' ? 'active' : ''}" data-game="" onclick="setGame('')">
5461                    ${allContent}
5462                    <div class="game-overlay">
5463                        <div class="game-title">All Games</div>
5464                    </div>
5465                </div>`;
5466                }
5467                reset = false;
5468
5469                // Append Games
5470                games.forEach(g => {
5471                    if (!g.name) return;
5472                    const cleanName = g.name.trim();
5473                    if (!cleanName || cleanName.toLowerCase() === "all games") return;
5474
5475                    // Add to dropdown if not present
5476                    const existingOpt = [...filter.options].find(opt => opt.value.trim().toLowerCase() === cleanName.toLowerCase());
5477                    if (!existingOpt) {
5478                        const opt = document.createElement("option");
5479                        opt.value = cleanName;
5480                        opt.textContent = `${cleanName} (${g.count || 0})`;
5481                        filter.appendChild(opt);
5482                    } else if (!existingOpt.textContent.includes('(')) {
5483                        existingOpt.textContent = `${cleanName} (${g.count || 0})`;
5484                    }
5485
5486                    // Add card if not already in container
5487                    if (![...container.querySelectorAll('.game-card')].some(c => (c.getAttribute('data-game') || "").trim().toLowerCase() === cleanName.toLowerCase())) {
5488                        const url = new URL(window.location);
5489                        const activeGame = url.searchParams.get("game") || "";
5490                        const isActive = cleanName.toLowerCase() === activeGame.trim().toLowerCase();
5491                        const imgPath = mapping ? (mapping[cleanName.toLowerCase()] || mapping[cleanName] || "") : "";
5492
5493                        const placeholderHtml = `
5494                        <div class="game-placeholder">
5495                            <i class="fa-solid fa-gamepad fa-2x"></i>
5496                        </div>
5497                    `;
5498                        let imgHtml = placeholderHtml;
5499                        if (imgPath) {
5500                            imgHtml += `<img src="${imgPath}" alt="${cleanName.replace(/"/g, '&quot;')}" loading="lazy" onload="this.classList.add('loaded')">`;
5501                        }
5502
5503                        container.insertAdjacentHTML("beforeend", `
5504                        <div class="game-card ${isActive ? 'active' : ''}" data-game="${cleanName.replace(/"/g, '&quot;')}" onclick="setGame('${cleanName.replace(/'/g, "\\'")}')">
5505                            ${imgHtml}
5506                            <div class="game-overlay">
5507                                <div class="game-title">${cleanName.replace(/"/g, '&quot;')}</div>
5508                            </div>
5509                        </div>
5510                    `);
5511                    }
5512                });
5513
5514                if (filter.customDropdown) {
5515                    filter.customDropdown.renderOptions();
5516                    filter.customDropdown.updateLabel();
5517                    setupGameDropdownInfiniteScroll();
5518                }
5519
5520                if (window.AOS) AOS.refresh();
5521
5522                // Scroll active game into view only on first load
5523                if (isInitialReset) {
5524                    setTimeout(() => {
5525                        const activeCard = container.querySelector('.game-card.active');
5526                        if (activeCard) {
5527                            scrollCarouselToCard(container, activeCard);
5528                        }
5529                    }, 100);
5530                }
5531            } catch (e) {
5532                console.error("fetchGames error:", e);
5533            } finally {
5534                gamesLoading = false;
5535                const nextBtn = document.querySelector('.scroll-btn.right');
5536                if (nextBtn) nextBtn.classList.remove('loading-glow');
5537            }
5538        }
5539
5540        async function searchCategories() {
5541            const q = getSearch();
5542            if (!q || q.length < 2) return;
5543
5544            try {
5545                const res = await fetch(`/api/search_categories?q=${encodeURIComponent(q)}`);
5546                const games = await res.json();
5547                const filter = document.getElementById("game-filter");
5548                if (!filter) return;
5549
5550                games.forEach(g => {
5551                    if (!g.name) return;
5552                    const cleanName = g.name.trim();
5553                    if (!cleanName || cleanName.toLowerCase() === "all games") return;
5554
5555                    const existingOpt = [...filter.options].find(opt => opt.value.trim().toLowerCase() === cleanName.toLowerCase());
5556                    if (!existingOpt) {
5557                        const opt = document.createElement("option");
5558                        opt.value = cleanName;
5559                        opt.textContent = `${cleanName} (${g.count || 0})`;
5560                        filter.appendChild(opt);
5561                    } else if (!existingOpt.textContent.includes('(')) {
5562                        existingOpt.textContent = `${cleanName} (${g.count || 0})`;
5563                    }
5564                });
5565                if (filter.customDropdown) {
5566                    filter.customDropdown.renderOptions();
5567                    filter.customDropdown.updateLabel();
5568                }
5569            } catch (e) {
5570                console.error("searchCategories error:", e);
5571            }
5572        }
5573
5574        document.getElementById("search").addEventListener("input", () => {
5575            clearTimeout(debounceTimer);
5576            debounceTimer = setTimeout(() => {
5577                reload();
5578                searchCategories();
5579            }, 500);
5580        });
5581
5582        const gameContainer = document.getElementById("game-cards");
5583        if (gameContainer) {
5584            gameContainer.addEventListener("scroll", () => {
5585                const { scrollLeft, scrollWidth, clientWidth } = gameContainer;
5586                if (scrollLeft + clientWidth >= scrollWidth - 200) {
5587                    fetchGames();
5588                }
5589            });
5590        }
5591
5592        // Scrolling the game filter dropdown's option list near the bottom fetches
5593        // the next page of games too — same lazy loading as the horizontal card
5594        // strip. Bound once (via the dataset flag) as soon as the custom dropdown
5595        // exists; the menu element itself survives renderOptions() re-renders.
5596        function setupGameDropdownInfiniteScroll() {
5597            const filter = document.getElementById("game-filter");
5598            const menu = filter && filter.customDropdown && filter.customDropdown.menu;
5599            if (!menu || menu.dataset.infiniteScrollBound) return;
5600            menu.dataset.infiniteScrollBound = "true";
5601            menu.addEventListener("scroll", () => {
5602                const { scrollTop, scrollHeight, clientHeight } = menu;
5603                if (scrollTop + clientHeight >= scrollHeight - 60) {
5604                    fetchGames();
5605                }
5606            });
5607        }
5608
5609        window.scrollGames = (direction) => {
5610            const container = document.getElementById("game-cards");
5611            if (!container) return;
5612            const scrollAmount = container.clientWidth * 0.8;
5613            container.scrollBy({
5614                left: direction * scrollAmount,
5615                behavior: 'smooth'
5616            });
5617        };
5618
5619        document.addEventListener('DOMContentLoaded', async () => {
5620            initHeroScene();
5621            await fetchGames(true);
5622
5623            scrollAnchor = document.getElementById('scroll-anchor');
5624            infiniteLoader = document.getElementById('infinite-loader');
5625
5626            await restoreFromURL();
5627
5628            // If restoreFromURL didn't call setGame (which calls reload/fetchPage), call it now
5629            if (!getGame()) {
5630                fetchPage(true);
5631            }
5632
5633            if (scrollAnchor) observer.observe(scrollAnchor);
5634
5635            if (window.setThemeCallback) {
5636                const isDark = document.body.classList.contains('dark-theme');
5637                setThemeCallback(isDark);
5638            }
5639        });
5640
5641        /* ── Tip Rotation Logic ── */
5642        const tips = [
5643            { text: "🎮 <b>Valorant Auto-pilot</b>: Everyone gets 100% automated Valorant highlight clipping.", link: "/settings#valorant" },
5644            { text: "<i class='fa-brands fa-discord'></i> <b>Send Clips To Discord</b>: Send clips directly to your own discord server.", link: "/settings#discord" },
5645            { text: "<i class='fa-regular fa-message'></i> <b>Customize The message</b>: Customize what the message in chat will look like.", link: "/settings#general" },
5646            { 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" },
5647            { text: "<i class='fa-solid fa-message'></i> <b>Preferred Title</b>
5647: If you stream in both formats (snips and normal stream) use this feature.", link: "/settings#general" },
5648            { 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" },
5649            { 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" },
5650            { 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" },
5651            { 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" },
5652            { 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" },
5653            { 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" },
5654            { text: "<i class='fa-solid fa-wand-magic-sparkles'></i> <b>Smart Clip Timing</b>: Automatically fine-tunes exactly when your clips start — no more early or late cuts.", link: "/settings#general" },
5655            { text: "<i class='fa-solid fa-unlock'></i> <b>100% Free</b>: Every feature, unlimited clips, no plans. Free for everyone.", link: "/get-started" },
5656            { 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" },
5657
5658        ];
5659
5660        function shuffleArray(array) {
5661            for (let i = array.length - 1; i > 0; i--) {
5662                const j = Math.floor(Math.random() * (i + 1));
5663                [array[i], array[j]] = [array[j], array[i]];
5664            }
5665        }
5666
5667        shuffleArray(tips);
5668
5669        let currentTipIndex = 0;
5670        let isTipPaused = false;
5671        const tipTextEl = document.getElementById("tip-text");
5672        const tipBannerEl = document.getElementById("tips-banner");
5673
5674        if (tipBannerEl) {
5675            tipBannerEl.addEventListener('mouseenter', () => isTipPaused = true);
5676            tipBannerEl.addEventListener('mouseleave', () => isTipPaused = false);
5677        }
5678
5679        function rotateTip() {
5680            if (!tipTextEl || isTipPaused) return;
5681
5682            tipTextEl.classList.add("tip-fade-transition");
5683
5684            setTimeout(() => {
5685                currentTipIndex = (currentTipIndex + 1) % tips.length;
5686                tipTextEl.innerHTML = tips[currentTipIndex].text;
5687                tipTextEl.classList.remove("tip-fade-transition");
5688            }, 400);
5689        }
5690
5691        function handleTipClick() {
5692            const link = tips[currentTipIndex].link;
5693            if (link) {
5694                if (link.startsWith('http')) {
5695                    window.open(link, '_blank');
5696                } else {
5697                    window.location.href = link;
5698                }
5699            }
5700        }
5701
5702        // Initialize rotation and set initial shuffled tip
5703        if (tipTextEl) {
5704            rotateTip();
5705            setInterval(rotateTip, 8000);
5706        }
5707
5708        /* ── Greeting Name: shrink long single-word names to fit the column ──
5709           The name is painted with background-clip: text, so anything that
5710           overflows its box renders invisible and looks cut off. */
5711        (function () {
5712            const nameEl = document.getElementById('hero-greeting-name');
5713            if (!nameEl) return;
5714            const MIN_PX = 28;
5715            function fit() {
5716                nameEl.style.fontSize = '';
5717                nameEl.style.overflowWrap = '';
5718                let size = parseFloat(getComputedStyle(nameEl).fontSize);
5719                while (nameEl.scrollWidth > nameEl.clientWidth && size > MIN_PX) {
5720                    size = Math.max(MIN_PX, size - 2);
5721                    nameEl.style.fontSize = size + 'px';
5722                }
5723                // Still too wide at the floor (tiny phones): break the word rather than hide it.
5724                if (nameEl.scrollWidth > nameEl.clientWidth) nameEl.style.overflowWrap = 'anywhere';
5725            }
5726            fit();
5727            if (document.fonts) document.fonts.ready.then(fit);
5728            let t;
5729            window.addEventListener('resize', () => { clearTimeout(t); t = setTimeout(fit, 150); });
5730        })();
5731
5732        /* ── Greeting Name: colour from profile picture ── */
5733        (function () {
5734            const nameEl = document.getElementById('hero-greeting-name');
5735            if (!nameEl) return;
5736            const pfp = nameEl.dataset.pfp;
5737            if (!pfp) return;
5738
5739            const img = new Image();
5740            img.crossOrigin = 'anonymous'; // must be set before src
5741            img.onload = function () {
5742                try {
5743                    const canvas = document.createElement('canvas');
5744                    canvas.width = 32; canvas.height = 32;
5745                    const ctx = canvas.getContext('2d');
5746                    ctx.drawImage(img, 0, 0, 32, 32);
5747                    const { data } = ctx.getImageData(0, 0, 32, 32);
5748
5749                    let bestR = 96, bestG = 165, bestB = 250, bestScore = -1;
5750
5751                    for (let i = 0; i < data.length; i += 4) {
5752                        if (data[i + 3] < 128) continue;
5753                        const r = data[i] / 255, g = data[i + 1] / 255, b = data[i + 2] / 255;
5754                        const max = Math.max(r, g, b), min = Math.min(r, g, b);
5755                        const l = (max + min) / 2;
5756                        const s = max === min ? 0 : (max - min) / (l < 0.5 ? max + min : 2 - max - min);
5757                        if (l < 0.12 || l > 0.92 || s < 0.1) continue;
5758                        const score = s * (1 - Math.abs(l - 0.5) * 1.8);
5759                        if (score > bestScore) {
5760                            bestScore = score;
5761                            bestR = data[i]; bestG = data[i + 1]; bestB = data[i + 2];
5762                        }
5763                    }
5764
5765                    // If no vibrant pixel found (e.g. greyscale avatar), keep default
5766                    if (bestScore < 0.05) return;
5767
5768                    // Boost saturation: push colour further from grey
5769                    const mid = (bestR + bestG + bestB) / 3;
5770                    const boost = 1.6;
5771                    const clamp = (v) => Math.min(255, Math.max(0, Math.round(v)));
5772                    const r2 = clamp(mid + (bestR - mid) * boost);
5773                    const g2 = clamp(mid + (bestG - mid) * boost);
5774                    const b2 = clamp(mid + (bestB - mid) * boost);
5775
5776                    // Lighter variant for gradient midpoint
5777                    const lighten = (c) => clamp(c * 0.55 + 255 * 0.45);
5778                    const c1 = `rgb(${r2},${g2},${b2})`;
5779                    const c2 = `rgb(${lighten(r2)},${lighten(g2)},${lighten(b2)})`;
5780                    nameEl.style.backgroundImage = `linear-gradient(135deg, ${c1} 0%, ${c2} 50%, ${c1} 100%)`;
5781                } catch (_) { /* CORS blocked — keep default CSS gradient */ }
5782            };
5783            img.src = pfp;
5784        })();
5785
5786        /* ── Logged-In Time Greeting ── */
5787        (function () {
5788            const el = document.getElementById('hero-greeting-label');
5789            if (!el) return;
5790            const GREETINGS = {
5791                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."],
5792                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?"],
5793                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"],
5794                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? 👀"]
5795            };
5796            const h = new Date().getHours();
5797            const period = h >= 5 && h < 12 ? 'morning' : h >= 12 && h < 17 ? 'afternoon' : h >= 17 && h < 21 ? 'evening' : 'night';
5798            const variants = GREETINGS[period];
5799            el.textContent = variants[Math.floor(Math.random() * variants.length)];
5800        })();
5801    </script>
5801
5802    
5803  
5803<script>
5804  (function(){
5805    var s=document.createElement('script');
5806    s.async=true;
5807    s.src='https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js';
5808    s.onload=function(){
5809      if(!window.webVitals)return;
5810      function sv(m){
5811        try{navigator.sendBeacon('/api/vitals',JSON.stringify({n:m.name,v:m.value,p:location.pathname}));}catch(e){}
5812      }
5813      webVitals.onFCP(sv);webVitals.onLCP(sv);webVitals.onCLS(sv);webVitals.onINP(sv);webVitals.onTTFB(sv);
5814    };
5815    document.head.appendChild(s);
5816  })();
5817  </script>
5817
5818
5819  <!-- ========= TERMINAL COPY BUTTONS =========
5820       Adds a "Copy" button to every .terminal-container (command/code blocks on
5821       get-started, guides, yt-dlp builder…). Replaces the undiscoverable
5822       click-anywhere-to-copy with an obvious affordance + copied-state feedback.
5823       Terminals are always dark, so this light-on-glass button reads in both themes. -->
5824  <style>
5825    .terminal-container { position: relative; }
5826    .ss-term-copy {
5827      display: inline-flex;
5828      align-items: center;
5829      gap: 6px;
5830      padding: 4px 9px;
5831      font-size: 0.7rem;
5832      font-weight: 600;
5833      line-height: 1;
5834      font-family: inherit;
5835      color: #cbd5e1;
5836      background: rgba(255, 255, 255, 0.08);
5837      border: 1px solid rgba(255, 255, 255, 0.14);
5838      border-radius: var(--radius-sm);
5839      cursor: pointer;
5840      transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
5841    }
5842    .ss-term-copy:hover { background: rgba(255, 255, 255, 0.16); color: #fff; }
5843    .ss-term-copy:active { transform: scale(0.94); }
5844    .ss-term-copy.copied {
5845      color: #4ade80;
5846      border-color: rgba(74, 222, 128, 0.4);
5847      background: rgba(74, 222, 128, 0.14);
5848    }
5849    /* Floating variant for terminals with no title bar — reserve room so the
5850       button never overlaps the first line of the command. */
5851    .ss-term-copy--float { position: absolute; top: 8px; right: 8px; z-index: 3; }
5852    .terminal-container.has-floating-copy .terminal-body { padding-top: 2.5rem; }
5853  </style>
5854  
5854<script>
5855    (function () {
5856      function addCopyButtons() {
5857        document.querySelectorAll('.terminal-container').forEach(function (term) {
5858          if (term.querySelector('.ss-term-copy')) return;          // already enhanced
5859          var body = term.querySelector('.terminal-body');
5860          if (!body) return;
5861          var header = term.querySelector('.terminal-header');
5862
5863          var btn = document.createElement('button');
5864          btn.type = 'button';
5865          btn.className = 'ss-term-copy';
5866          btn.setAttribute('aria-label', 'Copy to clipboard');
5867          btn.innerHTML = '<i class="fa-solid fa-copy" aria-hidden="true"></i><span>Copy</span>';
5868
5869          if (header) {
5870            // sit at the right of the title bar; only self-align when the header
5871            // has no existing right-aligned element (e.g. a "Nightbot" label).
5872            if (!header.querySelector('.ms-auto')) btn.style.marginLeft = 'auto';
5873            header.appendChild(btn);
5874          } else {
5875            btn.classList.add('ss-term-copy--float');
5876            term.classList.add('has-floating-copy');
5877            term.appendChild(btn);
5878          }
5879
5880          btn.addEventListener('click', function (e) {
5881            e.stopPropagation();
5882            var text = (body.innerText || '').trim();
5883            navigator.clipboard.writeText(text).then(function () {
5884              btn.classList.add('copied');
5885              btn.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i><span>Copied</span>';
5886              setTimeout(function () {
5887                btn.classList.remove('copied');
5888                btn.innerHTML = '<i class="fa-solid fa-copy" aria-hidden="true"></i><span>Copy</span>';
5889              }, 1800);
5890            }).catch(function () {});
5891          });
5892        });
5893      }
5894      document.addEventListener('DOMContentLoaded', addCopyButtons);
5895      if (document.readyState !== 'loading') addCopyButtons();
5896    })();
5897  </script>
5897
5898
5899  <!-- ========= PALETTE DROPDOWN (wiring) =========
5900       Lets a signed-in user pick an accent palette from the navbar. The choice is
5901       stored in localStorage (client-side only — does NOT change the CSS file or
5902       affect other users) and re-applied pre-paint by the head script. -->
5903  <style>
5904    .palette-dot {
5905      display: inline-block;
5906      width: 12px;
5907      height: 12px;
5908      border-radius: 50%;
5909      margin-inline-end: 8px;
5910      vertical-align: middle;
5911      box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
5912    }
5913  </style>
5914  
5914<script>
5915    (function () {
5916      function apply(h, s, l, rgb) {
5917        var st = document.documentElement.style;
5918        if (h === null) {
5919          st.removeProperty('--accent-h'); st.removeProperty('--accent-s');
5920          st.removeProperty('--accent-l'); st.removeProperty('--color-primary-rgb');
5921          localStorage.removeItem('ss_palette');
5922          return;
5923        }
5924        st.setProperty('--accent-h', h); st.setProperty('--accent-s', s);
5925        st.setProperty('--accent-l', l); st.setProperty('--color-primary-rgb', rgb);
5926        localStorage.setItem('ss_palette', JSON.stringify({ h: h, s: s, l: l, rgb: rgb }));
5927      }
5928      function wire() {
5929        document.querySelectorAll('.palette-opt').forEach(function (opt) {
5930          if (opt._wired) return; opt._wired = true;
5931          opt.addEventListener('click', function (e) {
5932            e.preventDefault();
5933            if (opt.dataset.reset) apply(null);
5934            else apply(opt.dataset.h, opt.dataset.s, opt.dataset.l, opt.dataset.rgb);
5935          });
5936        });
5937      }
5938      document.addEventListener('DOMContentLoaded', wire);
5939      if (document.readyState !== 'loading') wire();
5940    })();
5941  </script>
5941
5942</body>
5943
5944</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.