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