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