mwask commited on
Commit
d571830
·
verified ·
1 Parent(s): 8e70e8f

Upload 59 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. frontend/.env.example +11 -0
  2. frontend/.env.local +3 -0
  3. frontend/.env.production.local +4 -0
  4. frontend/Dockerfile +12 -0
  5. frontend/dist/assets/index-BOX6jOo8.css +1 -0
  6. frontend/dist/assets/index-BYVU7BLe.js +0 -0
  7. frontend/dist/index.html +20 -0
  8. frontend/dist/music-api.js +151 -0
  9. frontend/dist/music.css +2197 -0
  10. frontend/dist/music.html +274 -0
  11. frontend/dist/music.js +1729 -0
  12. frontend/index.html +19 -0
  13. frontend/package-lock.json +1747 -0
  14. frontend/package.json +21 -0
  15. frontend/public/music-api.js +151 -0
  16. frontend/public/music.css +2197 -0
  17. frontend/public/music.html +274 -0
  18. frontend/public/music.js +1729 -0
  19. frontend/src/App.jsx +59 -0
  20. frontend/src/api.js +115 -0
  21. frontend/src/components/Ambient.jsx +159 -0
  22. frontend/src/components/AnimeCard.jsx +55 -0
  23. frontend/src/components/AnimeRow.jsx +44 -0
  24. frontend/src/components/AuthModal.jsx +206 -0
  25. frontend/src/components/ContinueWatching.jsx +123 -0
  26. frontend/src/components/ErrorBoundary.jsx +51 -0
  27. frontend/src/components/Header.jsx +196 -0
  28. frontend/src/components/HeroCarousel.jsx +98 -0
  29. frontend/src/components/HeroSection.jsx +148 -0
  30. frontend/src/components/HlsPlayer.jsx +1012 -0
  31. frontend/src/components/LibraryMarquee.jsx +39 -0
  32. frontend/src/components/MangaCard.jsx +46 -0
  33. frontend/src/components/MediaCard.jsx +34 -0
  34. frontend/src/components/MediaRow.jsx +22 -0
  35. frontend/src/components/MobileNav.jsx +106 -0
  36. frontend/src/components/MovieCard.jsx +59 -0
  37. frontend/src/components/MovieRow.jsx +41 -0
  38. frontend/src/components/Sidebar.jsx +116 -0
  39. frontend/src/components/Toast.jsx +45 -0
  40. frontend/src/index.css +1072 -0
  41. frontend/src/main.jsx +13 -0
  42. frontend/src/pages/Anime.jsx +196 -0
  43. frontend/src/pages/AuthCallback.jsx +51 -0
  44. frontend/src/pages/Home.jsx +180 -0
  45. frontend/src/pages/Landing.jsx +93 -0
  46. frontend/src/pages/Manga.jsx +142 -0
  47. frontend/src/pages/MangaDetails.jsx +185 -0
  48. frontend/src/pages/MovieDetails.jsx +285 -0
  49. frontend/src/pages/MovieWatch.jsx +326 -0
  50. frontend/src/pages/Movies.jsx +291 -0
frontend/.env.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend REST API base (leave as /api to use the Vite dev proxy)
2
+ VITE_API_URL=/api
3
+
4
+ # HLS proxy worker endpoint (leave as /hls to use the Vite dev proxy).
5
+ # To use the deployed Cloudflare worker instead (recommended — the local
6
+ # dev proxy must be running for /hls to work):
7
+ # VITE_STREAM_PROXY_URL=https://anidoom-proxy.shawnmwask1234.workers.dev/hls
8
+ VITE_STREAM_PROXY_URL=/hls
9
+
10
+ # Only needed if the worker requires auth (STREAM_KEY secret)
11
+ VITE_STREAM_KEY=
frontend/.env.local ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Use the deployed anicove-proxy Cloudflare Worker for all HLS/MP4 streaming
2
+ # instead of the local dev proxy (:8787, which is not always running).
3
+ VITE_STREAM_PROXY_URL=https://anicove-proxy.shawnmwask1234.workers.dev/hls
frontend/.env.production.local ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Production build: stream everything through the deployed anicove-proxy worker.
2
+ # Set VITE_API_URL here too if the backend is deployed somewhere else.
3
+ VITE_API_URL=https://frantic-dollie-mwask-009a226f.koyeb.app
4
+ VITE_STREAM_PROXY_URL=https://anicove-proxy.shawnmwask1234.workers.dev/hls
frontend/Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package*.json ./
6
+ RUN npm install
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 5173
11
+
12
+ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
frontend/dist/assets/index-BOX6jOo8.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{--bg-base: #080808;--bg-surface: #0d0d0d;--bg-elevated: #111111;--bg-card: #0f0f0f;--bg-card-hover: #1a1a1a;--accent-v: #7c3aed;--accent-v2: #9d6ef8;--accent-c: #5b21b6;--accent-c2: #c4b5fd;--accent-green: #22c55e;--accent-amber: #f59e0b;--grad-accent: linear-gradient(135deg, #7c3aed, #4c1d95);--grad-card: linear-gradient(180deg, transparent 35%, rgba(8,8,8,.99) 100%);--text-primary: #ffffff;--text-secondary: #777790;--text-muted: #404050;--border: rgba(255,255,255,.07);--border-bright: rgba(124,58,237,.5);--glass-border: rgba(255,255,255,.06);--radius-sm: 6px;--radius-md: 10px;--radius-lg: 16px;--radius-pill: 999px;--shadow-hover: 0 8px 40px rgba(124,58,237,.35);--transition: all .22s cubic-bezier(.4,0,.2,1);--header-h: 62px;--max-w: 1380px}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html{scroll-behavior:smooth;scrollbar-width:none}::-webkit-scrollbar{display:none}body{font-family:Outfit,Inter,sans-serif;background:radial-gradient(circle at top,rgba(124,58,237,.22),transparent 32%),linear-gradient(180deg,#06070a,#0b0b12 36%,#08090d);color:var(--text-primary);min-height:100vh;line-height:1.6;overflow-x:hidden}#root{min-height:100vh}img{display:block;max-width:100%}a{color:inherit;text-decoration:none}button{cursor:pointer;border:none;background:none;font-family:inherit;color:inherit}ul,ol{list-style:none}input{font-family:inherit}.container{max-width:var(--max-w);margin:0 auto;padding:0 28px}.main-content{position:relative;min-height:100vh;overflow-x:hidden}.page-shell{padding-top:calc(var(--header-h) + 30px);padding-bottom:80px}.catalog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:20px 16px}.search-shell{display:flex;flex-direction:column;gap:18px}.section-header.compact{margin-bottom:16px}#header{position:fixed;top:0;left:0;right:0;z-index:100;height:var(--header-h);transition:var(--transition)}#header:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(180deg,#0a0a12d1,#0a0a1261);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);opacity:0;transition:var(--transition);pointer-events:none}#header.scrolled:before,#header:not(.scrolled){opacity:1}#header.scrolled{border-bottom:1px solid var(--border);box-shadow:0 12px 28px #0000002e}#header .container{height:100%;display:flex;align-items:center;gap:24px;position:relative;z-index:1}.logo{display:flex;align-items:center;flex-shrink:0}.logo-wordmark{display:flex;align-items:flex-end;line-height:1}.logo-wordmark .l{font-family:Bebas Neue,Outfit,sans-serif;font-size:2.2rem;font-weight:400;line-height:1;color:#fff;letter-spacing:.02em}nav{display:flex;align-items:center;gap:2px}nav a{padding:6px 14px;border-radius:var(--radius-pill);font-size:.88rem;font-weight:500;color:var(--text-secondary);transition:var(--transition)}nav a:hover,nav a.active{color:var(--text-primary);background:#8b5cf626}.search-wrap{flex:1;max-width:360px;position:relative}.search-input{width:100%;padding:9px 16px 9px 42px;border-radius:var(--radius-pill);background:#ffffff0f;border:1px solid var(--border);color:var(--text-primary);font-size:.88rem;outline:none;transition:var(--transition)}.search-input::placeholder{color:var(--text-muted)}.search-input:focus{background:#ffffff1a;border-color:var(--accent-v);box-shadow:0 0 0 3px #8b5cf61f}.search-icon{position:absolute;left:14px;top:50%;transform:translateY(-50%);pointer-events:none;color:var(--text-muted)}.search-icon svg{width:16px;height:16px}.auth-nav{margin-left:auto;flex-shrink:0}.auth-avatar-btn{display:flex;align-items:center;gap:8px;padding:7px 14px 7px 8px;border-radius:var(--radius-pill);background:#ffffff0f;border:1px solid var(--border);transition:var(--transition);font-size:.85rem;font-weight:700;letter-spacing:.01em;color:var(--text-primary);box-shadow:0 8px 18px #0000001f}.auth-avatar-btn:hover{background:#ffffff1a;border-color:var(--border-bright);transform:translateY(-1px)}.auth-avatar-btn.open{border-color:var(--border-bright);background:linear-gradient(180deg,#7c3aed2e,#ffffff14);box-shadow:0 12px 30px #7c3aed2e}.auth-avatar-btn.guest{background:linear-gradient(135deg,#7c3aed2e,#ffffff14);border-color:#7c3aed6b}.auth-avatar-btn.guest:hover{border-color:#7c3aedb3;box-shadow:0 12px 32px #7c3aed38}.auth-avatar-circle{width:28px;height:28px;border-radius:50%;background:#ffffff14;display:flex;align-items:center;justify-content:center;flex-shrink:0}.auth-avatar-icon{background:linear-gradient(135deg,#7c3aed42,#ffffff14);color:var(--accent-v2)}.auth-avatar-img{object-fit:cover}.auth-nav{position:relative}@media (max-width: 1024px){.container{padding:0 20px}nav{display:none}.search-wrap{max-width:260px}}@media (max-width: 768px){.container{padding:0 16px}.search-wrap{display:none}.anime-card{width:130px}.hero-next,.hero-prev{display:none!important}.hero-cover-wrap{display:none}.home-layout{grid-template-columns:1fr;gap:14px}.home-sidebar{position:static}}@media (max-width: 680px){.container{padding:0 16px}.auth-avatar-btn{gap:0;padding:5px 8px 5px 6px;min-width:42px;justify-content:center}.auth-avatar-btn span,.auth-avatar-btn svg:last-child{display:none}.auth-avatar-circle{width:26px;height:26px}.auth-avatar-icon svg{width:14px;height:14px}.main-content{padding-bottom:calc(66px + env(safe-area-inset-bottom))}.home-layout{grid-template-columns:1fr;gap:14px}.spotlight-featured{min-height:262px;border-radius:14px}.spotlight-featured-body{padding:12px 12px 38px}.spotlight-featured-title{font-size:1.28rem;margin-bottom:4px}.spotlight-nav-btn{width:30px;height:30px;opacity:1}.spotlight-nav-btn.prev{left:8px}.spotlight-nav-btn.next{right:8px}.genre-row-wrap,.genre-row{gap:8px}.genre-pill{padding:7px 12px;font-size:.74rem}.section-header{margin-bottom:14px}.section-title{font-size:1.02rem}.section-title:before{height:19px}.scroll-row{gap:10px}.anime-card{width:138px}.card-body{padding:8px 9px 10px}.card-title{font-size:.78rem}.btn{padding:10px 18px;font-size:.85rem}.btn-sm{padding:6px 13px;font-size:.78rem}}@media (max-width: 420px){.anime-card{width:126px}.hero-ctas{flex-direction:column}.hero-cta{width:100%;justify-content:center}.spotlight-featured{min-height:240px}.auth-dropdown{left:12px;right:12px;min-width:0}}.auth-dropdown{position:absolute;top:calc(100% + 10px);right:0;z-index:130;min-width:240px;background:#0c0c14f5;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);border:1px solid var(--border-bright);border-radius:var(--radius-lg);box-shadow:0 22px 52px #000000b3;padding:10px;animation:fadeUp .18s ease both;overflow:hidden}.auth-dropdown-head{padding:12px 12px 10px;border-bottom:1px solid var(--border);margin-bottom:8px;background:linear-gradient(180deg,rgba(124,58,237,.1),transparent);border-radius:8px 8px 0 0}.auth-dropdown-name{font-size:.9rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.auth-dropdown-sub{font-size:.72rem;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.auth-dropdown-item{display:flex;align-items:center;gap:9px;width:100%;padding:10px 12px;border-radius:var(--radius-md);font-size:.85rem;font-weight:600;color:var(--text-primary);transition:var(--transition);cursor:pointer;background:none;border:none;text-align:left;margin-top:2px}.auth-dropdown-item svg{color:var(--accent-v2);flex-shrink:0}.auth-dropdown-item:hover{background:linear-gradient(90deg,#7c3aed29,#ffffff08);border-color:transparent}.auth-dropdown-item.danger{color:#fda4af}.auth-dropdown-item.danger svg{color:#fda4af}.auth-dropdown-item.danger:hover{background:#e11d481f}.auth-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:200;background:#05050ab8;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;padding:20px;animation:fadeIn .2s ease both}.auth-modal{position:relative;width:100%;max-width:400px;background:var(--bg-surface);border:1px solid var(--border-bright);border-radius:var(--radius-lg);padding:30px 28px 26px;box-shadow:0 30px 90px #000000b3;animation:fadeUp .22s ease both}.auth-modal-close{position:absolute;top:14px;right:14px;width:30px;height:30px;border-radius:50%;background:#ffffff0f;border:1px solid var(--border);color:var(--text-secondary);font-size:.85rem;display:flex;align-items:center;justify-content:center;transition:var(--transition);cursor:pointer}.auth-modal-close:hover{background:#ffffff1f;color:#fff}.auth-modal-logo{font-family:Bebas Neue,Outfit,sans-serif;font-size:1.9rem;letter-spacing:.05em;text-align:center;margin-bottom:20px;color:#fff}.auth-tabs{display:flex;background:#ffffff0d;border:1px solid var(--border);border-radius:var(--radius-pill);padding:4px;margin-bottom:20px}.auth-tab{flex:1;padding:8px 0;border-radius:var(--radius-pill);font-size:.84rem;font-weight:700;color:var(--text-secondary);transition:var(--transition);cursor:pointer;background:none;border:none}.auth-tab.active{background:var(--grad-accent);color:#fff;box-shadow:0 0 16px #7c3aed66}.auth-form{display:flex;flex-direction:column;gap:14px}.auth-field{display:flex;flex-direction:column;gap:6px}.auth-field span{font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-muted)}.auth-field input{padding:11px 14px;border-radius:var(--radius-md);background:#ffffff0d;border:1px solid var(--border);color:var(--text-primary);font-size:.9rem;outline:none;transition:var(--transition)}.auth-field input:focus{background:#ffffff14;border-color:var(--accent-v);box-shadow:0 0 0 3px #8b5cf61f}.auth-field input::placeholder{color:var(--text-muted)}.auth-error{font-size:.8rem;font-weight:600;color:#fda4af;background:#e11d481a;border:1px solid rgba(225,29,72,.25);border-radius:var(--radius-sm);padding:8px 12px}.auth-submit{width:100%;justify-content:center;margin-top:4px}.auth-submit:disabled{opacity:.5;cursor:not-allowed}.auth-divider{display:flex;align-items:center;gap:12px;margin:18px 0 14px;color:var(--text-muted);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase}.auth-divider:before,.auth-divider:after{content:"";flex:1;height:1px;background:var(--border)}.auth-anilist-btn{width:100%;display:flex;align-items:center;justify-content:center;gap:9px;padding:11px;border-radius:var(--radius-md);background:#06b6d41f;border:1px solid rgba(6,182,212,.35);color:#67e8f9;font-size:.86rem;font-weight:700;transition:var(--transition);cursor:pointer}.auth-anilist-btn:hover{background:#06b6d438;border-color:#06b6d499}.btn{display:inline-flex;align-items:center;gap:8px;padding:11px 24px;border-radius:var(--radius-pill);font-size:.9rem;font-weight:600;transition:var(--transition);white-space:nowrap;cursor:pointer;border:none}.btn-primary{background:var(--grad-accent);color:#fff;box-shadow:0 0 22px #7c3aed59}.btn-primary:hover{transform:translateY(-2px);box-shadow:0 4px 30px #7c3aed8c}.btn-primary:disabled{opacity:.4;cursor:not-allowed;transform:none}.btn-ghost{background:#ffffff12;color:var(--text-primary);border:1px solid var(--border)}.btn-ghost:hover{background:#ffffff1f;border-color:var(--border-bright)}.btn-ghost:disabled{opacity:.4;cursor:not-allowed}.btn-sm{padding:7px 16px;font-size:.82rem}.badge{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:var(--radius-pill);font-size:.72rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase}.badge-score{background:#f59e0b26;color:#fcd34d;border:1px solid rgba(245,158,11,.3)}.badge-green{background:#22c55e26;color:#4ade80;border:1px solid rgba(34,197,94,.3)}.badge-dark{background:#ffffff12;color:var(--text-secondary);border:1px solid var(--border)}.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:22px}.section-title{display:flex;align-items:center;gap:10px;font-size:1.2rem;font-weight:700}.section-title:before{content:"";display:block;width:4px;height:22px;background:var(--grad-accent);border-radius:2px}.section-link{font-size:.84rem;font-weight:500;color:var(--accent-v2);display:flex;align-items:center;gap:4px;transition:var(--transition)}.section-link:hover{color:var(--accent-c2);gap:8px}.anime-card{position:relative;flex-shrink:0;width:158px;border-radius:var(--radius-md);overflow:hidden;background:var(--bg-card);cursor:pointer;transition:var(--transition);border:1px solid var(--border)}.anime-card:hover{transform:translateY(-6px) scale(1.02);border-color:var(--border-bright);box-shadow:var(--shadow-hover)}.card-poster{position:relative;aspect-ratio:2/3;overflow:hidden}.card-poster img{width:100%;height:100%;object-fit:cover;transition:transform .45s ease}.anime-card:hover .card-poster img{transform:scale(1.05)}.card-overlay{position:absolute;top:0;right:0;bottom:0;left:0;background:var(--grad-card);opacity:0;transition:var(--transition)}.anime-card:hover .card-overlay{opacity:1}.card-quick{position:absolute;bottom:8px;left:8px;right:8px;opacity:0;transform:translateY(8px);transition:var(--transition)}.anime-card:hover .card-quick{opacity:1;transform:translateY(0)}.card-quick-btn{width:100%;padding:7px;border-radius:var(--radius-sm);background:var(--grad-accent);color:#fff;font-size:.78rem;font-weight:600;text-align:center}.card-body{padding:10px 10px 12px}.card-title{font-size:.84rem;font-weight:600;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-bottom:6px}.card-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.card-score{font-size:.72rem;font-weight:700;color:#6ee7b7}.card-meta-item{font-size:.7rem;color:var(--text-muted)}.scroll-row{display:flex;gap:14px;overflow-x:auto;scroll-snap-type:x mandatory;padding:12px 0;scrollbar-width:thin;scrollbar-color:var(--accent-v) transparent}.scroll-row::-webkit-scrollbar{height:4px;display:block}.scroll-row::-webkit-scrollbar-thumb{background:var(--accent-v);border-radius:99px}.scroll-row .anime-card{scroll-snap-align:start}.skeleton{background:linear-gradient(90deg,var(--bg-elevated) 25%,var(--bg-card-hover) 50%,var(--bg-elevated) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:var(--radius-md)}.skel-card{width:158px;flex-shrink:0}.skel-poster{width:100%;aspect-ratio:2/3;border-radius:var(--radius-md) var(--radius-md) 0 0}.skel-line{height:11px;margin:8px 10px;border-radius:6px}.skel-line-sm{width:55%}@keyframes shimmer{0%{background-position:200% 0}to{background-position:-200% 0}}@keyframes fadeUp{0%{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes marquee{0%{transform:translate(0)}to{transform:translate(-50%)}}@keyframes bounce{0%,to{transform:translate(-50%) translateY(0)}50%{transform:translate(-50%) translateY(6px)}}.spinner{width:34px;height:34px;border:3px solid var(--border);border-top-color:var(--accent-v);border-radius:50%;animation:spin .7s linear infinite}.loader{display:flex;justify-content:center;align-items:center;padding:60px;flex-direction:column;gap:12px;color:var(--text-muted)}.home-hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:calc(var(--header-h) + 40px) 20px 60px;text-align:center;background:radial-gradient(circle at 20% 15%,rgba(124,58,237,.26),transparent 22%),radial-gradient(circle at 80% 18%,rgba(139,92,246,.18),transparent 18%)}.hero-glow{position:absolute;top:0;right:0;bottom:0;left:0;background:radial-gradient(ellipse 70% 70% at 15% 50%,rgba(88,28,235,.35) 0%,transparent 60%),radial-gradient(ellipse 40% 40% at 85% 50%,rgba(88,28,235,.15) 0%,transparent 60%);pointer-events:none}.hero-badge{display:inline-flex;align-items:center;gap:8px;padding:6px 16px;border-radius:var(--radius-pill);background:#7c3aed1f;border:1px solid rgba(124,58,237,.3);font-size:.75rem;font-weight:700;letter-spacing:.1em;color:var(--accent-v2);margin-bottom:32px;animation:fadeUp .5s ease both;box-shadow:0 14px 32px #7c3aed1f}.hero-badge-dot{width:6px;height:6px;border-radius:50%;background:var(--accent-v)}.hero-logo-text{font-family:Rubik Dirt,Bebas Neue,sans-serif;font-size:clamp(5rem,16vw,14rem);font-weight:400;line-height:.9;margin-bottom:16px;background:linear-gradient(180deg,#fff 40%,#c8b4ffb8);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;animation:fadeUp .6s ease .1s both;text-shadow:0 0 26px rgba(124,58,237,.18)}.hero-logo-e{display:inline-block;transform:scaleX(-1)}.hero-subtitle{font-size:clamp(1.4rem,3.5vw,2.4rem);font-weight:700;color:var(--text-secondary);margin-bottom:14px;animation:fadeUp .6s ease .2s both}.hero-desc{font-size:1rem;color:var(--text-secondary);max-width:500px;margin:0 auto 32px;line-height:1.7;animation:fadeUp .6s ease .25s both}.hero-desc strong{color:var(--text-primary)}.hero-filters{display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:24px;animation:fadeUp .6s ease .3s both;flex-wrap:wrap}.hero-filter-pill{padding:7px 20px;border-radius:var(--radius-pill);background:#ffffff0f;border:1px solid var(--border);font-size:.86rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);cursor:pointer}.hero-filter-pill.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff}.hero-filter-pill:hover:not(.active){border-color:var(--border-bright);color:var(--text-primary)}.hero-search-wrap{position:relative;width:min(620px,100%);margin:0 auto 28px;animation:fadeUp .6s ease .35s both}.hero-search-input{width:100%;padding:16px 60px 16px 22px;border-radius:var(--radius-pill);background:#ffffff12;border:1px solid rgba(255,255,255,.1);color:var(--text-primary);font-size:.95rem;outline:none;transition:var(--transition)}.hero-search-input::placeholder{color:var(--text-muted)}.hero-search-input:focus{background:#ffffff1a;border-color:var(--accent-v);box-shadow:0 0 0 4px #7c3aed26}.hero-search-btn{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:42px;height:42px;border-radius:50%;background:var(--accent-v);color:#fff;display:flex;align-items:center;justify-content:center;transition:var(--transition)}.hero-search-btn:hover{background:var(--accent-v2);transform:translateY(-50%) scale(1.08)}.hero-search-btn svg{width:18px;height:18px}.hero-popular{margin-bottom:36px;animation:fadeUp .6s ease .4s both}.hero-popular-label{font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);margin-bottom:12px}.hero-popular-tags{display:flex;justify-content:center;flex-wrap:wrap;gap:8px}.hero-popular-tag{padding:6px 16px;border-radius:var(--radius-pill);background:#ffffff0d;border:1px solid var(--border);font-size:.82rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);cursor:pointer}.hero-popular-tag:hover{background:#7c3aed1f;border-color:var(--border-bright);color:var(--accent-v2)}.hero-ctas{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:12px;margin-bottom:52px;animation:fadeUp .6s ease .45s both}.hero-cta{display:inline-flex;align-items:center;gap:10px;padding:13px 26px;border-radius:var(--radius-pill);font-size:.9rem;font-weight:700;transition:var(--transition);cursor:pointer;border:1px solid transparent}.hero-cta-anime{background:var(--grad-accent);color:#fff;box-shadow:0 0 28px #7c3aed66}.hero-cta-anime:hover{transform:translateY(-2px);box-shadow:0 6px 36px #7c3aed8c}.hero-cta-manga{background:#ffffff12;border-color:var(--border);color:var(--text-primary)}.hero-cta-manga:hover{background:#ffffff1f;border-color:var(--border-bright)}.hero-cta-movies{background:#06b6d426;border-color:#06b6d466;color:#67e8f9}.hero-cta-movies:hover{background:#06b6d440;transform:translateY(-2px)}.hero-cta-music{background:#8b5cf62e;border-color:#8b5cf666;color:#a78bfa}.hero-cta-music:hover{background:#8b5cf647;transform:translateY(-2px)}.hero-stats{display:flex;align-items:center;justify-content:center;animation:fadeUp .6s ease .5s both}.hero-stat{padding:0 36px;text-align:center;position:relative}.hero-stat+.hero-stat:before{content:"";position:absolute;left:0;top:15%;bottom:15%;width:1px;background:var(--border)}.hero-stat-val{font-size:1.6rem;font-weight:800;letter-spacing:-.02em;margin-bottom:2px}.hero-stat-label{font-size:.68rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted)}.scroll-indicator{position:absolute;bottom:24px;left:50%;transform:translate(-50%);color:var(--text-muted);animation:bounce 2s ease infinite}.library-section{padding:60px 0;overflow:hidden;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.library-header{display:flex;align-items:center;justify-content:center;gap:20px;margin-bottom:28px}.library-header-line{flex:1;height:1px;background:linear-gradient(to right,transparent,var(--border-bright));max-width:200px}.library-header-line.right{background:linear-gradient(to left,transparent,var(--border-bright))}.library-header-text{font-size:.7rem;font-weight:700;letter-spacing:.15em;text-transform:uppercase;color:var(--text-muted)}.marquee-track{overflow:hidden}.marquee-row{display:flex;gap:10px;width:max-content;animation:marquee 30s linear infinite;margin-bottom:10px}.marquee-row.reverse{animation:marquee 35s linear infinite reverse}.marquee-row:last-child{margin-bottom:0}.marquee-poster{width:100px;height:140px;flex-shrink:0;border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--border);transition:var(--transition);cursor:pointer}.marquee-poster:hover{border-color:var(--border-bright);transform:scale(1.04)}.marquee-poster img{width:100%;height:100%;object-fit:cover}.feature-section{padding:80px 0}.feature-eyebrow{font-size:.75rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--accent-v2);margin-bottom:16px}.feature-heading{font-size:clamp(2rem,4vw,3.2rem);font-weight:800;line-height:1.15;margin-bottom:20px}.feature-heading .accent{color:var(--accent-v2)}.feature-desc{font-size:1rem;color:var(--text-secondary);line-height:1.75;max-width:480px;margin-bottom:40px}.feature-card{background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;max-width:560px}.feature-card-icon{width:44px;height:44px;border-radius:var(--radius-md);background:#7c3aed26;border:1px solid rgba(124,58,237,.3);display:flex;align-items:center;justify-content:center;color:var(--accent-v2);margin-bottom:16px}.feature-card-eyebrow{font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--accent-v2);margin-bottom:6px}.feature-card-title{font-size:1.1rem;font-weight:800;margin-bottom:8px}.feature-card-desc{font-size:.88rem;color:var(--text-secondary);line-height:1.7;margin-bottom:16px}.feature-track-item{display:flex;align-items:center;gap:12px;padding:12px 14px;background:var(--bg-elevated);border-radius:var(--radius-md);margin-bottom:8px}.feature-track-name{font-size:.88rem;font-weight:600}.feature-track-sub{font-size:.75rem;color:var(--text-muted)}.feature-track-badge{margin-left:auto;font-size:.72rem;font-weight:700;color:var(--accent-green)}.content-section{padding:40px 0}.content-section:first-child{padding-top:calc(var(--header-h) + 26px)}.section-sub-label{font-size:.7rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--text-muted)}.info-hero{position:relative;min-height:520px;overflow:hidden;display:flex;align-items:flex-end;padding-top:var(--header-h);background:#0a0a0a}.info-hero:before,.info-hero:after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;pointer-events:none}.info-hero:before{background:radial-gradient(120% 80% at 30% 30%,#00000040,#000000d9 70%,#0a0a0a)}.info-hero:after{background:linear-gradient(180deg,rgba(10,10,10,0) 40%,rgba(10,10,10,.85) 80%,var(--bg-base) 100%)}.info-hero-bg{position:absolute;top:0;right:0;bottom:0;left:0;background-size:cover;background-position:center;background-repeat:no-repeat;transform:scale(1.05);filter:brightness(.55)}.info-hero-inner{position:relative;z-index:2;display:flex;gap:36px;padding:96px 28px 56px;align-items:flex-end}.info-poster{flex:0 0 240px;width:240px;aspect-ratio:2/3;border-radius:14px;overflow:hidden;box-shadow:0 24px 60px #0009,0 0 0 1px #ffffff0f;background:#111;border:1px solid rgba(255,255,255,.06)}.info-poster img{width:100%;height:100%;object-fit:cover;display:block}.info-head{flex:1;min-width:0;display:flex;flex-direction:column;gap:18px}.info-eyebrow{display:inline-flex;align-items:center;gap:10px;color:#ffffffb3;font-size:.78rem;font-weight:600;letter-spacing:.18em;text-transform:uppercase}.info-eyebrow .dot{width:6px;height:6px;border-radius:50%;background:var(--accent-v);box-shadow:0 0 12px var(--accent-v)}.info-title{font-family:Poppins,Inter,sans-serif;font-size:clamp(1.8rem,3.6vw,3.2rem);font-weight:900;line-height:1.05;color:#fff;margin:0;text-shadow:0 6px 28px rgba(0,0,0,.6)}.info-subtitle{font-size:.95rem;color:#ffffffa6;font-weight:500;margin-top:-6px}.info-meta-row{display:flex;flex-wrap:wrap;gap:16px;align-items:center;color:#ffffffd9;font-size:.85rem;font-weight:600}.meta-pill{background:#ffffff14;border:1px solid rgba(255,255,255,.08);padding:6px 12px;border-radius:999px;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.meta-score{color:#fbbf24}.meta-status-airing{color:#22c55e}.info-genres{display:flex;gap:8px;flex-wrap:wrap}.info-genre-tag{background:#ffffff0f;border:1px solid rgba(255,255,255,.08);padding:5px 12px;border-radius:999px;font-size:.72rem;font-weight:600;color:#ffffffd9;cursor:pointer;transition:all .2s ease}.info-genre-tag:hover{background:#ffffff1f;transform:translateY(-1px)}.info-cta{display:flex;gap:12px;margin-top:4px;flex-wrap:wrap}.btn-watch{display:inline-flex;align-items:center;gap:10px;padding:14px 28px;border-radius:12px;background:linear-gradient(135deg,var(--accent-v) 0%,var(--accent-v2) 100%);color:#fff;font-weight:800;font-size:.95rem;letter-spacing:.04em;text-decoration:none;border:none;cursor:pointer;transition:transform .2s ease,box-shadow .2s ease;box-shadow:0 12px 30px #7c3aed59}.btn-watch:hover{transform:translateY(-2px);box-shadow:0 18px 40px #7c3aed80}.btn-secondary{display:inline-flex;align-items:center;gap:8px;padding:14px 22px;border-radius:12px;background:#ffffff14;color:#fff;font-weight:700;font-size:.9rem;text-decoration:none;border:1px solid rgba(255,255,255,.1);cursor:pointer;transition:background .2s ease}.btn-secondary:hover{background:#ffffff24}.info-body{padding:40px 28px 80px}.info-section{margin-bottom:48px}.info-section-title{display:flex;align-items:center;gap:12px;font-family:Poppins,Inter,sans-serif;font-size:1.3rem;font-weight:800;color:#fff;margin:0 0 18px}.info-section-title:before{content:"";width:4px;height:22px;border-radius:4px;background:var(--accent-v)}.info-grid-2{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:32px;align-items:flex-start}.info-desc{font-size:.96rem;line-height:1.75;color:#ffffffc7;margin:0;position:relative;max-height:22em;overflow:hidden;transition:max-height .3s ease}.info-desc.expanded{max-height:200em}.info-desc:after{content:"";position:absolute;inset:auto 0 0 0;height:3em;background:linear-gradient(180deg,rgba(10,10,10,0) 0%,var(--bg-base) 100%);pointer-events:none;transition:opacity .3s ease}.info-desc.expanded:after{opacity:0}.info-desc-toggle{display:inline-block;margin-top:8px;color:var(--accent-v2);font-weight:700;font-size:.85rem;cursor:pointer;background:none;border:none;padding:0}.info-stats{display:grid;grid-template-columns:1fr 1fr;gap:14px;background:#ffffff05;border:1px solid rgba(255,255,255,.06);border-radius:14px;padding:22px}.info-stat-row{display:flex;flex-direction:column;gap:4px}.info-stat-row .label{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:#ffffff73}.info-stat-row .value{font-size:.95rem;font-weight:700;color:#fff}.info-tags{display:flex;flex-wrap:wrap;gap:6px}.info-tag{background:#ffffff0a;border:1px solid rgba(255,255,255,.06);padding:4px 10px;border-radius:6px;font-size:.72rem;font-weight:600;color:#ffffffb3}@media (max-width: 880px){.info-hero-inner{flex-direction:column;align-items:center;text-align:center;padding:80px 22px 40px;gap:24px}.info-poster{flex:0 0 180px;width:180px}.info-meta-row,.info-genres,.info-cta{justify-content:center}.info-grid-2{grid-template-columns:1fr}.info-hero{min-height:auto}.info-body{padding:32px 22px 80px}}@media (max-width: 540px){.info-hero-inner{padding:72px 20px 36px}.info-poster{flex:0 0 150px;width:150px}.btn-watch{padding:12px 22px;font-size:.88rem}.info-body{padding:28px 20px 80px}}.countdown-wrap{display:inline-flex;flex-direction:column;gap:10px;padding:14px 18px;margin-bottom:22px;border-radius:var(--radius-md);background:#7c3aed14;border:1px solid rgba(124,58,237,.3);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);width:fit-content}.countdown-label{font-size:.72rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--accent-v2)}.countdown-cards{display:flex;gap:8px}.countdown-cell{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:58px;padding:8px 6px;border-radius:var(--radius-sm);background:#08080899;border:1px solid var(--border-bright)}.countdown-num{font-size:1.35rem;font-weight:800;font-variant-numeric:tabular-nums;line-height:1;color:#fff}.countdown-unit{font-size:.6rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted)}.info-genre-pill{padding:5px 14px;background:#ffffff12;border:1px solid var(--border);border-radius:var(--radius-pill);font-size:.8rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);cursor:pointer}.info-genre-pill:hover{border-color:var(--border-bright);color:var(--accent-v2)}.info-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.info-btn-watch{background:var(--grad-accent);color:#fff;box-shadow:0 0 24px #7c3aed59}.info-btn-watch:hover{transform:translateY(-2px);box-shadow:0 6px 32px #7c3aed8c}.info-btn-ghost{background:#ffffff14;color:var(--text-primary);border:1px solid var(--border)}.info-btn-ghost:hover{background:#ffffff24;border-color:var(--border-bright)}.info-body{padding:44px 0 80px}.info-layout{display:grid;grid-template-columns:1fr 300px;gap:48px;align-items:start}.info-section-label{font-size:1.15rem;font-weight:700;display:flex;align-items:center;gap:10px;margin-bottom:16px}.info-section-label:before{content:"";width:4px;height:20px;background:var(--grad-accent);border-radius:2px}.info-synopsis{font-size:.93rem;color:var(--text-secondary);line-height:1.8;margin-bottom:8px}.info-synopsis.clamped{display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.show-more-btn{color:var(--accent-v2);font-size:.88rem;font-weight:600;display:inline-flex;align-items:center;gap:4px;transition:var(--transition);margin-top:8px;cursor:pointer;background:none;border:none}.show-more-btn:hover{color:var(--accent-c2)}.info-tags{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0 32px}.info-tag{padding:5px 14px;background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-pill);font-size:.78rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);cursor:pointer}.info-tag:hover{border-color:var(--border-bright);color:var(--accent-v2)}.info-stats-grid{background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;display:grid;grid-template-columns:1fr 1fr}.info-stat-cell{padding:18px 20px;border-right:1px solid var(--border);border-bottom:1px solid var(--border)}.info-stat-cell:nth-child(2n){border-right:none}.info-stat-cell:nth-last-child(-n+2){border-bottom:none}.info-stat-label{font-size:.68rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-muted);margin-bottom:5px}.info-stat-value{font-size:.95rem;font-weight:700}.char-row{display:flex;gap:16px;overflow-x:auto;padding:4px 0 12px}.char-card{display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0;cursor:pointer}.char-avatar{width:80px;height:80px;border-radius:50%;overflow:hidden;border:2px solid var(--border);transition:var(--transition)}.char-avatar img{width:100%;height:100%;object-fit:cover}.char-card:hover .char-avatar{border-color:var(--border-bright);transform:scale(1.06)}.char-name{font-size:.78rem;font-weight:600;text-align:center;max-width:84px;line-height:1.3}.char-role{font-size:.68rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em}.watch-page{padding-top:var(--header-h)}.watch-container{padding:0 28px 80px;max-width:var(--max-w);margin:0 auto}.watch-layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:18px;align-items:start;padding-top:12px}.watch-main,.watch-sidebar-col{min-width:0}.player-wrapper{position:relative;width:100%;aspect-ratio:16/9;background:#080a12;border-radius:14px;overflow:hidden;border:1px solid var(--border)}.player-wrapper iframe,.player-wrapper video,.player-wrapper .custom-player{width:100%;height:100%;border:none;display:block}.player-placeholder{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;color:var(--text-muted);background:#070a12f2}.player-placeholder svg{width:52px;height:52px;opacity:.3}.player-placeholder span{font-size:.9rem;font-weight:500}.player-error-box{color:#f43f5e}.server-warning{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f59e0b12;border:1px solid rgba(245,158,11,.2);border-radius:var(--radius-md);margin:10px 0;font-size:.83rem;color:#fcd34d}.server-warning-close{margin-left:auto;opacity:.5;transition:var(--transition);font-size:1.1rem;line-height:1;cursor:pointer;border:none;background:none;color:inherit}.server-warning-close:hover{opacity:1}.watch-warn-banner{display:flex;align-items:center;gap:10px;padding:12px 14px;background:#f59e0b14;border:1px solid rgba(245,158,11,.2);border-radius:12px;margin:10px 0 14px;font-size:.83rem;color:#fcd34d}.wwb-icon{width:16px;height:16px;flex-shrink:0}.wwb-text{display:block;flex:1}.wwb-close{margin-left:auto;border:none;background:none;color:inherit;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0}.wwb-close svg{width:16px;height:16px}.kv-prefs-bar{display:flex;align-items:center;gap:18px;padding:11px 14px;background:var(--bg-surface);border:1px solid var(--border);border-radius:12px;margin:10px 0;flex-wrap:wrap}.kv-pref-btn{display:flex;align-items:center;gap:8px;padding:0;background:none;border:none;color:var(--text-secondary);font-size:.82rem;font-weight:600;cursor:pointer}.kv-pref-btn svg{opacity:.8}.kv-pref-pill{position:relative;display:inline-flex;align-items:center;width:36px;height:20px;border-radius:999px;background:#ffffff14;border:1px solid var(--border);margin-left:4px}.kv-pref-knob{position:absolute;left:3px;width:12px;height:12px;border-radius:50%;background:#fff;box-shadow:0 1px 4px #0006}.kv-pref-btn.on{color:var(--text-primary)}.kv-pref-btn.on svg{color:var(--accent-v2)}.kv-pref-pill.on{background:var(--accent-v);border-color:var(--accent-v)}.kv-pref-pill.on .kv-pref-knob{left:19px}.kv-pref-knob{transition:left .18s ease}.action-btn.is-active{color:var(--accent-v2);border-color:#7c3aed73;background:#7c3aed1f}.kv-prefs-sep{width:1px;height:16px;background:#ffffff14}.watch-player-info{padding-top:14px}.watch-ep-label{font-size:.72rem;font-weight:800;color:var(--accent-v2);letter-spacing:.12em;text-transform:uppercase;margin:0 0 8px}.watch-ep-title{font-size:1.45rem;font-weight:800;line-height:1.25;margin:0 0 8px}.watch-filler-warn{color:#facc15;font-size:.82rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;margin-top:8px}.action-bar{display:flex;align-items:center;gap:12px;padding:14px 0 18px;border-bottom:1px solid var(--border);margin-bottom:18px}.action-anime{display:flex;align-items:center;gap:12px;min-width:0;flex:1;text-decoration:none;color:inherit}.aa-poster{width:52px;height:52px;border-radius:10px;overflow:hidden;flex-shrink:0;border:1px solid rgba(255,255,255,.08)}.aa-poster img{width:100%;height:100%;object-fit:cover;display:block}.aa-info{min-width:0;display:flex;flex-direction:column;gap:4px}.aa-name{font-size:.94rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.aa-meta{font-size:.72rem;color:var(--text-muted)}.action-buttons{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto}.audio-toggle{display:flex;align-items:center;background:#ffffff0a;border:1px solid var(--border);border-radius:999px;padding:4px;gap:4px}.audio-btn{padding:6px 12px;border-radius:999px;font-size:.78rem;font-weight:700;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;transition:var(--transition)}.audio-btn.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff}.audio-btn:disabled{opacity:.5;cursor:not-allowed}.action-btn{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;background:#ffffff0f;border:1px solid var(--border);color:var(--text-primary);font-weight:700;font-size:.8rem;cursor:pointer;transition:var(--transition)}.action-btn svg{width:15px;height:15px}.action-btn:hover,.audio-btn:not(.active):hover{border-color:var(--border-bright)}.audio-lang-selected{font-size:.78rem;color:var(--text-muted)}.server-panel{background:var(--bg-surface);border:1px solid var(--border);border-radius:14px;padding:14px;margin:10px 0 0;box-shadow:0 20px 40px #0000003d}.server-panel.is-hidden{display:none}.server-panel-row{display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap}.server-group{display:flex;flex-direction:column;gap:10px;flex:1;min-width:180px}.server-group-toggle{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:10px;background:#ffffff0a;border:1px solid var(--border);color:var(--text-primary);font-weight:700;cursor:pointer}.sg-icon{flex-shrink:0}.sg-label{flex:1;text-align:left}.sg-count{font-size:.72rem;padding:2px 7px;border-radius:999px;background:#ffffff14;color:var(--text-muted)}.server-btn-row{display:flex;gap:8px;flex-wrap:wrap;padding-left:2px}.server-option{padding:7px 12px;border-radius:10px;border:1px solid var(--border);background:#ffffff08;color:var(--text-secondary);font-size:.78rem;font-weight:600;cursor:pointer}.server-option.selected{background:#7c3aed29;border-color:#7c3aed6b;color:var(--text-primary)}.server-quality-wrap{margin-left:auto}.server-quality-select{padding:8px 10px;border-radius:10px;border:1px solid var(--border);background:#ffffff0a;color:var(--text-primary)}.server-loading,.server-error{display:flex;align-items:center;gap:8px;padding-top:12px;font-size:.8rem;color:var(--text-muted)}.server-loading-dot{width:8px;height:8px;border-radius:50%;background:var(--accent-v);display:inline-block;animation:pulse 1.3s ease-in-out infinite}@keyframes pulse{0%,to{opacity:.45;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}.watch-desc-card{background:var(--bg-surface);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin-top:18px}.watch-sidebar{background:var(--bg-surface);border:1px solid var(--border);border-radius:14px;overflow:hidden;position:sticky;top:calc(var(--header-h) + 12px);max-height:calc(100vh - var(--header-h) - 24px);display:flex;flex-direction:column}.up-next-header{padding:14px 14px 0}.un-info{display:flex;flex-direction:column;gap:3px}.un-title{font-size:1rem;font-weight:800}.un-subtitle{font-size:.76rem;color:var(--accent-v2)}.next-ep-strip{padding:12px 14px 0}.next-ep-pill{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border-radius:12px;background:#7c3aed14;border:1px solid rgba(124,58,237,.22);color:var(--text-primary)}.nep-bell{width:15px;height:15px}.nep-label{font-size:.72rem;font-weight:700;color:var(--text-muted);letter-spacing:.08em;text-transform:uppercase}.nep-units{display:flex;align-items:flex-end;gap:8px}.nec-unit{display:flex;align-items:flex-end;gap:2px;font-weight:800;color:#fff}.nec-num{font-size:.9rem}.nec-u{font-size:.6rem;color:var(--text-muted)}.ep-search-wrap{padding:12px 14px 0}.ep-search-input{width:100%;padding:9px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;color:var(--text-primary);font-size:.82rem;outline:none}.ep-search-input::placeholder{color:var(--text-muted)}.ep-view-bar{display:flex;align-items:center;justify-content:space-between;padding:12px 14px 8px}.ep-view-bar-label{font-size:.68rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-muted)}.ep-view-toggle-btn{padding:5px 9px;border:1px solid var(--border);border-radius:8px;color:var(--text-secondary);font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.ep-view-toggle-btn:hover{color:var(--text-primary);border-color:var(--border-bright)}.ep-list{flex:1;overflow-y:auto;padding:0 8px 12px;scrollbar-width:thin;scrollbar-color:var(--accent-v) transparent}.ep-list::-webkit-scrollbar{width:3px;display:block}.ep-list::-webkit-scrollbar-thumb{background:var(--accent-v);border-radius:99px}.ep-list.compact{display:grid;grid-template-columns:repeat(auto-fill,minmax(54px,1fr));gap:7px;align-content:start}.ep-list.compact .ep-item{justify-content:center;margin:0;padding:9px 5px}.ep-list.compact .ep-thumb{width:auto;height:auto;background:transparent;overflow:visible}.ep-list.compact .ep-thumb img,.ep-list.compact .ep-info{display:none}.ep-list.compact .ep-thumb-label{position:static;background:transparent;padding:0;color:var(--text-secondary);font-size:.76rem}.ep-list.compact .ep-item.active .ep-thumb-label{color:var(--accent-v2)}.ep-item{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:10px;cursor:pointer;transition:var(--transition);margin-bottom:2px;border:1px solid transparent}.ep-item:hover{background:#ffffff09}.ep-item.active{background:#7c3aed1f;border-color:#7c3aed40}.ep-thumb{width:72px;height:46px;flex-shrink:0;background:var(--bg-elevated);border-radius:8px;overflow:hidden;display:flex;align-items:center;justify-content:center;font-size:.68rem;font-weight:700;color:var(--text-muted);position:relative}.ep-thumb img{width:100%;height:100%;object-fit:cover}.ep-thumb-label{position:absolute;bottom:3px;left:3px;background:#000000b3;color:#fff;font-size:.62rem;font-weight:700;padding:1px 5px;border-radius:4px}.ep-info{flex:1;min-width:0}.ep-info-title{font-size:.8rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text-primary)}.ep-info-sub{font-size:.7rem;color:var(--text-muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.audio-notice{display:flex;align-items:center;gap:10px;padding:9px 14px;background:#7c3aed14;border:1px solid rgba(124,58,237,.28);border-radius:var(--radius-md);margin:12px 0;font-size:.83rem;font-weight:500;color:var(--accent-c2);animation:fadeUp .2s ease both}.audio-notice svg{flex-shrink:0;color:var(--accent-v2)}.audio-notice .server-warning-close{margin-left:auto;color:var(--text-muted)}@media (max-width: 1024px){.watch-layout{grid-template-columns:1fr}.watch-sidebar{position:static;max-height:540px}}@media (max-width: 680px){.watch-container{padding:0 12px 60px}.watch-layout{gap:12px;padding-top:8px}.watch-warn-banner{padding:10px 12px}.action-bar{flex-wrap:wrap;border-bottom:none;padding-bottom:8px}.action-anime{width:100%}.action-buttons{width:100%;justify-content:flex-start}.watch-ep-title{font-size:1.15rem}.watch-desc-card{padding:14px 16px}.watch-sidebar{position:static;max-height:420px}.ep-thumb{width:64px;height:40px}.aa-poster{width:44px;height:44px}}.movie-search-wrap{position:relative}.movie-tabs{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.movie-tabs button{padding:8px 20px;border-radius:var(--radius-pill);background:#ffffff0f;border:1px solid var(--border);font-size:.86rem;font-weight:600;color:var(--text-secondary);transition:var(--transition);cursor:pointer}.movie-tabs button:hover{border-color:var(--border-bright);color:var(--text-primary)}.movie-tabs button.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff;box-shadow:0 0 18px #7c3aed59}.movie-sort{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:0 0 24px}.movie-sort-label{font-size:.7rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);margin-right:6px}.movie-sort button{padding:5px 13px;border-radius:var(--radius-pill);background:#ffffff0a;border:1px solid var(--border);font-size:.76rem;font-weight:600;color:var(--text-secondary);transition:var(--transition);cursor:pointer}.movie-sort button:hover{border-color:var(--border-bright);color:var(--text-primary)}.movie-sort button.active{background:#7c3aed2e;border-color:#7c3aed80;color:var(--accent-c2)}.movie-suggest{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:60;background:var(--bg-elevated);border:1px solid var(--border-bright);border-radius:var(--radius-md);box-shadow:0 18px 50px #0009;overflow:hidden;max-height:320px;overflow-y:auto}.movie-suggest-item{display:flex;align-items:center;gap:10px;width:100%;padding:11px 16px;font-size:.88rem;color:var(--text-secondary);transition:var(--transition);cursor:pointer;text-align:left;background:none;border:none;border-bottom:1px solid var(--border)}.movie-suggest-item:last-child{border-bottom:none}.movie-suggest-item:hover{background:#7c3aed1f;color:var(--text-primary)}.movie-suggest-item svg{color:var(--accent-v2);flex-shrink:0}.load-more-wrap{display:flex;justify-content:center;margin-top:36px}.load-more-btn{min-width:220px;justify-content:center}.cast-row{display:flex;gap:14px;overflow-x:auto;padding:4px 0 14px;scrollbar-width:thin;scrollbar-color:var(--accent-v) transparent}.cast-chip{flex-shrink:0;width:96px;text-align:center;cursor:default}.cast-chip img{width:72px;height:72px;border-radius:50%;object-fit:cover;margin:0 auto 8px;border:2px solid var(--border);transition:var(--transition)}.cast-chip:hover img{border-color:var(--border-bright);transform:scale(1.06)}.cast-avatar-fallback{width:72px;height:72px;border-radius:50%;margin:0 auto 8px;background:var(--grad-accent);display:flex;align-items:center;justify-content:center;font-size:1.4rem;font-weight:800;color:#fff}.cast-chip-name{font-size:.76rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.cast-chip-role{font-size:.66rem;color:var(--text-muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ep-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(76px,1fr));gap:10px}.ep-grid-btn{display:flex;flex-direction:column;align-items:center;gap:6px;padding:12px 8px;background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-md);transition:var(--transition);cursor:pointer;color:var(--text-secondary)}.ep-grid-btn:hover{background:#7c3aed1f;border-color:var(--border-bright);color:var(--text-primary);transform:translateY(-2px)}.ep-grid-num{font-size:.82rem;font-weight:700}.ep-grid-play{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:#7c3aed2e;color:var(--accent-v2);transition:var(--transition)}.ep-grid-btn:hover .ep-grid-play{background:var(--accent-v);color:#fff}.player-error{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--text-muted);padding:20px;text-align:center}.player-error .big{font-size:2.6rem}.player-error p{font-size:.9rem;max-width:420px}.custom-player{position:relative;width:100%;height:100%;background:#000;outline:none;overflow:hidden;cursor:default}.custom-player video{width:100%;height:100%;object-fit:contain;background:#000}.custom-player video::-webkit-media-controls{display:none!important}.cp-fade{position:absolute;left:0;right:0;bottom:0;height:130px;background:linear-gradient(to top,rgba(0,0,0,.88),transparent);pointer-events:none;transition:opacity .25s ease;z-index:3}.cp-controls{position:absolute;left:0;right:0;bottom:0;padding:14px 16px 12px;display:flex;flex-direction:column;gap:8px;transition:opacity .25s ease,transform .25s ease;z-index:5}.cp-controls.hide,.cp-fade.hide{opacity:0;pointer-events:none;transform:translateY(10px)}.cp-seek{position:relative;height:18px;display:flex;align-items:center;cursor:pointer;touch-action:none}.cp-seek-track{position:absolute;left:0;right:0;height:4px;border-radius:99px;background:#ffffff38;transition:height .15s ease}.cp-seek:hover .cp-seek-track{height:6px}.cp-buffered{position:absolute;left:0;height:4px;border-radius:99px;background:#ffffff52;transition:height .15s ease}.cp-seek:hover .cp-buffered{height:6px}.cp-progress{position:absolute;left:0;height:4px;border-radius:99px;background:var(--accent-v);box-shadow:0 0 12px #7c3aeda6;transition:height .15s ease}.cp-seek:hover .cp-progress{height:6px}.cp-thumb{position:absolute;top:50%;width:13px;height:13px;border-radius:50%;background:#fff;transform:translate(-50%,-50%);opacity:0;transition:opacity .15s ease;box-shadow:0 1px 6px #00000080;pointer-events:none}.cp-seek:hover .cp-thumb{opacity:1}.cp-hover-time{position:absolute;top:-28px;transform:translate(-50%);background:#0a0a12eb;border:1px solid var(--border-bright);color:#fff;font-size:.72rem;font-weight:600;padding:2px 8px;border-radius:6px;pointer-events:none;white-space:nowrap}.cp-row{display:flex;align-items:center;gap:4px}.cp-btn{width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#ffffffeb;transition:var(--transition);background:transparent;flex-shrink:0}.cp-btn:hover{background:#ffffff1f;color:#fff}.cp-btn.active{color:var(--accent-v2);background:#7c3aed38}.cp-time{font-size:.8rem;font-weight:600;color:#ffffffd9;font-variant-numeric:tabular-nums;margin-left:6px;white-space:nowrap}.cp-time em{font-style:normal;color:#fff6;margin:0 4px}.cp-spacer{flex:1}.cp-vol-slider{width:74px;accent-color:var(--accent-v);cursor:pointer;flex-shrink:0}.cp-menu{position:absolute;bottom:56px;right:12px;background:#0c0c14f2;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border:1px solid var(--border-bright);border-radius:var(--radius-md);padding:8px 10px 10px;min-width:172px;box-shadow:0 16px 44px #0009;z-index:10;animation:fadeUp .18s ease both;max-height:min(340px,60vh);overflow-y:auto}.cp-menu-title{font-size:.64rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);padding:8px 10px 4px}.cp-menu-title:first-child{padding-top:2px}.cp-menu-item{display:block;width:100%;text-align:left;padding:7px 10px;border-radius:var(--radius-sm);font-size:.82rem;font-weight:600;color:var(--text-secondary);transition:var(--transition);cursor:pointer;background:none;border:none}.cp-menu-item:hover{background:#ffffff0f;color:var(--text-primary)}.cp-menu-item.active{background:var(--accent-v);color:#fff}.cp-menu-note{font-size:.7rem;color:var(--text-muted);padding:8px 10px 2px}.cp-center-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:76px;height:76px;border-radius:50%;background:#0a0a1299;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.18);color:#fff;display:flex;align-items:center;justify-content:center;transition:var(--transition);z-index:4}.cp-center-play:hover{background:var(--accent-v);border-color:var(--accent-v);transform:translate(-50%,-50%) scale(1.08);box-shadow:0 0 34px #7c3aed99}.skip-btn{position:absolute;right:18px;bottom:78px;z-index:6;padding:9px 18px;border-radius:var(--radius-pill);background:#0c0c14d9;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border:1px solid var(--border-bright);color:#fff;font-size:.84rem;font-weight:700;opacity:0;transform:translateY(10px);pointer-events:none;transition:var(--transition);display:flex;align-items:center;gap:6px}.skip-btn.show{opacity:1;transform:translateY(0);pointer-events:auto}.skip-btn:hover{background:var(--accent-v);border-color:var(--accent-v)}.movie-quality-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:12px 16px;background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-md);margin:10px 0}.movie-quality-label{font-size:.7rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);margin-right:4px}.movie-quality-bar button{padding:5px 14px;border-radius:var(--radius-pill);font-size:.78rem;font-weight:700;background:#ffffff0f;border:1px solid var(--border);color:var(--text-secondary);transition:var(--transition);cursor:pointer}.movie-quality-bar button:hover{border-color:var(--border-bright);color:var(--text-primary)}.movie-quality-bar button.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff}.genre-nav-wrap{position:relative}.row-nav-btn{position:absolute;top:50%;transform:translateY(-50%);z-index:6;width:38px;height:38px;border-radius:50%;background:#0a0a12d9;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border:1px solid var(--border-bright);color:var(--text-primary);display:flex;align-items:center;justify-content:center;opacity:0;transition:var(--transition);cursor:pointer;box-shadow:0 6px 20px #00000080}.genre-nav-wrap:hover .row-nav-btn{opacity:1}.row-nav-btn:hover{background:var(--accent-v);border-color:var(--accent-v);transform:translateY(-50%) scale(1.08)}.row-nav-btn.prev{left:-14px}.row-nav-btn.next{right:-14px}.cw-subtitle{font-size:.78rem;font-weight:500;color:var(--text-muted)}.cw-card{position:relative;flex-shrink:0;width:250px;border-radius:var(--radius-md);overflow:hidden;background:var(--bg-card);cursor:pointer;transition:var(--transition);border:1px solid var(--border)}.cw-card:hover{transform:translateY(-4px);border-color:var(--border-bright);box-shadow:var(--shadow-hover)}.cw-poster{position:relative;aspect-ratio:16/9;overflow:hidden;background:var(--bg-elevated)}.cw-poster img{width:100%;height:100%;object-fit:cover;transition:transform .45s ease}.cw-card:hover .cw-poster img{transform:scale(1.06)}.cw-overlay{position:absolute;top:0;right:0;bottom:0;left:0;background:#08080c8c;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition:var(--transition);display:flex;align-items:center;justify-content:center;gap:8px;color:#fff;font-size:.86rem;font-weight:700}.cw-card:hover .cw-overlay{opacity:1}.cw-overlay svg{width:20px;height:20px}.cw-badge{position:absolute;top:8px;left:8px;padding:2px 8px;border-radius:var(--radius-pill);background:#080808c7;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);border:1px solid var(--border);color:#fff;font-size:.64rem;font-weight:700;letter-spacing:.04em}.cw-progress{position:absolute;left:0;right:0;bottom:0;height:4px;background:#ffffff29}.cw-progress-fill{height:100%;background:var(--grad-accent);box-shadow:0 0 8px #7c3aed99}.cw-remove{position:absolute;top:6px;right:6px;width:22px;height:22px;border-radius:50%;background:#080808cc;border:1px solid var(--border);color:var(--text-secondary);font-size:.9rem;line-height:1;display:flex;align-items:center;justify-content:center;opacity:0;transition:var(--transition);cursor:pointer;padding:0}.cw-card:hover .cw-remove{opacity:1}.cw-remove:hover{background:#e11d48;border-color:#e11d48;color:#fff}.cw-body{padding:10px 12px 12px}.cw-body h3{font-size:.88rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.cw-meta{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.7rem;color:var(--text-muted)}.cw-meta-left{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.cw-left{color:var(--accent-v2);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}.spotlight-featured{position:relative;width:100%;min-height:500px;border-radius:16px;overflow:hidden;background:var(--bg-elevated) center/cover no-repeat;border:1px solid var(--border);cursor:pointer;transition:var(--transition)}.spotlight-featured:hover{transform:translateY(-4px);border-color:var(--border-bright);box-shadow:var(--shadow-hover)}.spotlight-featured-grad{position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(180deg,#08080c14,#08080c3d 34%,#08080cb8 75%,#08080ce6)}.spotlight-featured-body{position:absolute;left:0;right:0;bottom:0;padding:26px 26px 24px;z-index:2}.spotlight-featured-meta-row{display:flex;align-items:center;gap:10px;font-size:.72rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#f8d6d6;opacity:.9;margin-bottom:12px}.spotlight-featured-bubble{display:inline-flex;align-items:center;justify-content:center;margin-bottom:12px}.spotlight-featured-bubble-inner{display:flex;flex-direction:column;align-items:flex-start;padding:11px 16px;border-radius:999px;background:#123a8261;border:1px solid rgba(255,255,255,.18);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.spotlight-featured-bubble-kicker{font-size:.68rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#dbeafe}.spotlight-featured-bubble-title{font-size:1rem;font-weight:800;color:#f2fbff;line-height:1.15}.spotlight-featured-title{font-family:Bebas Neue,Outfit,sans-serif;font-size:clamp(2.2rem,4.6vw,4.4rem);font-weight:400;line-height:.9;margin:0 0 10px;text-shadow:0 8px 30px rgba(0,0,0,.7);letter-spacing:.02em;max-width:52%}.spotlight-featured-caption{max-width:48%;font-size:.9rem;color:#fffc;line-height:1.55;margin-bottom:18px}.spotlight-featured-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.spotlight-featured-btn{position:relative;z-index:2}.spotlight-featured-btn.secondary{background:#ffffff14;border:1px solid rgba(255,255,255,.12);color:#fff}.spotlight-slide{animation:spotFade .5s ease both}@keyframes spotFade{0%{opacity:0;transform:scale(1.03)}to{opacity:1;transform:scale(1)}}.spotlight-nav-btn{position:absolute;top:50%;transform:translateY(-50%);z-index:3;width:36px;height:36px;border-radius:50%;background:#0808089e;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.16);color:#fff;display:flex;align-items:center;justify-content:center;opacity:.7;transition:var(--transition);cursor:pointer}.spotlight-featured:hover .spotlight-nav-btn{opacity:1}.spotlight-nav-btn:hover{background:var(--accent-v);border-color:var(--accent-v);transform:translateY(-50%) scale(1.08)}.spotlight-nav-btn.prev{left:12px}.spotlight-nav-btn.next{right:12px}.spotlight-dots{position:absolute;bottom:14px;left:0;right:0;display:flex;justify-content:center;gap:6px;z-index:3}.spotlight-dot{width:7px;height:7px;border-radius:50%;background:#ffffff59;transition:var(--transition);cursor:pointer;padding:0}.spotlight-dot.active{background:var(--accent-v2);width:20px;border-radius:99px;box-shadow:0 0 10px #7c3aed99}.genre-section{padding-top:8px;padding-bottom:12px}.genre-row-wrap{position:relative;display:flex;align-items:center;gap:12px}.genre-row{display:flex;align-items:center;gap:10px;overflow-x:auto;scrollbar-width:none;padding:6px 0}.genre-row::-webkit-scrollbar{display:none}.genre-pill{padding:8px 16px;border-radius:var(--radius-pill);background:#ffffff09;border:1px solid var(--border);color:var(--text-secondary);font-size:.82rem;font-weight:600;transition:var(--transition);cursor:pointer;white-space:nowrap}.genre-pill:hover{background:#7c3aed1f;border-color:var(--border-bright);color:var(--text-primary)}.home-dashboard{padding-top:4px}.home-layout{display:grid;grid-template-columns:minmax(0,1.9fr) 300px;gap:22px;align-items:start}.home-main-column{display:flex;flex-direction:column;gap:10px}.home-sidebar{display:flex;flex-direction:column;gap:18px;position:sticky;top:88px}.sidebar-panel{background:#ffffff05;border:1px solid var(--border);border-radius:12px;padding:18px 14px}.sidebar-panel h3{font-size:.96rem;font-weight:800;letter-spacing:.02em;margin:0 0 14px}.sidebar-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}.sidebar-list li{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.04);cursor:pointer}.sidebar-list li:last-child{border-bottom:none}.sidebar-rank{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:8px;background:var(--grad-accent);font-size:.72rem;font-weight:800;color:#fff;flex-shrink:0}.sidebar-rank.alt{background:linear-gradient(135deg,#0ea5e9e6,#7c3aede6)}.sidebar-info{display:flex;flex-direction:column;min-width:0}.sidebar-info strong{font-size:.8rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-info small{font-size:.67rem;color:var(--text-muted);margin-top:2px}.landing-enter{position:relative;padding:90px 24px;overflow:hidden;text-align:center;border-top:1px solid var(--border)}.landing-enter-glow{position:absolute;top:0;right:0;bottom:0;left:0;background:radial-gradient(ellipse 55% 80% at 50% 110%,rgba(124,58,237,.3) 0%,transparent 65%);pointer-events:none}.landing-enter-inner{position:relative;max-width:560px;margin:0 auto}.landing-enter-label{font-size:.72rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase;color:var(--accent-v2);margin-bottom:14px}.landing-enter-title{font-family:Bebas Neue,Outfit,sans-serif;font-size:clamp(2rem,4vw,3.2rem);font-weight:400;line-height:1.05;margin-bottom:12px}.landing-enter-desc{font-size:.95rem;color:var(--text-secondary);line-height:1.7;margin-bottom:26px}.empty{padding:40px;text-align:center;color:var(--text-muted)}.empty .big{font-size:2.6rem;margin-bottom:12px}.empty p{font-size:.9rem}.card-rank{position:absolute;top:8px;left:8px;z-index:2;width:26px;height:26px;border-radius:8px;background:var(--grad-accent);color:#fff;font-size:.78rem;font-weight:800;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px #00000080}.card-type-badge{position:absolute;top:8px;right:8px;z-index:2;padding:3px 9px;border-radius:var(--radius-pill);background:#080808b8;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);border:1px solid var(--border-bright);color:var(--accent-c2);font-size:.62rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.card-type-badge.coming-soon{background:#f59e0b2e;border-color:#f59e0b8c;color:#fcd34d}.card-quick-btn.coming-soon{background:#f59e0be6;color:#1a1205}.card-ep-badge{position:absolute;top:8px;left:8px;z-index:2;padding:3px 9px;border-radius:var(--radius-pill);background:#080808b8;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);border:1px solid var(--border);color:#fff;font-size:.62rem;font-weight:700;letter-spacing:.04em}#root .mobile-nav{display:flex!important;position:fixed;left:0;right:0;bottom:0;z-index:120;height:calc(60px + env(safe-area-inset-bottom));padding-bottom:env(safe-area-inset-bottom);background:#0a0a12f5;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);border-top:1px solid var(--border);justify-content:space-around;align-items:stretch;box-shadow:0 -16px 34px #0a0a0ea6}#root .mobile-nav-item{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;color:var(--text-muted);font-size:.6rem;font-weight:600;letter-spacing:.02em;transition:var(--transition)}#root .mobile-nav-item.active{color:var(--accent-v2);background:linear-gradient(180deg,#7c3aed1f,#7c3aed05)}#root .mobile-nav-item.active svg{filter:drop-shadow(0 0 8px rgba(124,58,237,.6))}#root .mobile-nav-item:active{transform:scale(.94)}@media (max-width:1024px){.search-wrap{max-width:260px}nav{gap:0}nav a{padding:6px 10px;font-size:.8rem}.container{padding:0 20px}.page-shell{padding-top:calc(var(--header-h) + 24px);padding-bottom:64px}.catalog-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px 14px}.watch-grid,.info-layout{grid-template-columns:1fr}.watch-sidebar{position:static;max-height:500px}.info-hero-body{flex-direction:column;align-items:flex-start}.spotlight-featured{min-height:340px}}@media (max-width:900px){.main-content{padding-bottom:calc(66px + env(safe-area-inset-bottom))}.home-hero{padding-bottom:110px}.scroll-indicator{bottom:calc(80px + env(safe-area-inset-bottom))}.landing-enter{padding-bottom:120px}}@media (max-width:680px){.container{padding:0 16px}nav{display:none}.page-shell{padding-top:calc(var(--header-h) + 18px);padding-bottom:60px}.catalog-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:16px 12px}.search-shell{gap:12px}#header .container{gap:10px}.logo-wordmark .l{font-size:1.6rem}.search-wrap{display:none}.auth-avatar-btn{padding:5px 7px;gap:0;border-radius:14px}.auth-avatar-btn span,.auth-avatar-btn>svg:last-child{display:none}.auth-avatar-circle{width:30px;height:30px}.auth-dropdown{min-width:220px;max-width:calc(100vw - 24px)}.content-section{padding:26px 0}.section-header{margin-bottom:14px}.section-title{font-size:1.02rem}.section-title:before{height:19px}.scroll-row{gap:10px}.anime-card{width:138px;border-radius:var(--radius-sm)}.skel-card{width:138px}.card-body{padding:8px 9px 10px}.card-title{font-size:.78rem}.cw-card{width:198px}.spotlight-featured{min-height:262px;border-radius:14px}.spotlight-featured-body{padding:12px 12px 38px}.spotlight-featured-score{font-size:.68rem;margin-bottom:4px}.spotlight-featured-title{font-size:1.28rem;margin-bottom:4px}.spotlight-featured-meta{font-size:.7rem;margin-bottom:10px}.spotlight-featured-btn{padding:8px 15px;font-size:.76rem}.spotlight-nav-btn{opacity:1;width:30px;height:30px}.spotlight-nav-btn.prev{left:8px}.spotlight-nav-btn.next{right:8px}.spotlight-dots{bottom:9px;gap:5px}.spotlight-dot{width:6px;height:6px}.spotlight-dot.active{width:16px}.genre-row-wrap,.genre-row{gap:8px}.genre-pill{padding:7px 12px;font-size:.74rem}.home-layout{grid-template-columns:1fr;gap:14px}.home-sidebar{position:static}.sidebar-panel{padding:14px 12px}.btn{padding:10px 18px;font-size:.85rem}.btn-sm{padding:6px 13px;font-size:.78rem}.section-link{font-size:.78rem}.home-hero{padding:calc(var(--header-h) + 30px) 14px 0}.home-hero{padding-bottom:110px}.hero-badge{padding:5px 13px;font-size:.66rem;margin-bottom:22px}.hero-logo-text{font-size:clamp(3.2rem,17vw,5.6rem);margin-bottom:12px}.hero-subtitle{font-size:clamp(1.05rem,3.6vw,1.45rem);margin-bottom:10px}.hero-desc{font-size:.88rem;margin-bottom:22px}.hero-filters{gap:6px;margin-bottom:18px}.hero-filter-pill{padding:6px 14px;font-size:.8rem}.hero-search-input{padding:12px 52px 12px 16px;font-size:.9rem}.hero-search-btn{width:38px;height:38px}.hero-ctas{gap:9px;margin-bottom:32px}.hero-cta{padding:11px 17px;font-size:.84rem;gap:7px}.hero-stat{padding:0 14px}.hero-stat-val{font-size:1.15rem}.hero-stat-label{font-size:.6rem}.library-section{padding:44px 0}.library-header{gap:12px;margin-bottom:20px}.library-header-text{font-size:.62rem}.library-header-line{max-width:90px}.marquee-poster{width:86px;height:120px}.marquee-row{gap:8px}.feature-section{padding:48px 0}.feature-eyebrow{font-size:.7rem;margin-bottom:12px}.feature-heading{font-size:1.7rem;line-height:1.2;margin-bottom:14px}.feature-desc{font-size:.9rem;margin-bottom:26px}.feature-card{padding:18px}.landing-enter{padding:56px 18px 118px}.landing-enter-title{font-size:1.9rem}.landing-enter-desc{font-size:.88rem;margin-bottom:20px}.info-hero{min-height:320px}.info-hero-content{padding:20px 0}.info-hero-body{gap:16px}.info-poster{width:108px}.info-title{font-size:1.7rem;margin-bottom:10px}.info-type-tag{font-size:.72rem;margin-bottom:8px}.info-badges{margin-bottom:12px}.info-genres{gap:6px;margin-bottom:16px}.info-genre-pill{padding:4px 11px;font-size:.74rem}.info-actions{gap:8px}.countdown-wrap{padding:10px 14px;gap:8px;margin-bottom:16px}.countdown-cell{min-width:50px;padding:6px 4px}.countdown-num{font-size:1.05rem}.countdown-unit{font-size:.56rem}.info-body{padding:26px 0 60px}.info-layout{gap:30px}.info-section-label{font-size:1.02rem}.info-synopsis{font-size:.88rem}.info-stat-cell{padding:13px 14px}.char-avatar{width:64px;height:64px}.char-name{font-size:.72rem;max-width:72px}.info-btn-watch{padding:10px 20px}.watch-container{padding:0 12px 60px}.watch-grid{gap:12px;padding-top:8px}.watch-anime-row{flex-wrap:wrap}.watch-info-card{padding:14px 16px}.watch-info-title{font-size:.95rem}.ep-title{font-size:1.1rem;margin-bottom:12px}.prefs-bar{padding:10px 12px;gap:12px}.pref-item{font-size:.78rem}.audio-btn,.server-btn{padding:4px 10px;font-size:.74rem}.audio-notice{margin:10px 0;font-size:.78rem;padding:8px 12px}.watch-anime-name{font-size:.85rem}.sidebar-upnext{font-size:.92rem}.ep-thumb{width:64px;height:40px}.ep-item{padding:6px}.ep-info-title{font-size:.76rem}.ep-grid{grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:8px}.movie-quality-bar{padding:10px 12px;gap:6px}.movie-quality-bar button{padding:4px 11px;font-size:.72rem}.movie-tabs{flex-wrap:nowrap;overflow-x:auto;padding-bottom:4px;scrollbar-width:none}.movie-tabs::-webkit-scrollbar{display:none}.movie-tabs button{white-space:nowrap;padding:7px 16px;font-size:.8rem}.movie-sort button{padding:5px 11px;font-size:.72rem}.load-more-btn{min-width:170px}.cp-controls{padding:10px 8px 8px}.cp-btn{width:34px;height:34px}.cp-vol-slider{width:52px}.cp-time{font-size:.72rem}.cp-menu{right:6px;bottom:52px;min-width:150px}.cp-center-play{width:60px;height:60px}.skip-btn{bottom:72px;right:12px;padding:8px 14px;font-size:.8rem}.auth-overlay{padding:0;align-items:flex-end}.auth-modal{max-width:100%;border-radius:20px 20px 0 0;padding:24px 20px calc(20px + env(safe-area-inset-bottom));animation:sheetUp .25s ease both}@keyframes sheetUp{0%{transform:translateY(44px);opacity:0}to{transform:translateY(0);opacity:1}}}@media (max-width:420px){.anime-card,.skel-card{width:126px}.cw-card{width:172px}.marquee-poster{width:74px;height:104px}.info-poster{width:94px}.hero-logo-text{font-size:3rem}.hero-ctas{flex-direction:column}.hero-cta{width:100%;justify-content:center}.spotlight-featured{min-height:240px}.auth-dropdown{left:12px;right:12px;min-width:0}}#hero{position:relative;height:56vh;min-height:600px;max-height:720px;overflow:hidden;background:var(--bg-base)}#hero .hero-slide{position:absolute;top:0;right:0;bottom:0;left:0;opacity:0;transition:opacity .7s ease;pointer-events:none;will-change:opacity}#hero .hero-slide.active{opacity:1;pointer-events:auto}#hero .hero-bg{position:absolute;top:0;right:0;bottom:0;left:0}#hero .hero-bg img{width:100%;height:100%;object-fit:cover;object-position:center top;transform:scale(1.025);transition:transform 8s ease;filter:saturate(.9) contrast(1.04)}#hero .hero-slide.active .hero-bg img{transform:scale(1)}#hero .hero-gradient{position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(90deg,#06070af5,#06070aa3 40%,#06070a1a 72%),linear-gradient(0deg,rgba(6,7,10,.98) 0%,transparent 52%)}#hero .hero-content{position:relative;z-index:2;height:100%;display:flex;align-items:flex-end;padding:calc(var(--header-h) + 20px) 0 72px}#hero .hero-content .container{width:100%}#hero .hero-layout{display:flex;align-items:center;justify-content:space-between;gap:40px;width:100%}#hero .hero-info{max-width:560px;animation:fadeUp .6s ease both}#hero .hero-title-block{display:flex;flex-direction:column;align-items:flex-start;gap:10px}#hero .hero-badges{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px}#hero .hero-title{font-size:clamp(2rem,4vw,3.35rem);font-weight:800;line-height:1.12;letter-spacing:-.035em;margin-bottom:14px;text-wrap:balance}#hero .hero-desc{font-size:.95rem;color:var(--text-secondary);line-height:1.7;margin:0 0 18px;max-width:470px;display:-webkit-box;-webkit-line-clamp:3;line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;text-align:left}#hero .hero-genres{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}#hero .hero-genre-tag{padding:4px 10px;border-radius:var(--radius-pill);background:#ffffff14;border:1px solid rgba(255,255,255,.1);font-size:.75rem;font-weight:600;color:var(--text-secondary);transition:var(--transition)}#hero .hero-genre-tag:hover{background:#7c3aed33;border-color:var(--accent-v);color:var(--accent-v2)}#hero .hero-actions{display:flex;gap:12px;flex-wrap:wrap}#hero .hero-stats{display:flex;gap:20px;margin-bottom:14px;flex-wrap:wrap}#hero .hero-stat{display:flex;align-items:center;gap:6px;font-size:.82rem;color:var(--text-secondary)}#hero .hero-stat svg{color:var(--accent-v2);flex-shrink:0}#hero .hero-cover-wrap{position:relative;flex-shrink:0;animation:fadeIn .8s ease .3s both}#hero .hero-cover{width:190px;aspect-ratio:2/3;object-fit:cover;border-radius:var(--radius-lg);box-shadow:0 8px 48px #0009,0 0 0 1px var(--border);display:block}#hero .hero-cover-glow{position:absolute;top:-14px;right:-14px;bottom:-14px;left:-14px;border-radius:32px;background:radial-gradient(ellipse at center,rgba(139,92,246,.28),transparent 70%);z-index:-1;animation:glow-pulse 3s ease infinite}#hero .hero-prev,#hero .hero-next{position:absolute;top:50%;z-index:5;width:48px;height:48px;border-radius:50%;background:#0a0a12b8;border:1px solid var(--border);display:flex;align-items:center;justify-content:center;color:var(--text-primary);transition:var(--transition);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);opacity:0;pointer-events:none}#hero .hero-prev{left:40px;transform:translateY(-50%)}#hero .hero-next{right:40px;transform:translateY(-50%)}#hero:hover .hero-next,#hero:hover .hero-prev{opacity:1;pointer-events:auto}#hero .hero-prev:hover{background:#7c3aed59;border-color:var(--accent-v);transform:translate(-4px,-50%)}#hero .hero-next:hover{background:#7c3aed59;border-color:var(--accent-v);transform:translate(4px,-50%)}#hero .hero-indicators{position:absolute;bottom:25px;left:50%;transform:translate(-50%);display:flex;gap:8px;z-index:5}#hero .hero-dot{width:8px;height:8px;border-radius:99px;background:#ffffff4d;transition:var(--transition)}#hero .hero-dot.active{width:28px;background:var(--accent-v)}.genre-bar{display:flex;align-items:center;gap:8px;overflow-x:auto;padding:14px 0 12px;border-bottom:1px solid var(--border);scrollbar-width:none;margin-bottom:24px}.genre-bar::-webkit-scrollbar{display:none}.genre-pill{flex-shrink:0;padding:6px 14px;border-radius:var(--radius-pill);background:#ffffff0f;border:1px solid rgba(255,255,255,.08);font-size:.8rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);white-space:nowrap}.genre-pill:hover{background:#7c3aed2e;border-color:var(--accent-v);color:var(--accent-v2);transform:translateY(-1px)}.home-grid{display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:28px;align-items:start}.home-sections{min-width:0}.home-grid .sidebar-widget{position:sticky;top:calc(var(--header-h) + 16px);background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-md);overflow:hidden}.home-grid .sidebar-tabs{display:flex;gap:16px;padding:14px 16px 0;border-bottom:1px solid var(--border);background:#ffffff05}.home-grid .sidebar-tab{padding:0 0 10px;font-size:.82rem;font-weight:700;text-transform:uppercase;color:var(--text-secondary);border-bottom:2px solid transparent;transition:var(--transition)}.home-grid .sidebar-tab:hover{color:var(--text-primary)}.home-grid .sidebar-tab.active{color:var(--accent-v2);border-bottom-color:var(--accent-v)}.home-grid .sidebar-item{display:flex;align-items:center;gap:12px;width:100%;padding:12px 16px;transition:var(--transition);border-bottom:1px solid var(--border);color:inherit;text-align:left}.home-grid .sidebar-item:last-child{border-bottom:none}.home-grid .sidebar-item:hover{background:var(--bg-elevated)}.home-grid .sidebar-thumb{width:50px;height:68px;object-fit:cover;border-radius:6px;flex-shrink:0}.home-grid .sidebar-rank{font-size:.72rem;font-weight:800;color:var(--text-muted);width:18px;flex-shrink:0}.home-grid .sidebar-title{font-size:.86rem;font-weight:600;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.home-grid .sidebar-meta{font-size:.7rem;color:var(--text-muted);margin-top:4px}.catalog-grid{grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:22px}@media (max-width:1024px){.home-grid{grid-template-columns:1fr}.home-grid .sidebar-widget{position:static;margin-top:20px}}@media (max-width:680px){#hero{height:620px;min-height:0}#hero .hero-content{align-items:flex-end;padding:calc(var(--header-h) + 18px) 0 58px}#hero .hero-cover-wrap,#hero .hero-prev,#hero .hero-next{display:none}#hero .hero-title{font-size:clamp(1.8rem,9vw,2.5rem)}#hero .hero-desc{-webkit-line-clamp:4;line-clamp:4}#hero .hero-indicators{bottom:22px}.genre-bar{margin-bottom:10px}}@media (max-width:600px){.catalog-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.catalog-grid .anime-card{width:100%}}@media (min-width:601px) and (max-width:768px){.anime-card{width:130px}}@media (max-width:420px){.anime-card{width:126px}}.watch-desc-card{display:flex;gap:18px;align-items:flex-start;position:relative;isolation:isolate;overflow:hidden;background:linear-gradient(135deg,#7c3aed29,#0d0d14f5 38%,#0b0b11fa);border-color:#8b5cf642;box-shadow:0 18px 42px #0003}.watch-desc-card:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:radial-gradient(circle at 0 0,rgba(167,139,250,.15),transparent 42%);z-index:-1}.watch-info-poster{width:92px;aspect-ratio:2/3;object-fit:cover;border-radius:10px;box-shadow:0 10px 24px #0000006b;flex-shrink:0}.watch-info-body{min-width:0;flex:1}.watch-info-kicker{font-size:.66rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--accent-v2);margin-bottom:5px}.watch-info-title{font-size:1.22rem;font-weight:800;line-height:1.22;letter-spacing:-.02em;margin-bottom:10px}.watch-info-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin-bottom:12px}.watch-meta-item,.watch-meta-score{display:inline-flex;align-items:center;min-height:23px;padding:3px 8px;border-radius:999px;background:#ffffff0e;border:1px solid rgba(255,255,255,.075);font-size:.7rem;font-weight:700;color:var(--text-secondary)}.watch-meta-score{color:#fcd34d;background:#f59e0b1a;border-color:#f59e0b33}.watch-meta-dot{display:none}.watch-genres{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 11px}.watch-genre-pill{padding:3px 8px;border-radius:999px;background:#7c3aed21;border:1px solid rgba(124,58,237,.22);color:#c4b5fd;font-size:.68rem;font-weight:700}.watch-synopsis{margin:0;color:var(--text-secondary);font-size:.86rem;line-height:1.62;display:-webkit-box;-webkit-line-clamp:3;line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}@media (max-width:600px){.watch-desc-card{gap:12px;padding:14px}.watch-info-poster{width:72px;border-radius:8px}.watch-info-title{font-size:1.04rem}.watch-synopsis{-webkit-line-clamp:4;line-clamp:4}}
frontend/dist/assets/index-BYVU7BLe.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/index.html ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="anidoom - Stream thousands of anime series free in HD. Subbed and dubbed. No ads, no limits." />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=Bebas+Neue&family=Rubik+Dirt&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <title>anidoom — Anime, Manga, Movies &amp; Music in One Place</title>
14
+ <script type="module" crossorigin src="/assets/index-BYVU7BLe.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BOX6jOo8.css">
16
+ </head>
17
+ <body>
18
+ <div id="root"></div>
19
+ </body>
20
+ </html>
frontend/dist/music-api.js ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // music-api.js — fetchAPI bridge for music.js
2
+ // Connects music.js directly to AnimeThemes API v2.
3
+
4
+ (function () {
5
+ const AT = 'https://api.animethemes.moe';
6
+
7
+ async function _get(url) {
8
+ const r = await fetch(url, { headers: { Accept: 'application/json' } });
9
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
10
+ return r.json();
11
+ }
12
+
13
+ function normalizeImages(images) {
14
+ if (!images || !Array.isArray(images)) return [];
15
+ return images.map(img => {
16
+ let link = img.link || img.url;
17
+ if (!link && img.path) link = `https://static.animethemes.moe/${img.path}`;
18
+ if (!link && img.basename) link = `https://static.animethemes.moe/${img.basename}`;
19
+ return {
20
+ facet: img.facet || 'Large Cover',
21
+ link: link || ''
22
+ };
23
+ });
24
+ }
25
+
26
+ function normalizeTheme(t, animeRef) {
27
+ if (!t) return null;
28
+ const entries = t.animethemeentries || t.entries || [];
29
+ const normEntries = entries.map(e => {
30
+ const videos = e.videos || [];
31
+ const normVideos = videos.map(v => {
32
+ let audioLink = v.audio?.link || v.link || v.url;
33
+ if (!audioLink && v.basename) audioLink = `https://v.animethemes.moe/${v.basename}`;
34
+ if (!audioLink && v.filename) audioLink = `https://v.animethemes.moe/${v.filename}`;
35
+ return {
36
+ ...v,
37
+ audio: { id: v.audio?.id || v.id || t.id || '1', link: audioLink }
38
+ };
39
+ });
40
+ return { ...e, videos: normVideos };
41
+ });
42
+ return {
43
+ ...t,
44
+ id: t.id || Math.random().toString(36).substring(7),
45
+ slug: t.slug || `${t.type || 'OP'}${t.sequence || ''}`,
46
+ type: t.type || 'OP',
47
+ animethemeentries: normEntries,
48
+ anime: animeRef || t.anime
49
+ };
50
+ }
51
+
52
+ function normalizeAnime(a) {
53
+ if (!a) return null;
54
+ const rawThemes = a.animethemes || a.themes || [];
55
+ const animeObj = {
56
+ id: a.slug || String(a.id),
57
+ rawId: a.id,
58
+ name: a.name || a.title || a.name_jp || 'Anime',
59
+ slug: a.slug || String(a.id),
60
+ year: a.year || (a.season ? a.season : ''),
61
+ media_format: a.media_format || a.format || 'TV',
62
+ images: normalizeImages(a.images)
63
+ };
64
+ animeObj.animethemes = rawThemes.map(t => normalizeTheme(t, animeObj));
65
+ return animeObj;
66
+ }
67
+
68
+ function normalizeArtist(art) {
69
+ if (!art) return null;
70
+ return {
71
+ id: art.slug || String(art.id),
72
+ name: art.name || 'Artist',
73
+ slug: art.slug || String(art.id),
74
+ images: normalizeImages(art.images),
75
+ songs: art.songs || []
76
+ };
77
+ }
78
+
79
+ async function fetchAPI(path) {
80
+ try {
81
+ // /themes/search?q=<q>
82
+ if (path.startsWith('/themes/search')) {
83
+ const q = new URL(path, 'https://x').searchParams.get('q') || '';
84
+ const data = await _get(`${AT}/search?q=${encodeURIComponent(q)}&include[anime]=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`);
85
+ const list = (data.search?.anime || data.anime || []).map(normalizeAnime);
86
+ return { anime: list };
87
+ }
88
+
89
+ // /themes/anime/<id_or_slug>
90
+ if (path.startsWith('/themes/anime/')) {
91
+ const param = decodeURIComponent(path.replace('/themes/anime/', '').split('?')[0]);
92
+ let rawAnime = null;
93
+
94
+ // 1. Direct slug endpoint fetch with full relations
95
+ const data = await _get(`${AT}/anime/${encodeURIComponent(param)}?include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`).catch(() => null);
96
+ if (data?.anime) rawAnime = data.anime;
97
+ else if (data?.data) rawAnime = Array.isArray(data.data) ? data.data[0] : data.data;
98
+
99
+ // 2. Filter search if direct fetch failed
100
+ if (!rawAnime) {
101
+ const filterData = await _get(`${AT}/anime?filter[slug]=${encodeURIComponent(param)}&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`).catch(() => null)
102
+ || await _get(`${AT}/anime?filter[anilist_id]=${encodeURIComponent(param)}&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`).catch(() => null);
103
+ const list = filterData?.anime || filterData?.data || [];
104
+ if (list.length) rawAnime = list[0];
105
+ }
106
+
107
+ return { anime: rawAnime ? normalizeAnime(rawAnime) : null };
108
+ }
109
+
110
+ // /themes/artist/<slug>
111
+ if (path.startsWith('/themes/artist/')) {
112
+ const slug = decodeURIComponent(path.replace('/themes/artist/', '').split('?')[0]);
113
+ const data = await _get(`${AT}/artist/${encodeURIComponent(slug)}?include=animethemes.anime.images,animethemes.song`).catch(() => null);
114
+ const raw = data?.artist || (Array.isArray(data?.data) ? data.data[0] : data?.data);
115
+ return { artist: raw ? normalizeArtist(raw) : null };
116
+ }
117
+
118
+ // /themes/artists or /themes/top-artists
119
+ if (path.startsWith('/themes/artists') || path.startsWith('/themes/top-artists')) {
120
+ const data = await _get(`${AT}/artist?page[size]=24&include=images`);
121
+ const list = data?.artist || data?.data || [];
122
+ return { artists: list.map(normalizeArtist) };
123
+ }
124
+
125
+ // /themes/recent
126
+ if (path.startsWith('/themes/recent')) {
127
+ const data = await _get(`${AT}/anime?sort=-created_at&page[size]=24&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`);
128
+ const list = data?.anime || data?.data || [];
129
+ return { anime: list.map(normalizeAnime) };
130
+ }
131
+
132
+ // /themes/year/<year>
133
+ if (path.startsWith('/themes/year/')) {
134
+ const year = decodeURIComponent(path.replace('/themes/year/', '').split('?')[0]);
135
+ const data = await _get(`${AT}/anime?filter[year]=${year}&sort=name&page[size]=24&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`);
136
+ const list = data?.anime || data?.data || [];
137
+ return { anime: list.map(normalizeAnime) };
138
+ }
139
+
140
+ // Default home discover feed
141
+ const defaultData = await _get(`${AT}/anime?page[size]=24&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`);
142
+ const defaultList = defaultData?.anime || defaultData?.data || [];
143
+ return { anime: defaultList.map(normalizeAnime) };
144
+ } catch (e) {
145
+ console.error('[music-api] fetch error:', e);
146
+ return { anime: null, artists: [] };
147
+ }
148
+ }
149
+
150
+ window.fetchAPI = fetchAPI;
151
+ })();
frontend/dist/music.css ADDED
@@ -0,0 +1,2197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ :root {
3
+ --ms-bg: #0a0a0f;
4
+ --ms-sidebar: #000000;
5
+ --ms-surface: #121218;
6
+ --ms-surface-2: #1a1a22;
7
+ --ms-surface-3: #232330;
8
+ --ms-text: #ffffff;
9
+ --ms-text-dim: #a0a0b8;
10
+ --ms-text-mute: #6a6a82;
11
+ --ms-divider: rgba(255, 255, 255, 0.07);
12
+ --ms-accent: #7c3aed;
13
+ --ms-accent-hot: #9d6ef8;
14
+ --ms-accent-soft: rgba(124, 58, 237, 0.18);
15
+ }
16
+
17
+ .music-body {
18
+ overflow: hidden;
19
+ background:
20
+ radial-gradient(circle at top, rgba(124,58,237,0.22), transparent 22%),
21
+ linear-gradient(180deg, #07070d 0%, #090910 100%);
22
+ }
23
+
24
+ .music-body .float-nav { display: none !important; }
25
+
26
+ .music-body #mobile-nav { display: none !important; }
27
+
28
+ .music-body #header {
29
+ background: rgba(0, 0, 0, 0.92);
30
+ border-bottom: 1px solid var(--ms-divider);
31
+ backdrop-filter: blur(20px);
32
+ -webkit-backdrop-filter: blur(20px);
33
+ }
34
+
35
+ .music-app {
36
+ position: fixed;
37
+ top: var(--header-h);
38
+ left: 0;
39
+ right: 0;
40
+ bottom: 92px;
41
+ display: grid;
42
+ grid-template-columns: 280px 1fr;
43
+ gap: 8px;
44
+ padding: 8px;
45
+ background: #000;
46
+ transition: top 0.32s cubic-bezier(0.22, 1, 0.36, 1),
47
+ bottom 0.28s cubic-bezier(0.22, 1, 0.36, 1),
48
+ grid-template-columns 0.32s cubic-bezier(0.22, 1, 0.36, 1);
49
+ }
50
+
51
+ .music-body #header {
52
+ transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
53
+ will-change: transform;
54
+ }
55
+ .music-body #header.is-hidden {
56
+ transform: translateY(-100%);
57
+ }
58
+ .music-body.header-hidden .music-app {
59
+ top: 0;
60
+ }
61
+
62
+ @media (max-width: 1024px) {
63
+ .music-body .music-sidebar {
64
+ transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1),
65
+ top 0.32s cubic-bezier(0.22, 1, 0.36, 1);
66
+ }
67
+ .music-body.header-hidden .music-sidebar { top: 0; }
68
+ }
69
+
70
+ @media (min-width: 1025px) {
71
+ .music-app.sb-collapsed {
72
+ grid-template-columns: 0 1fr;
73
+ gap: 0;
74
+ }
75
+ .music-app.sb-collapsed .music-sidebar {
76
+ transform: translateX(-110%);
77
+ opacity: 0;
78
+ pointer-events: none;
79
+ }
80
+ .music-sidebar {
81
+ transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1),
82
+ opacity 0.22s ease;
83
+ will-change: transform;
84
+ }
85
+ .ms-sidebar-toggle { display: flex; }
86
+ }
87
+
88
+ .music-body.pb-hidden .music-app { bottom: 0; }
89
+ .music-body.pb-hidden .player-bar { display: none; }
90
+
91
+ .music-sidebar {
92
+ background: var(--ms-sidebar);
93
+ border-radius: 10px;
94
+ padding: 16px 12px;
95
+ overflow-y: auto;
96
+ overflow-x: hidden;
97
+ scrollbar-width: thin;
98
+ scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
99
+ }
100
+
101
+ .music-sidebar::-webkit-scrollbar { width: 8px; }
102
+ .music-sidebar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.16); border-radius: 4px; }
103
+
104
+ .ms-block { margin-bottom: 22px; }
105
+
106
+ .ms-block-title {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 10px;
110
+ color: var(--ms-text-dim);
111
+ font-size: 0.75rem;
112
+ font-weight: 700;
113
+ text-transform: uppercase;
114
+ letter-spacing: 0.08em;
115
+ padding: 6px 10px 10px;
116
+ }
117
+ .ms-block-title svg { width: 16px; height: 16px; }
118
+
119
+ .ms-link {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 14px;
123
+ padding: 10px 12px;
124
+ border-radius: 6px;
125
+ color: var(--ms-text-dim);
126
+ font-size: 0.92rem;
127
+ font-weight: 600;
128
+ transition: color 0.15s ease, background 0.15s ease;
129
+ cursor: pointer;
130
+ }
131
+ .ms-link svg { width: 20px; height: 20px; flex-shrink: 0; }
132
+ .ms-link:hover { color: #fff; background: transparent; }
133
+ .ms-link.active { color: #fff; background: var(--ms-surface-2); }
134
+ .ms-link.active svg { color: var(--ms-accent-hot); }
135
+
136
+ .ms-year-grid {
137
+ display: grid;
138
+ grid-template-columns: repeat(2, 1fr);
139
+ gap: 6px;
140
+ padding: 4px 6px;
141
+ }
142
+ .ms-year-grid a {
143
+ background: var(--ms-surface-2);
144
+ border-radius: 6px;
145
+ padding: 9px 10px;
146
+ text-align: center;
147
+ font-size: 0.85rem;
148
+ font-weight: 600;
149
+ color: var(--ms-text-dim);
150
+ transition: color 0.15s, background 0.15s;
151
+ }
152
+ .ms-year-grid a:hover {
153
+ background: var(--ms-surface-3);
154
+ color: #fff;
155
+ }
156
+
157
+ .music-main {
158
+ background: linear-gradient(180deg, #1a1228 0, #0e0e14 320px, var(--ms-surface) 100%);
159
+ border-radius: 10px;
160
+ overflow-y: auto;
161
+ overflow-x: hidden;
162
+ position: relative;
163
+ scrollbar-width: thin;
164
+ scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
165
+ }
166
+ .music-main::-webkit-scrollbar { width: 12px; }
167
+ .music-main::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.16); border-radius: 6px; }
168
+ .music-main::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.28); }
169
+
170
+ .ms-topbar {
171
+ position: sticky;
172
+ top: 0;
173
+ z-index: 5;
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 14px;
177
+ padding: 14px 24px;
178
+ background: rgba(10, 10, 16, 0.65);
179
+ backdrop-filter: blur(18px);
180
+ -webkit-backdrop-filter: blur(18px);
181
+ }
182
+
183
+ .ms-nav-btn {
184
+ width: 32px; height: 32px;
185
+ border-radius: 50%;
186
+ background: rgba(0, 0, 0, 0.7);
187
+ color: #fff;
188
+ display: flex; align-items: center; justify-content: center;
189
+ transition: background 0.15s, transform 0.15s;
190
+ flex-shrink: 0;
191
+ }
192
+ .ms-nav-btn svg { width: 14px; height: 14px; }
193
+ .ms-nav-btn:hover:not([disabled]) { background: #000; transform: scale(1.06); }
194
+ .ms-nav-btn[disabled] { opacity: 0.45; cursor: not-allowed; }
195
+
196
+ .ms-search {
197
+ position: relative;
198
+ flex: 1;
199
+ max-width: 360px;
200
+ display: flex;
201
+ align-items: center;
202
+ }
203
+ .ms-search-icon {
204
+ position: absolute;
205
+ left: 14px;
206
+ top: 50%;
207
+ transform: translateY(-50%);
208
+ color: #1a1a22;
209
+ display: flex;
210
+ pointer-events: none;
211
+ }
212
+ .ms-search-icon svg { width: 18px; height: 18px; }
213
+
214
+ .ms-search input {
215
+ width: 100%;
216
+ padding: 11px 40px 11px 42px;
217
+ background: #fff;
218
+ border: 2px solid transparent;
219
+ border-radius: 999px;
220
+ color: #1a1a22;
221
+ font-family: 'Outfit', sans-serif;
222
+ font-size: 0.92rem;
223
+ font-weight: 500;
224
+ outline: none;
225
+ transition: box-shadow 0.15s;
226
+ }
227
+ .ms-search input::placeholder { color: #6a6a82; font-weight: 500; }
228
+ .ms-search input:focus {
229
+ box-shadow: 0 0 0 2px var(--ms-accent);
230
+ }
231
+
232
+ .ms-search-clear {
233
+ position: absolute;
234
+ right: 8px;
235
+ top: 50%;
236
+ transform: translateY(-50%);
237
+ width: 26px; height: 26px;
238
+ border-radius: 50%;
239
+ color: #1a1a22;
240
+ display: flex; align-items: center; justify-content: center;
241
+ transition: background 0.15s;
242
+ }
243
+ .ms-search-clear svg { width: 14px; height: 14px; }
244
+ .ms-search-clear:hover { background: rgba(0, 0, 0, 0.08); }
245
+
246
+ .ms-sidebar-toggle {
247
+ display: flex;
248
+ width: 38px; height: 38px;
249
+ border-radius: 10px;
250
+ color: #fff;
251
+ background: rgba(255, 255, 255, 0.06);
252
+ border: 1px solid rgba(255, 255, 255, 0.08);
253
+ align-items: center; justify-content: center;
254
+ margin: 0;
255
+
256
+ order: -1;
257
+ flex-shrink: 0;
258
+ transition: background 0.16s ease, transform 0.16s ease, border-color 0.16s ease;
259
+ cursor: pointer;
260
+ }
261
+ .ms-sidebar-toggle:hover {
262
+ background: rgba(167, 139, 250, 0.18);
263
+ border-color: rgba(167, 139, 250, 0.35);
264
+ transform: scale(1.04);
265
+ }
266
+ .ms-sidebar-toggle:active { transform: scale(0.94); }
267
+ .ms-sidebar-toggle svg { width: 20px; height: 20px; }
268
+
269
+ .music-app.sb-collapsed .ms-sidebar-toggle svg { transform: rotate(180deg); transition: transform 0.22s ease; }
270
+
271
+ .ms-view {
272
+ padding: 0 24px 40px;
273
+ min-height: calc(100% - 70px);
274
+ }
275
+
276
+ .ms-section-title {
277
+ display: flex;
278
+ align-items: baseline;
279
+ justify-content: space-between;
280
+ gap: 14px;
281
+ margin: 30px 0 16px;
282
+ }
283
+ .ms-section-title h2 {
284
+ font-size: 1.5rem;
285
+ font-weight: 800;
286
+ letter-spacing: -0.01em;
287
+ color: #fff;
288
+ }
289
+ .ms-section-title a {
290
+ color: var(--ms-text-dim);
291
+ font-size: 0.78rem;
292
+ font-weight: 700;
293
+ text-transform: uppercase;
294
+ letter-spacing: 0.08em;
295
+ transition: color 0.15s;
296
+ }
297
+ .ms-section-title a:hover { color: #fff; text-decoration: underline; }
298
+
299
+ .ms-card-grid {
300
+ display: grid;
301
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
302
+ gap: 24px;
303
+ }
304
+
305
+ .ms-card {
306
+ background: var(--ms-surface);
307
+ border-radius: 8px;
308
+ padding: 16px;
309
+ transition: background 0.2s ease;
310
+ cursor: pointer;
311
+ position: relative;
312
+ text-align: left;
313
+ isolation: isolate;
314
+ }
315
+ .ms-card:hover { background: var(--ms-surface-2); }
316
+ .ms-card:hover .ms-card-play {
317
+ opacity: 1;
318
+ transform: translateY(0);
319
+ }
320
+
321
+ .ms-card-cover-wrap {
322
+ position: relative;
323
+ width: 100%;
324
+ aspect-ratio: 1;
325
+ border-radius: 6px;
326
+ overflow: hidden;
327
+ background: var(--ms-surface-2);
328
+ margin-bottom: 14px;
329
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
330
+
331
+ contain: layout paint;
332
+ flex-shrink: 0;
333
+ }
334
+ .ms-card.is-artist .ms-card-cover-wrap {
335
+ border-radius: 50%;
336
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
337
+ }
338
+
339
+ .ms-card-cover {
340
+ width: 100%;
341
+ height: 100%;
342
+ object-fit: cover;
343
+ transition: transform 0.4s ease;
344
+ }
345
+
346
+ .ms-card-cover-fallback {
347
+ width: 100%;
348
+ height: 100%;
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: center;
352
+ background: linear-gradient(135deg, var(--ms-accent), #2a1656);
353
+ color: #fff;
354
+ }
355
+ .ms-card-cover-fallback svg { width: 48px; height: 48px; opacity: 0.7; }
356
+
357
+ .ms-card-play {
358
+ position: absolute;
359
+ right: 8px;
360
+ bottom: 8px;
361
+ width: 48px; height: 48px;
362
+ border-radius: 50%;
363
+ background: var(--ms-accent);
364
+ color: #fff;
365
+ display: flex;
366
+ align-items: center;
367
+ justify-content: center;
368
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.6);
369
+ opacity: 0;
370
+ transform: translateY(8px);
371
+ transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease, scale 0.12s;
372
+ z-index: 2;
373
+ }
374
+ .ms-card-play svg { width: 22px; height: 22px; transform: translateX(1px); }
375
+ .ms-card-play:hover { background: var(--ms-accent-hot); transform: translateY(0) scale(1.06); }
376
+ .ms-card-play:active { transform: translateY(0) scale(0.96); }
377
+
378
+ .ms-card-title {
379
+ font-size: 1rem;
380
+ font-weight: 700;
381
+ color: #fff;
382
+ margin-bottom: 6px;
383
+ overflow: hidden;
384
+ text-overflow: ellipsis;
385
+ display: -webkit-box;
386
+ -webkit-line-clamp: 1;
387
+ -webkit-box-orient: vertical;
388
+ line-clamp: 1;
389
+ }
390
+ .ms-card-sub {
391
+ font-size: 0.85rem;
392
+ color: var(--ms-text-dim);
393
+ overflow: hidden;
394
+ text-overflow: ellipsis;
395
+ display: -webkit-box;
396
+ -webkit-line-clamp: 2;
397
+ -webkit-box-orient: vertical;
398
+ line-clamp: 2;
399
+ line-height: 1.4;
400
+ }
401
+
402
+ .ms-empty {
403
+ padding: 60px 20px;
404
+ text-align: center;
405
+ color: var(--ms-text-dim);
406
+ }
407
+ .ms-empty svg { width: 56px; height: 56px; margin-bottom: 14px; opacity: 0.4; }
408
+ .ms-empty h3 { font-size: 1.4rem; color: #fff; margin-bottom: 8px; font-weight: 800; }
409
+ .ms-empty p { font-size: 0.95rem; }
410
+
411
+ .ms-loading {
412
+ padding: 80px 20px;
413
+ text-align: center;
414
+ color: var(--ms-text-dim);
415
+ }
416
+ .ms-spinner {
417
+ width: 36px; height: 36px;
418
+ border: 3px solid rgba(124, 58, 237, 0.18);
419
+ border-top-color: var(--ms-accent);
420
+ border-radius: 50%;
421
+ margin: 0 auto 16px;
422
+ animation: msSpin 0.8s linear infinite;
423
+ }
424
+ @keyframes msSpin { to { transform: rotate(360deg); } }
425
+
426
+ .ms-album-hero {
427
+ display: flex;
428
+ gap: 28px;
429
+ align-items: flex-end;
430
+ padding: 36px 0 24px;
431
+ }
432
+
433
+ .ms-album-cover {
434
+ width: 232px; height: 232px;
435
+ border-radius: 6px;
436
+ overflow: hidden;
437
+ flex-shrink: 0;
438
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.85);
439
+ background: linear-gradient(135deg, var(--ms-accent), #2a1656);
440
+ }
441
+ .ms-album-cover img { width: 100%; height: 100%; object-fit: cover; }
442
+
443
+ .ms-album-info { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
444
+ .ms-album-eyebrow {
445
+ font-size: 0.78rem;
446
+ font-weight: 700;
447
+ text-transform: uppercase;
448
+ letter-spacing: 0.08em;
449
+ color: #fff;
450
+ }
451
+ .ms-album-title {
452
+ font-size: clamp(2rem, 6vw, 4rem);
453
+ font-weight: 900;
454
+ color: #fff;
455
+ line-height: 1.05;
456
+ letter-spacing: -0.03em;
457
+ margin: 4px 0 8px;
458
+ word-break: break-word;
459
+ }
460
+ .ms-album-meta { color: var(--ms-text-dim); font-size: 0.9rem; }
461
+ .ms-album-meta strong { color: #fff; font-weight: 700; }
462
+
463
+ .ms-album-actions {
464
+ display: flex;
465
+ gap: 16px;
466
+ align-items: center;
467
+ padding: 24px 0 10px;
468
+ }
469
+ .ms-play-big {
470
+ width: 60px; height: 60px;
471
+ border-radius: 50%;
472
+ background: linear-gradient(135deg, var(--ms-accent) 0%, var(--ms-accent-hot) 100%);
473
+ color: #fff;
474
+ display: flex; align-items: center; justify-content: center;
475
+ transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
476
+ box-shadow 0.18s ease,
477
+ filter 0.18s ease;
478
+ box-shadow: 0 10px 28px rgba(167, 139, 250, 0.45),
479
+ 0 4px 12px rgba(0, 0, 0, 0.5),
480
+ inset 0 1px 0 rgba(255, 255, 255, 0.25);
481
+ position: relative;
482
+ overflow: hidden;
483
+ }
484
+ .ms-play-big::before {
485
+ content: "";
486
+ position: absolute;
487
+ inset: 0;
488
+ border-radius: inherit;
489
+ background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.35), transparent 55%);
490
+ pointer-events: none;
491
+ }
492
+ .ms-play-big svg { width: 28px; height: 28px; transform: translateX(2px); position: relative; z-index: 1; }
493
+ .ms-play-big:hover {
494
+ transform: scale(1.08) translateY(-1px);
495
+ filter: brightness(1.08);
496
+ box-shadow: 0 14px 36px rgba(167, 139, 250, 0.6),
497
+ 0 6px 14px rgba(0, 0, 0, 0.55),
498
+ inset 0 1px 0 rgba(255, 255, 255, 0.3);
499
+ }
500
+ .ms-play-big:active { transform: scale(0.95); transition-duration: 0.08s; }
501
+ .ms-play-big:focus-visible {
502
+ outline: none;
503
+ box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.5),
504
+ 0 10px 28px rgba(167, 139, 250, 0.45),
505
+ 0 4px 12px rgba(0, 0, 0, 0.5);
506
+ }
507
+
508
+ .ms-icon-btn {
509
+ width: 38px; height: 38px;
510
+ border-radius: 50%;
511
+ color: var(--ms-text-dim);
512
+ display: flex; align-items: center; justify-content: center;
513
+ transition: color 0.12s, transform 0.12s;
514
+ }
515
+ .ms-icon-btn svg { width: 24px; height: 24px; }
516
+ .ms-icon-btn:hover { color: #fff; transform: scale(1.05); }
517
+ .ms-icon-btn.is-on { color: var(--ms-accent-hot); }
518
+ .ms-icon-btn.is-on svg { fill: currentColor; }
519
+
520
+ .ms-tracks {
521
+ margin-top: 8px;
522
+ padding-bottom: 16px;
523
+ }
524
+ .ms-tracks-head {
525
+ display: grid;
526
+ grid-template-columns: 36px 1fr 0.8fr 100px 60px;
527
+ gap: 14px;
528
+ padding: 8px 16px;
529
+ border-bottom: 1px solid var(--ms-divider);
530
+ margin-bottom: 8px;
531
+ color: var(--ms-text-dim);
532
+ font-size: 0.74rem;
533
+ text-transform: uppercase;
534
+ letter-spacing: 0.1em;
535
+ font-weight: 600;
536
+ }
537
+
538
+ .ms-track {
539
+ display: grid;
540
+ grid-template-columns: 36px 1fr 0.8fr 100px 60px;
541
+ gap: 14px;
542
+ align-items: center;
543
+ padding: 8px 16px;
544
+ border-radius: 4px;
545
+ cursor: pointer;
546
+ transition: background 0.12s ease;
547
+ color: var(--ms-text-dim);
548
+ position: relative;
549
+ }
550
+ .ms-track:hover { background: rgba(255, 255, 255, 0.07); color: #fff; }
551
+ .ms-track:hover .ms-track-num .ms-num { display: none; }
552
+ .ms-track:hover .ms-track-num .ms-play-mini { display: flex; }
553
+ .ms-track.is-playing { color: var(--ms-accent-hot); }
554
+ .ms-track.is-playing .ms-track-title { color: var(--ms-accent-hot); }
555
+
556
+ .ms-track-num {
557
+ text-align: center;
558
+ font-size: 0.95rem;
559
+ color: var(--ms-text-dim);
560
+ width: 36px; height: 36px;
561
+ display: flex; align-items: center; justify-content: center;
562
+ }
563
+ .ms-track-num .ms-play-mini { display: none; color: #fff; }
564
+ .ms-track-num .ms-play-mini svg { width: 16px; height: 16px; fill: currentColor; }
565
+ .ms-track.is-playing .ms-num { display: none; }
566
+ .ms-track.is-playing .ms-now-bars { display: flex; }
567
+ .ms-now-bars { display: none; gap: 2px; align-items: flex-end; height: 14px; }
568
+ .ms-now-bars span {
569
+ display: block; width: 3px; background: var(--ms-accent-hot); border-radius: 1px;
570
+ animation: msBar 0.8s ease-in-out infinite alternate;
571
+ }
572
+ .ms-now-bars span:nth-child(1) { animation-delay: -0.5s; }
573
+ .ms-now-bars span:nth-child(2) { animation-delay: -0.2s; }
574
+ .ms-now-bars span:nth-child(3) { animation-delay: -0.8s; }
575
+ @keyframes msBar { from { height: 4px; } to { height: 14px; } }
576
+
577
+ .ms-track-meta { display: flex; gap: 12px; align-items: center; min-width: 0; }
578
+ .ms-track-cover {
579
+ width: 40px; height: 40px;
580
+ border-radius: 4px;
581
+ object-fit: cover;
582
+ background: var(--ms-surface-2);
583
+ flex-shrink: 0;
584
+ }
585
+ .ms-track-text { min-width: 0; }
586
+ .ms-track-title {
587
+ color: #fff;
588
+ font-size: 0.95rem;
589
+ font-weight: 500;
590
+ white-space: nowrap;
591
+ overflow: hidden;
592
+ text-overflow: ellipsis;
593
+ }
594
+ .ms-track-sub {
595
+ color: var(--ms-text-dim);
596
+ font-size: 0.82rem;
597
+ white-space: nowrap;
598
+ overflow: hidden;
599
+ text-overflow: ellipsis;
600
+ }
601
+ .ms-track-sub a { transition: color 0.12s; }
602
+ .ms-track-sub a:hover { color: #fff; text-decoration: underline; }
603
+
604
+ .ms-track-anime {
605
+ color: var(--ms-text-dim);
606
+ font-size: 0.85rem;
607
+ white-space: nowrap;
608
+ overflow: hidden;
609
+ text-overflow: ellipsis;
610
+ }
611
+ .ms-track-anime:hover { color: #fff; text-decoration: underline; }
612
+
613
+ .ms-track-tag {
614
+ display: inline-flex;
615
+ align-items: center;
616
+ padding: 3px 10px;
617
+ border-radius: 999px;
618
+ background: rgba(124, 58, 237, 0.18);
619
+ border: 1px solid rgba(124, 58, 237, 0.32);
620
+ color: #c4b5fd;
621
+ font-size: 0.7rem;
622
+ font-weight: 700;
623
+ letter-spacing: 0.04em;
624
+ width: fit-content;
625
+ }
626
+ .ms-track-tag.ed { background: rgba(190, 24, 93, 0.16); border-color: rgba(190, 24, 93, 0.4); color: #f9a8d4; }
627
+ .ms-track-tag.in { background: rgba(34, 197, 94, 0.13); border-color: rgba(34, 197, 94, 0.35); color: #86efac; }
628
+
629
+ .ms-track-like {
630
+ width: 28px; height: 28px;
631
+ color: var(--ms-text-dim);
632
+ display: flex; align-items: center; justify-content: center;
633
+ border-radius: 50%;
634
+ transition: color 0.12s, transform 0.12s, opacity 0.12s;
635
+ opacity: 0;
636
+ }
637
+ .ms-track-like svg { width: 16px; height: 16px; }
638
+ .ms-track:hover .ms-track-like { opacity: 1; }
639
+ .ms-track-like.is-liked { color: var(--ms-accent-hot); opacity: 1; }
640
+ .ms-track-like.is-liked svg { fill: currentColor; }
641
+ .ms-track-like:hover { color: #fff; transform: scale(1.1); }
642
+
643
+ .ms-search-tabs {
644
+ display: flex;
645
+ gap: 8px;
646
+ padding: 18px 0 6px;
647
+ flex-wrap: wrap;
648
+ }
649
+ .ms-tab {
650
+ padding: 8px 18px;
651
+ border-radius: 999px;
652
+ background: var(--ms-surface-2);
653
+ color: #fff;
654
+ font-size: 0.86rem;
655
+ font-weight: 600;
656
+ transition: background 0.15s, color 0.15s;
657
+ cursor: pointer;
658
+ }
659
+ .ms-tab:hover { background: var(--ms-surface-3); }
660
+ .ms-tab.active { background: #fff; color: #000; }
661
+
662
+ .ms-search-block { margin-bottom: 36px; }
663
+
664
+ .ms-top-result {
665
+ display: grid;
666
+ grid-template-columns: 1.05fr 1.6fr;
667
+ gap: 24px;
668
+ align-items: stretch;
669
+ margin: 6px 0 30px;
670
+ }
671
+ @media (max-width: 900px) {
672
+ .ms-top-result { grid-template-columns: 1fr; }
673
+ }
674
+
675
+ .ms-top-card {
676
+ display: grid;
677
+ grid-template-columns: 120px 1fr;
678
+ grid-template-rows: auto auto auto 1fr;
679
+ grid-template-areas:
680
+ "cover name"
681
+ "cover tag"
682
+ "cover ."
683
+ "play .";
684
+ column-gap: 22px;
685
+ row-gap: 8px;
686
+ background: var(--ms-surface);
687
+ border-radius: 10px;
688
+ padding: 22px;
689
+ transition: background 0.2s;
690
+ cursor: pointer;
691
+ position: relative;
692
+ min-height: 220px;
693
+ overflow: hidden;
694
+ }
695
+ .ms-top-card:hover { background: var(--ms-surface-2); }
696
+ .ms-top-card:hover .ms-top-play { opacity: 1; transform: translateY(0); }
697
+
698
+ .ms-top-cover {
699
+ grid-area: cover;
700
+ width: 120px; height: 120px;
701
+ border-radius: 6px;
702
+ object-fit: cover;
703
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.6);
704
+ align-self: start;
705
+ }
706
+ .ms-top-card.is-artist .ms-top-cover { border-radius: 50%; }
707
+ .ms-top-card .ms-top-name { grid-area: name; align-self: end; margin: 0; }
708
+ .ms-top-card .ms-top-tag { grid-area: tag; justify-self: start; }
709
+
710
+ .ms-top-play {
711
+ grid-area: play;
712
+ justify-self: end;
713
+ align-self: end;
714
+ width: 56px; height: 56px;
715
+ border-radius: 50%;
716
+ background: var(--ms-accent);
717
+ color: #fff;
718
+ display: flex; align-items: center; justify-content: center;
719
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.55);
720
+ opacity: 0;
721
+ transform: translateY(8px);
722
+ transition: opacity 0.18s, transform 0.18s, background 0.18s;
723
+ z-index: 2;
724
+ }
725
+ .ms-top-play:hover { background: var(--ms-accent-hot); transform: translateY(0) scale(1.06); }
726
+ .ms-top-play svg { width: 26px; height: 26px; transform: translateX(1px); }
727
+
728
+ @media (max-width: 720px) {
729
+ .ms-top-card {
730
+ grid-template-columns: 84px 1fr;
731
+ grid-template-areas:
732
+ "cover name"
733
+ "cover tag"
734
+ "play play";
735
+ column-gap: 16px;
736
+ padding: 16px;
737
+ min-height: auto;
738
+ }
739
+ .ms-top-cover { width: 84px; height: 84px; }
740
+ .ms-top-card .ms-top-name { font-size: 1.4rem; }
741
+ .ms-top-play { opacity: 1; transform: none; width: 48px; height: 48px; margin-top: 6px; }
742
+ .ms-top-play svg { width: 22px; height: 22px; }
743
+ }
744
+ .ms-top-name {
745
+ font-size: 2rem;
746
+ font-weight: 900;
747
+ color: #fff;
748
+ margin-bottom: 10px;
749
+ line-height: 1.05;
750
+ letter-spacing: -0.02em;
751
+ overflow: hidden;
752
+ display: -webkit-box;
753
+ -webkit-line-clamp: 2;
754
+ -webkit-box-orient: vertical;
755
+ line-clamp: 2;
756
+ }
757
+ .ms-top-tag {
758
+ display: inline-flex;
759
+ align-items: center;
760
+ padding: 5px 12px;
761
+ background: rgba(0, 0, 0, 0.55);
762
+ border-radius: 4px;
763
+ color: #fff;
764
+ font-size: 0.72rem;
765
+ font-weight: 700;
766
+ text-transform: uppercase;
767
+ letter-spacing: 0.08em;
768
+ }
769
+
770
+ .ms-top-songs {
771
+ background: transparent;
772
+ border-radius: 8px;
773
+ padding: 0;
774
+ }
775
+ .ms-top-songs h3 {
776
+ font-size: 1.5rem;
777
+ font-weight: 800;
778
+ color: #fff;
779
+ margin: 0 0 12px;
780
+ letter-spacing: -0.01em;
781
+ }
782
+
783
+ .ms-songlist { display: flex; flex-direction: column; }
784
+ .ms-song-row {
785
+ display: grid;
786
+ grid-template-columns: 40px 1fr 28px;
787
+ gap: 14px;
788
+ align-items: center;
789
+ padding: 8px 12px;
790
+ border-radius: 6px;
791
+ cursor: pointer;
792
+ transition: background 0.12s;
793
+ }
794
+ .ms-song-row:hover { background: var(--ms-surface-2); }
795
+ .ms-song-row.is-playing .ms-song-title { color: var(--ms-accent-hot); }
796
+
797
+ .ms-song-cover {
798
+ width: 40px; height: 40px;
799
+ border-radius: 4px;
800
+ object-fit: cover;
801
+ background: var(--ms-surface-2);
802
+ flex-shrink: 0;
803
+ position: relative;
804
+ }
805
+ .ms-song-cover-wrap {
806
+ position: relative;
807
+ width: 40px; height: 40px;
808
+ flex-shrink: 0;
809
+ }
810
+ .ms-song-cover-wrap .ms-song-play {
811
+ position: absolute;
812
+ inset: 0;
813
+ display: none;
814
+ align-items: center;
815
+ justify-content: center;
816
+ background: rgba(0, 0, 0, 0.6);
817
+ color: #fff;
818
+ border-radius: 4px;
819
+ }
820
+ .ms-song-cover-wrap .ms-song-play svg { width: 16px; height: 16px; }
821
+ .ms-song-row:hover .ms-song-play { display: flex; }
822
+
823
+ .ms-song-text { min-width: 0; }
824
+ .ms-song-title {
825
+ color: #fff;
826
+ font-size: 0.92rem;
827
+ font-weight: 500;
828
+ white-space: nowrap;
829
+ overflow: hidden;
830
+ text-overflow: ellipsis;
831
+ }
832
+ .ms-song-sub {
833
+ color: var(--ms-text-dim);
834
+ font-size: 0.8rem;
835
+ white-space: nowrap;
836
+ overflow: hidden;
837
+ text-overflow: ellipsis;
838
+ }
839
+ .ms-song-sub a:hover { color: #fff; text-decoration: underline; }
840
+
841
+ .ms-song-like {
842
+ width: 28px; height: 28px;
843
+ color: var(--ms-text-dim);
844
+ display: flex; align-items: center; justify-content: center;
845
+ border-radius: 50%;
846
+ transition: color 0.12s, opacity 0.12s, transform 0.12s;
847
+ opacity: 0;
848
+ }
849
+ .ms-song-like svg { width: 16px; height: 16px; }
850
+ .ms-song-row:hover .ms-song-like { opacity: 1; }
851
+ .ms-song-like.is-liked { color: var(--ms-accent-hot); opacity: 1; }
852
+ .ms-song-like.is-liked svg { fill: currentColor; }
853
+
854
+ .player-bar {
855
+ position: fixed;
856
+ left: 0; right: 0; bottom: 0;
857
+ height: 90px;
858
+ background: #000;
859
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
860
+ display: grid;
861
+ grid-template-columns: minmax(180px, 30%) 1fr minmax(180px, 30%);
862
+ align-items: center;
863
+ padding: 0 16px;
864
+ gap: 16px;
865
+ z-index: 100;
866
+ box-shadow: 0 -10px 32px rgba(0, 0, 0, 0.6);
867
+ }
868
+ .player-bar[data-empty="1"] .pb-controls,
869
+ .player-bar[data-empty="1"] .pb-progress,
870
+ .player-bar[data-empty="1"] .pb-like { opacity: 0.5; pointer-events: none; }
871
+
872
+ .pb-track {
873
+ display: flex;
874
+ align-items: center;
875
+ gap: 14px;
876
+ min-width: 0;
877
+ }
878
+ .pb-cover {
879
+ width: 56px; height: 56px;
880
+ border-radius: 4px;
881
+ background: var(--ms-surface-2);
882
+ object-fit: cover;
883
+ flex-shrink: 0;
884
+ }
885
+
886
+ .pb-cover:not([src]),
887
+ .pb-cover[src=""],
888
+ .pb-cover.is-failed {
889
+ visibility: hidden;
890
+ }
891
+ .player-bar[data-empty="1"] .pb-cover {
892
+ visibility: hidden;
893
+ }
894
+
895
+ .pb-cover-fallback {
896
+ position: absolute;
897
+ inset: 0;
898
+ display: none;
899
+ align-items: center;
900
+ justify-content: center;
901
+ background: linear-gradient(135deg, #432470 0%, #1a0f33 100%);
902
+ color: rgba(255, 255, 255, 0.7);
903
+ border-radius: 4px;
904
+ }
905
+ .pb-cover-fallback svg { width: 22px; height: 22px; }
906
+ .pb-cover-btn:not(.has-cover) .pb-cover-fallback,
907
+ .player-bar[data-empty="1"] .pb-cover-fallback { display: flex; }
908
+
909
+ .pb-dismiss {
910
+ width: 28px; height: 28px;
911
+ color: var(--ms-text-mute);
912
+ display: flex; align-items: center; justify-content: center;
913
+ border-radius: 50%;
914
+ transition: color 0.15s, background 0.15s;
915
+ flex-shrink: 0;
916
+ }
917
+ .pb-dismiss svg { width: 14px; height: 14px; }
918
+ .pb-dismiss:hover { color: #fff; background: rgba(255, 255, 255, 0.08); }
919
+ .pb-meta { min-width: 0; flex: 1; }
920
+ .pb-title {
921
+ color: #fff;
922
+ font-size: 0.9rem;
923
+ font-weight: 500;
924
+ white-space: nowrap;
925
+ overflow: hidden;
926
+ text-overflow: ellipsis;
927
+ display: block;
928
+ transition: color 0.12s;
929
+ background: transparent;
930
+ border: 0;
931
+ padding: 0;
932
+ cursor: pointer;
933
+ text-align: left;
934
+ width: 100%;
935
+ font-family: inherit;
936
+ }
937
+ .pb-title:hover { text-decoration: underline; }
938
+ .pb-artist {
939
+ color: var(--ms-text-dim);
940
+ font-size: 0.74rem;
941
+ white-space: nowrap;
942
+ overflow: hidden;
943
+ text-overflow: ellipsis;
944
+ display: block;
945
+ transition: color 0.12s;
946
+ }
947
+ .pb-artist:hover { color: #fff; text-decoration: underline; }
948
+ .pb-like {
949
+ width: 32px; height: 32px;
950
+ color: var(--ms-text-dim);
951
+ display: flex; align-items: center; justify-content: center;
952
+ border-radius: 50%;
953
+ transition: color 0.12s, transform 0.12s;
954
+ flex-shrink: 0;
955
+ }
956
+ .pb-like svg { width: 18px; height: 18px; }
957
+ .pb-like:hover { color: #fff; transform: scale(1.1); }
958
+ .pb-like.is-liked { color: var(--ms-accent-hot); }
959
+ .pb-like.is-liked svg { fill: currentColor; }
960
+
961
+ .pb-center {
962
+ display: flex;
963
+ flex-direction: column;
964
+ align-items: center;
965
+ gap: 6px;
966
+ width: 100%;
967
+ max-width: 720px;
968
+ margin: 0 auto;
969
+ }
970
+
971
+ .pb-controls {
972
+ display: flex;
973
+ align-items: center;
974
+ justify-content: center;
975
+ gap: 18px;
976
+ }
977
+
978
+ .pb-btn {
979
+ width: 32px; height: 32px;
980
+ color: var(--ms-text-dim);
981
+ display: flex; align-items: center; justify-content: center;
982
+ border-radius: 50%;
983
+ transition: color 0.12s, transform 0.12s;
984
+ }
985
+ .pb-btn svg { width: 20px; height: 20px; }
986
+ .pb-btn:hover { color: #fff; transform: scale(1.08); }
987
+ .pb-btn.is-on { color: var(--ms-accent-hot); }
988
+ .pb-btn.is-on::after {
989
+ content: "";
990
+ width: 4px; height: 4px;
991
+ background: var(--ms-accent-hot);
992
+ border-radius: 50%;
993
+ position: absolute;
994
+ margin-top: 30px;
995
+ }
996
+
997
+ .pb-play {
998
+ width: 38px; height: 38px;
999
+ border-radius: 50%;
1000
+ background: #fff;
1001
+ color: #000;
1002
+ display: flex; align-items: center; justify-content: center;
1003
+ transition: transform 0.12s, background 0.15s;
1004
+ }
1005
+ .pb-play svg { width: 22px; height: 22px; }
1006
+ .pb-play:hover { transform: scale(1.07); background: #f1eaff; }
1007
+ .pb-play:active { transform: scale(0.96); }
1008
+ .player-bar[data-empty="1"] .pb-play { opacity: 0.6; }
1009
+
1010
+ .pb-progress {
1011
+ display: flex;
1012
+ align-items: center;
1013
+ gap: 12px;
1014
+ width: 100%;
1015
+ padding: 0 12px;
1016
+ }
1017
+ .pb-time {
1018
+ font-size: 0.7rem;
1019
+ color: var(--ms-text-dim);
1020
+ font-variant-numeric: tabular-nums;
1021
+ width: 38px;
1022
+ text-align: center;
1023
+ flex-shrink: 0;
1024
+ }
1025
+ .pb-bar {
1026
+ flex: 1;
1027
+ height: 4px;
1028
+ background: rgba(255, 255, 255, 0.18);
1029
+ border-radius: 2px;
1030
+ position: relative;
1031
+ cursor: pointer;
1032
+ transition: height 0.12s;
1033
+ }
1034
+ .pb-bar:hover { height: 6px; }
1035
+ .pb-bar:hover .pb-bar-fill { background: var(--ms-accent-hot); }
1036
+ .pb-bar:hover .pb-bar-knob { opacity: 1; }
1037
+ .pb-bar-fill {
1038
+ position: absolute;
1039
+ left: 0; top: 0; bottom: 0;
1040
+ width: 0%;
1041
+ background: #fff;
1042
+ border-radius: 2px;
1043
+ transition: background 0.12s;
1044
+ }
1045
+ .pb-bar-knob {
1046
+ position: absolute;
1047
+ top: 50%;
1048
+ width: 12px; height: 12px;
1049
+ background: #fff;
1050
+ border-radius: 50%;
1051
+ transform: translate(-50%, -50%);
1052
+ opacity: 0;
1053
+ transition: opacity 0.12s;
1054
+ pointer-events: none;
1055
+ }
1056
+
1057
+ .pb-right {
1058
+ display: flex;
1059
+ align-items: center;
1060
+ justify-content: flex-end;
1061
+ gap: 8px;
1062
+ }
1063
+ .pb-mobile-play { display: none; }
1064
+ .pb-vol {
1065
+ width: 100px;
1066
+ height: 4px;
1067
+ background: rgba(255, 255, 255, 0.18);
1068
+ border-radius: 2px;
1069
+ position: relative;
1070
+ cursor: pointer;
1071
+ }
1072
+ .pb-vol:hover .pb-bar-fill { background: var(--ms-accent-hot); }
1073
+ .pb-vol:hover .pb-bar-knob { opacity: 1; }
1074
+
1075
+ .ms-sidebar-backdrop {
1076
+ display: none;
1077
+ position: fixed;
1078
+ inset: 0;
1079
+ background: rgba(0, 0, 0, 0.6);
1080
+ z-index: 60;
1081
+ opacity: 0;
1082
+ transition: opacity 0.22s ease;
1083
+ }
1084
+ .ms-sidebar-backdrop.active {
1085
+ display: block;
1086
+ opacity: 1;
1087
+ }
1088
+
1089
+ @media (hover: none), (max-width: 800px) {
1090
+ .ms-card-play {
1091
+ opacity: 1;
1092
+ transform: translateY(0);
1093
+ width: 44px; height: 44px;
1094
+ right: 8px; bottom: 8px;
1095
+ }
1096
+ .ms-card-play svg { width: 18px; height: 18px; }
1097
+ .ms-track-like { opacity: 1; }
1098
+ .ms-song-like { opacity: 1; }
1099
+ .ms-song-cover-wrap .ms-song-play { display: none; }
1100
+ }
1101
+
1102
+ @media (max-width: 1024px) {
1103
+ .music-app { grid-template-columns: 1fr; }
1104
+ .music-sidebar {
1105
+ position: fixed;
1106
+ top: var(--header-h);
1107
+ left: 0;
1108
+ bottom: 92px;
1109
+ width: 280px;
1110
+ z-index: 80;
1111
+ margin: 8px;
1112
+ transform: translateX(-110%);
1113
+ transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
1114
+ pointer-events: none;
1115
+ }
1116
+ .music-sidebar.open {
1117
+ transform: translateX(0);
1118
+ pointer-events: auto;
1119
+ }
1120
+
1121
+ .music-sidebar.open .ms-link,
1122
+ .music-sidebar.open .ms-year-grid a,
1123
+ .music-sidebar.open .ms-block-title { pointer-events: auto; }
1124
+
1125
+ .music-sidebar .ms-link { padding: 12px 14px; font-size: 0.98rem; }
1126
+
1127
+ .music-sidebar .ms-link,
1128
+ .music-sidebar .ms-year-grid a,
1129
+ .ms-sidebar-toggle {
1130
+ touch-action: manipulation;
1131
+ -webkit-tap-highlight-color: rgba(167, 139, 250, 0.18);
1132
+ }
1133
+ .ms-sidebar-toggle { display: flex; }
1134
+ .ms-sidebar-backdrop { z-index: 70; }
1135
+ }
1136
+
1137
+ @media (max-width: 800px) {
1138
+ .ms-album-hero {
1139
+ flex-direction: column;
1140
+ align-items: flex-start;
1141
+ gap: 18px;
1142
+ padding: 16px 0;
1143
+ }
1144
+ .ms-album-cover { width: 180px; height: 180px; align-self: center; }
1145
+ .ms-album-info { align-items: flex-start; text-align: left; width: 100%; }
1146
+ .ms-tracks-head, .ms-track {
1147
+ grid-template-columns: 36px 1fr 56px;
1148
+ }
1149
+ .ms-tracks-head .ms-th-anime, .ms-tracks-head .ms-th-tag,
1150
+ .ms-track .ms-track-anime, .ms-track .ms-track-tag-cell { display: none; }
1151
+ .ms-view { padding: 0 14px 24px; }
1152
+ .ms-topbar { padding: 12px 14px; gap: 8px; }
1153
+ .ms-search { max-width: none; }
1154
+ .ms-card-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
1155
+ .ms-top-name { font-size: 1.5rem; }
1156
+ }
1157
+
1158
+ @media (max-width: 720px) {
1159
+ .player-bar {
1160
+ height: 64px;
1161
+ grid-template-columns: 1fr auto;
1162
+ padding: 0 10px;
1163
+ gap: 10px;
1164
+ bottom: 0;
1165
+ border-radius: 0;
1166
+ }
1167
+ .pb-cover-btn { width: 44px; height: 44px; }
1168
+ .pb-cover { width: 44px; height: 44px; }
1169
+ .pb-title { font-size: 0.86rem; }
1170
+ .pb-artist { font-size: 0.72rem; }
1171
+ .pb-like, .pb-center { display: none; }
1172
+ .pb-mobile-play {
1173
+ display: flex;
1174
+ width: 40px; height: 40px;
1175
+ color: #fff;
1176
+ background: transparent;
1177
+ border-radius: 50%;
1178
+ }
1179
+ .pb-mobile-play svg { width: 24px; height: 24px; }
1180
+ .pb-right .pb-btn:not(#pb-play-mobile), .pb-right .pb-vol { display: none; }
1181
+ .music-app {
1182
+ bottom: 64px;
1183
+ padding: 6px;
1184
+ gap: 6px;
1185
+ top: 10px;
1186
+ }
1187
+ .music-sidebar { bottom: 64px; }
1188
+ .player-bar[data-empty="1"] { display: none; }
1189
+ body.music-body:has(.player-bar[data-empty="1"]) .music-app,
1190
+ body.music-body:has(.player-bar[data-empty="1"]) .music-sidebar {
1191
+ bottom: 0;
1192
+ }
1193
+ .ms-topbar {
1194
+ padding: 12px 12px;
1195
+ gap: 8px;
1196
+ }
1197
+ .ms-search {
1198
+ max-width: none;
1199
+ }
1200
+ .ms-search input {
1201
+ padding: 10px 38px 10px 38px;
1202
+ font-size: 0.84rem;
1203
+ }
1204
+ .ms-sidebar-toggle {
1205
+ width: 34px;
1206
+ height: 34px;
1207
+ }
1208
+ }
1209
+
1210
+ @media (max-width: 480px) {
1211
+ .ms-card-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
1212
+ .ms-section-title h2 { font-size: 1.2rem; }
1213
+ .ms-top-name { font-size: 1.25rem; }
1214
+ .ms-years-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
1215
+ .ms-year-num { font-size: 1.8rem; }
1216
+ }
1217
+
1218
+ .ms-home-hero {
1219
+ margin: 18px 0 28px;
1220
+ }
1221
+ .ms-home-greet {
1222
+ font-size: 2rem;
1223
+ font-weight: 900;
1224
+ letter-spacing: -0.02em;
1225
+ color: #fff;
1226
+ margin: 0 0 18px;
1227
+ }
1228
+ .ms-quick-grid {
1229
+ display: grid;
1230
+ grid-template-columns: repeat(3, 1fr);
1231
+ gap: 10px;
1232
+ }
1233
+ @media (max-width: 900px) { .ms-quick-grid { grid-template-columns: repeat(2, 1fr); } }
1234
+ @media (max-width: 540px) { .ms-quick-grid { grid-template-columns: 1fr; } }
1235
+
1236
+ .ms-quick-tile {
1237
+ display: flex;
1238
+ align-items: center;
1239
+ gap: 12px;
1240
+ background: rgba(255, 255, 255, 0.06);
1241
+ border-radius: 6px;
1242
+ padding: 0 14px 0 0;
1243
+ height: 64px;
1244
+ overflow: hidden;
1245
+ position: relative;
1246
+ transition: background 0.15s;
1247
+ }
1248
+ .ms-quick-tile:hover { background: rgba(255, 255, 255, 0.12); }
1249
+ .ms-quick-cover {
1250
+ width: 64px; height: 64px;
1251
+ object-fit: cover;
1252
+ flex-shrink: 0;
1253
+ background: var(--ms-surface-2);
1254
+ }
1255
+ .ms-quick-name {
1256
+ flex: 1;
1257
+ font-weight: 700;
1258
+ color: #fff;
1259
+ font-size: 0.9rem;
1260
+ overflow: hidden;
1261
+ text-overflow: ellipsis;
1262
+ white-space: nowrap;
1263
+ min-width: 0;
1264
+ }
1265
+ .ms-quick-play {
1266
+ width: 44px; height: 44px;
1267
+ border-radius: 50%;
1268
+ background: var(--ms-accent);
1269
+ color: #fff;
1270
+ display: flex; align-items: center; justify-content: center;
1271
+ flex-shrink: 0;
1272
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45);
1273
+ opacity: 0;
1274
+ transform: translateX(8px);
1275
+ transition: opacity 0.15s, transform 0.15s, background 0.15s;
1276
+ }
1277
+ .ms-quick-tile:hover .ms-quick-play { opacity: 1; transform: translateX(0); }
1278
+ .ms-quick-play:hover { background: var(--ms-accent-hot); transform: scale(1.06); }
1279
+ .ms-quick-play svg { width: 18px; height: 18px; transform: translateX(1px); }
1280
+ .ms-quick-empty {
1281
+ grid-column: 1 / -1;
1282
+ color: var(--ms-text-dim);
1283
+ background: var(--ms-surface);
1284
+ border-radius: 6px;
1285
+ padding: 18px;
1286
+ font-size: 0.92rem;
1287
+ }
1288
+
1289
+ .ms-years-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 18px; }
1290
+ .ms-year-card {
1291
+ display: block;
1292
+ background: var(--ms-surface);
1293
+ padding: 14px;
1294
+ border-radius: 8px;
1295
+ transition: background 0.18s;
1296
+ text-align: left;
1297
+ }
1298
+ .ms-year-card:hover { background: var(--ms-surface-2); }
1299
+ .ms-year-cover {
1300
+ width: 100%;
1301
+ aspect-ratio: 1 / 1;
1302
+ border-radius: 6px;
1303
+ display: flex;
1304
+ align-items: center;
1305
+ justify-content: center;
1306
+ margin-bottom: 12px;
1307
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.55);
1308
+ overflow: hidden;
1309
+ position: relative;
1310
+ }
1311
+ .ms-year-cover::after {
1312
+ content: "";
1313
+ position: absolute;
1314
+ inset: 0;
1315
+ background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.12), transparent 55%);
1316
+ pointer-events: none;
1317
+ }
1318
+ .ms-year-num {
1319
+ font-size: 2.4rem;
1320
+ font-weight: 900;
1321
+ letter-spacing: -0.04em;
1322
+ color: #fff;
1323
+ text-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
1324
+ }
1325
+
1326
+ .pb-cover-btn {
1327
+ position: relative;
1328
+ width: 56px; height: 56px;
1329
+ border-radius: 4px;
1330
+ overflow: hidden;
1331
+ flex-shrink: 0;
1332
+ background: transparent;
1333
+ cursor: pointer;
1334
+ display: block;
1335
+ }
1336
+ .pb-cover-btn .pb-cover { width: 100%; height: 100%; display: block; }
1337
+ .pb-cover-overlay {
1338
+ position: absolute;
1339
+ inset: 0;
1340
+ display: flex;
1341
+ align-items: center;
1342
+ justify-content: center;
1343
+ background: rgba(0, 0, 0, 0.55);
1344
+ color: #fff;
1345
+ opacity: 0;
1346
+ transition: opacity 0.15s;
1347
+ }
1348
+ .pb-cover-overlay svg { width: 18px; height: 18px; }
1349
+ .pb-cover-btn:hover .pb-cover-overlay { opacity: 1; }
1350
+ .player-bar[data-empty="1"] .pb-cover-btn { pointer-events: none; }
1351
+
1352
+ .np-modal {
1353
+ position: fixed;
1354
+ inset: 0;
1355
+ z-index: 10001;
1356
+ color: #fff;
1357
+ overflow: hidden;
1358
+ animation: npFadeIn 0.22s ease-out;
1359
+ }
1360
+ .np-modal[hidden] { display: none; }
1361
+ body.np-open { overflow: hidden; }
1362
+ body.np-open .float-nav { display: none !important; }
1363
+
1364
+ body.np-open #header { display: none !important; }
1365
+ body.np-open .np-modal { top: 0; }
1366
+ @keyframes npFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
1367
+
1368
+ .np-bg {
1369
+ position: absolute;
1370
+ inset: 0;
1371
+ background-size: cover;
1372
+ background-position: center;
1373
+ filter: blur(38px) saturate(150%) brightness(0.6);
1374
+ transform: scale(1.2);
1375
+ z-index: 0;
1376
+ transition: filter 0.35s ease;
1377
+ }
1378
+ .np-bg::after {
1379
+ content: "";
1380
+ position: absolute;
1381
+ inset: 0;
1382
+ background: linear-gradient(180deg, rgba(10,5,25,0.55) 0%, rgba(10,5,25,0.85) 60%, #07050d 100%);
1383
+ transition: opacity 0.35s ease;
1384
+ }
1385
+
1386
+ body.np-lyrics-open .np-bg {
1387
+ filter: blur(46px) saturate(140%) brightness(0.45);
1388
+ }
1389
+ body.np-lyrics-open .np-bg::after {
1390
+ background: linear-gradient(180deg, rgba(8,4,20,0.72) 0%, rgba(8,4,20,0.88) 60%, #050208 100%);
1391
+ opacity: 1;
1392
+ }
1393
+
1394
+ body.np-lyrics-open .np-stage { box-shadow: none; background: transparent; }
1395
+ body.np-lyrics-open .np-cover,
1396
+ body.np-lyrics-open .np-cover-fallback { display: none !important; }
1397
+
1398
+ .np-inner {
1399
+ position: relative;
1400
+ z-index: 1;
1401
+ height: 100vh;
1402
+ height: 100dvh;
1403
+ max-width: 520px;
1404
+ margin: 0 auto;
1405
+ padding: 14px 22px 22px;
1406
+ display: grid;
1407
+ grid-template-rows: auto minmax(0, 1fr) auto auto auto auto;
1408
+ gap: 14px;
1409
+ }
1410
+ @media (min-width: 900px) {
1411
+ .np-inner { max-width: 560px; padding: 18px 28px 26px; gap: 16px; }
1412
+ }
1413
+ @media (max-width: 480px) {
1414
+ .np-inner { padding: 10px 16px 18px; gap: 10px; }
1415
+ }
1416
+
1417
+ .np-header {
1418
+ display: flex;
1419
+ align-items: center;
1420
+ justify-content: space-between;
1421
+ gap: 12px;
1422
+ }
1423
+ .np-icon-btn {
1424
+ min-width: 36px; height: 36px;
1425
+ display: flex; align-items: center; justify-content: center;
1426
+ gap: 6px;
1427
+ padding: 0 6px;
1428
+ color: #fff;
1429
+ border-radius: 18px;
1430
+ transition: background 0.15s;
1431
+ }
1432
+ .np-icon-btn:hover { background: rgba(255, 255, 255, 0.12); }
1433
+ .np-icon-btn svg { width: 22px; height: 22px; }
1434
+
1435
+ .np-back-btn span {
1436
+ font-size: 0.85rem;
1437
+ font-weight: 600;
1438
+ padding-right: 6px;
1439
+ }
1440
+ @media (max-width: 600px) {
1441
+ .np-back-btn span { display: none; }
1442
+ .np-back-btn { padding: 0; min-width: 36px; border-radius: 50%; }
1443
+ }
1444
+ .np-source { text-align: center; min-width: 0; flex: 1; }
1445
+ .np-eyebrow {
1446
+ font-size: 0.66rem;
1447
+ text-transform: uppercase;
1448
+ letter-spacing: 0.18em;
1449
+ color: rgba(255, 255, 255, 0.7);
1450
+ font-weight: 700;
1451
+ margin-bottom: 2px;
1452
+ }
1453
+ .np-source-name {
1454
+ font-size: 0.86rem;
1455
+ font-weight: 600;
1456
+ color: #fff;
1457
+ text-decoration: none;
1458
+ overflow: hidden;
1459
+ text-overflow: ellipsis;
1460
+ white-space: nowrap;
1461
+ display: block;
1462
+ }
1463
+ .np-source-name:hover { text-decoration: underline; }
1464
+
1465
+ .np-stage {
1466
+ position: relative;
1467
+
1468
+ width: min(100%, 70vh, 560px);
1469
+ max-width: 100%;
1470
+ aspect-ratio: 1 / 1;
1471
+ max-height: min(70vh, 560px);
1472
+ margin: 0 auto;
1473
+ border-radius: 12px;
1474
+ overflow: hidden;
1475
+ background: linear-gradient(135deg, #432470 0%, #1a0f33 100%);
1476
+ box-shadow: 0 26px 60px rgba(0, 0, 0, 0.6);
1477
+ align-self: center;
1478
+ flex-shrink: 0;
1479
+ }
1480
+ @media (max-width: 600px) {
1481
+ .np-stage {
1482
+
1483
+ width: min(100%, 54vh, 460px);
1484
+ max-height: 54vh;
1485
+ }
1486
+ }
1487
+ @media (max-width: 380px) {
1488
+ .np-stage {
1489
+ width: min(100%, 48vh, 380px);
1490
+ max-height: 48vh;
1491
+ }
1492
+ }
1493
+
1494
+ @media (max-height: 520px) and (orientation: landscape) {
1495
+ .np-stage {
1496
+ width: min(82vh, 400px);
1497
+ max-height: 82vh;
1498
+ }
1499
+ }
1500
+ .np-cover, .np-video {
1501
+ position: absolute;
1502
+ inset: 0;
1503
+ width: 100%;
1504
+ height: 100%;
1505
+ object-fit: cover;
1506
+ display: block;
1507
+ }
1508
+ .np-video { background: #000; object-fit: contain; }
1509
+
1510
+ .np-cover[hidden], .np-video[hidden] { display: none !important; }
1511
+
1512
+ .np-cover:not([src]),
1513
+ .np-cover[src=""],
1514
+ .np-cover.is-failed { visibility: hidden; }
1515
+
1516
+ .np-cover-fallback {
1517
+ position: absolute;
1518
+ inset: 0;
1519
+ display: flex;
1520
+ align-items: center;
1521
+ justify-content: center;
1522
+ color: rgba(255, 255, 255, 0.55);
1523
+ pointer-events: none;
1524
+ }
1525
+ .np-cover-fallback svg { width: 72px; height: 72px; }
1526
+
1527
+ .np-loading {
1528
+ position: absolute;
1529
+ inset: 0;
1530
+ display: flex;
1531
+ align-items: center;
1532
+ justify-content: center;
1533
+ background: rgba(0, 0, 0, 0.45);
1534
+ backdrop-filter: blur(2px);
1535
+ -webkit-backdrop-filter: blur(2px);
1536
+ z-index: 3;
1537
+ animation: npFadeIn 0.18s ease-out;
1538
+ }
1539
+ .np-loading[hidden] { display: none; }
1540
+ .np-spinner {
1541
+ width: 56px;
1542
+ height: 56px;
1543
+ border-radius: 50%;
1544
+ border: 3px solid rgba(255, 255, 255, 0.18);
1545
+ border-top-color: var(--ms-accent-hot);
1546
+ animation: npSpin 0.85s linear infinite;
1547
+ }
1548
+ @keyframes npSpin { to { transform: rotate(360deg); } }
1549
+
1550
+ .np-panel-close {
1551
+ width: 32px; height: 32px;
1552
+ color: rgba(255, 255, 255, 0.7);
1553
+ display: flex; align-items: center; justify-content: center;
1554
+ border-radius: 50%;
1555
+ transition: background 0.15s, color 0.15s;
1556
+ flex-shrink: 0;
1557
+ }
1558
+ .np-panel-close svg { width: 16px; height: 16px; }
1559
+ .np-panel-close:hover { color: #fff; background: rgba(255, 255, 255, 0.12); }
1560
+ .np-lyrics-head {
1561
+ display: flex;
1562
+ align-items: center;
1563
+ justify-content: space-between;
1564
+ margin-bottom: 12px;
1565
+ }
1566
+ .np-lyrics-head h3 { margin: 0; }
1567
+
1568
+ .np-lyrics {
1569
+ position: absolute;
1570
+ inset: 0;
1571
+
1572
+ background: radial-gradient(120% 80% at 50% 0%, #1a0d2e 0%, #0a0612 55%, #050208 100%);
1573
+ overflow: hidden;
1574
+ padding: 18px 22px;
1575
+ display: flex;
1576
+ align-items: center;
1577
+ justify-content: center;
1578
+ }
1579
+ .np-lyrics[hidden] { display: none; }
1580
+ .np-lyrics-inner { width: 100%; max-width: 420px; }
1581
+ .np-lyrics-inner h3 {
1582
+ margin: 0 0 12px;
1583
+ font-size: 1.1rem;
1584
+ font-weight: 800;
1585
+ color: #fff;
1586
+ }
1587
+ .np-lyrics-body {
1588
+ color: rgba(255, 255, 255, 0.92);
1589
+ line-height: 1.55;
1590
+ font-size: 0.96rem;
1591
+ margin: 0 0 18px;
1592
+ }
1593
+ .np-lyrics-meta {
1594
+ display: grid;
1595
+ gap: 10px;
1596
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
1597
+ padding-top: 14px;
1598
+ }
1599
+ .np-lyrics-meta div {
1600
+ display: flex;
1601
+ justify-content: space-between;
1602
+ gap: 16px;
1603
+ font-size: 0.85rem;
1604
+ }
1605
+ .np-lyrics-meta span {
1606
+ color: rgba(255, 255, 255, 0.55);
1607
+ text-transform: uppercase;
1608
+ letter-spacing: 0.08em;
1609
+ font-weight: 700;
1610
+ font-size: 0.7rem;
1611
+ }
1612
+ .np-lyrics-meta strong {
1613
+ color: #fff;
1614
+ font-weight: 600;
1615
+ text-align: right;
1616
+ overflow: hidden;
1617
+ text-overflow: ellipsis;
1618
+ white-space: nowrap;
1619
+ max-width: 60%;
1620
+ }
1621
+
1622
+ .np-info {
1623
+ display: flex;
1624
+ align-items: center;
1625
+ gap: 16px;
1626
+ }
1627
+ .np-info-text { flex: 1; min-width: 0; }
1628
+ .np-title {
1629
+ display: block;
1630
+ color: #fff;
1631
+ font-size: 1.4rem;
1632
+ font-weight: 800;
1633
+ letter-spacing: -0.01em;
1634
+ text-decoration: none;
1635
+ overflow: hidden;
1636
+ text-overflow: ellipsis;
1637
+ white-space: nowrap;
1638
+ margin-bottom: 4px;
1639
+ }
1640
+ .np-title:hover { text-decoration: underline; }
1641
+ .np-artist {
1642
+ display: block;
1643
+ color: rgba(255, 255, 255, 0.75);
1644
+ font-size: 0.95rem;
1645
+ text-decoration: none;
1646
+ overflow: hidden;
1647
+ text-overflow: ellipsis;
1648
+ white-space: nowrap;
1649
+ }
1650
+ .np-artist:hover { color: #fff; text-decoration: underline; }
1651
+ .np-like {
1652
+ width: 40px; height: 40px;
1653
+ color: rgba(255, 255, 255, 0.85);
1654
+ display: flex; align-items: center; justify-content: center;
1655
+ border-radius: 50%;
1656
+ transition: color 0.15s, transform 0.15s;
1657
+ flex-shrink: 0;
1658
+ }
1659
+ .np-like svg { width: 24px; height: 24px; }
1660
+ .np-like:hover { color: #fff; transform: scale(1.08); }
1661
+ .np-like.is-liked { color: var(--ms-accent-hot); }
1662
+ .np-like.is-liked svg { fill: currentColor; }
1663
+
1664
+ .np-progress { display: grid; gap: 6px; }
1665
+ .np-bar {
1666
+ position: relative;
1667
+ height: 4px;
1668
+ background: rgba(255, 255, 255, 0.18);
1669
+ border-radius: 2px;
1670
+ cursor: pointer;
1671
+ transition: height 0.12s;
1672
+ }
1673
+ .np-bar:hover { height: 6px; }
1674
+ .np-bar-fill {
1675
+ position: absolute;
1676
+ left: 0; top: 0; bottom: 0;
1677
+ width: 0;
1678
+ background: #fff;
1679
+ border-radius: 2px;
1680
+ }
1681
+ .np-bar:hover .np-bar-fill { background: var(--ms-accent-hot); }
1682
+ .np-bar-knob {
1683
+ position: absolute;
1684
+ top: 50%;
1685
+ width: 12px; height: 12px;
1686
+ background: #fff;
1687
+ border-radius: 50%;
1688
+ transform: translate(-50%, -50%);
1689
+ opacity: 0;
1690
+ transition: opacity 0.12s;
1691
+ }
1692
+ .np-bar:hover .np-bar-knob { opacity: 1; }
1693
+ .np-times {
1694
+ display: flex;
1695
+ justify-content: space-between;
1696
+ color: rgba(255, 255, 255, 0.7);
1697
+ font-size: 0.72rem;
1698
+ font-variant-numeric: tabular-nums;
1699
+ }
1700
+
1701
+ .np-controls {
1702
+ display: flex;
1703
+ align-items: center;
1704
+ justify-content: space-between;
1705
+ padding: 0 4px;
1706
+ }
1707
+ .np-ctl {
1708
+ width: 44px; height: 44px;
1709
+ color: rgba(255, 255, 255, 0.85);
1710
+ display: flex; align-items: center; justify-content: center;
1711
+ border-radius: 50%;
1712
+ transition: color 0.15s, transform 0.15s;
1713
+ position: relative;
1714
+ }
1715
+ .np-ctl svg { width: 26px; height: 26px; }
1716
+ .np-ctl:hover { color: #fff; transform: scale(1.08); }
1717
+ .np-ctl.is-on { color: var(--ms-accent-hot); }
1718
+ .np-ctl.is-on::after {
1719
+ content: "";
1720
+ position: absolute;
1721
+ bottom: 6px;
1722
+ left: 50%;
1723
+ transform: translateX(-50%);
1724
+ width: 4px; height: 4px;
1725
+ background: var(--ms-accent-hot);
1726
+ border-radius: 50%;
1727
+ }
1728
+ .np-play-big {
1729
+ width: 64px; height: 64px;
1730
+ border-radius: 50%;
1731
+ background: #fff;
1732
+ color: #000;
1733
+ display: flex; align-items: center; justify-content: center;
1734
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
1735
+ transition: transform 0.12s;
1736
+ }
1737
+ .np-play-big svg { width: 30px; height: 30px; transform: translateX(1px); }
1738
+ .np-play-big:hover { transform: scale(1.06); }
1739
+ .np-play-big:active { transform: scale(0.96); }
1740
+
1741
+ .np-tools {
1742
+ display: flex;
1743
+ align-items: center;
1744
+ justify-content: center;
1745
+ gap: 12px;
1746
+ padding-top: 4px;
1747
+ }
1748
+ .np-tool {
1749
+ display: inline-flex;
1750
+ align-items: center;
1751
+ gap: 8px;
1752
+ padding: 9px 18px;
1753
+ border-radius: 999px;
1754
+ background: rgba(255, 255, 255, 0.10);
1755
+ color: rgba(255, 255, 255, 0.92);
1756
+ font-weight: 600;
1757
+ font-size: 0.84rem;
1758
+ letter-spacing: 0.01em;
1759
+ transition: background 0.15s, color 0.15s, transform 0.12s;
1760
+ }
1761
+ .np-tool svg { width: 18px; height: 18px; }
1762
+ .np-tool:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
1763
+ .np-tool.is-on {
1764
+ background: var(--ms-accent);
1765
+ color: #fff;
1766
+ }
1767
+ .np-tool.is-on:hover { background: var(--ms-accent-hot); }
1768
+ .np-tool.is-disabled { opacity: 0.45; pointer-events: none; }
1769
+
1770
+ @media (min-width: 720px) {
1771
+ .np-inner { max-width: 620px; padding: 28px 32px 36px; gap: 22px; }
1772
+ .np-title { font-size: 1.7rem; }
1773
+ }
1774
+
1775
+ #toast-container {
1776
+ position: fixed;
1777
+ bottom: 110px;
1778
+ left: 50%;
1779
+ transform: translateX(-50%);
1780
+ z-index: 300;
1781
+ display: flex;
1782
+ flex-direction: column;
1783
+ align-items: center;
1784
+ gap: 8px;
1785
+ pointer-events: none;
1786
+ }
1787
+ .kv-toast {
1788
+ background: rgba(20, 14, 36, 0.96);
1789
+ color: #fff;
1790
+ border: 1px solid rgba(255, 255, 255, 0.10);
1791
+ padding: 10px 18px;
1792
+ border-radius: 999px;
1793
+ font-size: 0.85rem;
1794
+ font-weight: 600;
1795
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
1796
+ opacity: 0;
1797
+ transform: translateY(8px);
1798
+ transition: opacity 0.2s, transform 0.2s;
1799
+ }
1800
+ .kv-toast.show { opacity: 1; transform: none; }
1801
+
1802
+ .np-queue {
1803
+ position: absolute;
1804
+ inset: 0;
1805
+ background: rgba(8, 4, 18, 0.92);
1806
+ backdrop-filter: blur(28px);
1807
+ -webkit-backdrop-filter: blur(28px);
1808
+ border-radius: inherit;
1809
+ display: flex;
1810
+ flex-direction: column;
1811
+ overflow: hidden;
1812
+ animation: npFadeIn 0.18s ease-out;
1813
+ }
1814
+ .np-queue[hidden] { display: none; }
1815
+ .np-queue-head {
1816
+ flex-shrink: 0;
1817
+ padding: 16px 18px 10px;
1818
+ display: flex;
1819
+ align-items: baseline;
1820
+ justify-content: space-between;
1821
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
1822
+ }
1823
+ .np-queue-head h3 {
1824
+ font-size: 1rem;
1825
+ font-weight: 800;
1826
+ letter-spacing: 0.04em;
1827
+ text-transform: uppercase;
1828
+ }
1829
+ .np-queue-head span {
1830
+ font-size: 0.78rem;
1831
+ color: var(--ms-text-dim);
1832
+ font-weight: 600;
1833
+ }
1834
+ .np-queue-list {
1835
+ flex: 1;
1836
+ overflow-y: auto;
1837
+ padding: 6px 8px 12px;
1838
+ scrollbar-width: thin;
1839
+ scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
1840
+ }
1841
+ .np-queue-empty {
1842
+ color: var(--ms-text-dim);
1843
+ text-align: center;
1844
+ padding: 30px 16px;
1845
+ font-size: 0.92rem;
1846
+ }
1847
+ .np-queue-row {
1848
+ width: 100%;
1849
+ display: grid;
1850
+ grid-template-columns: 44px 1fr auto;
1851
+ gap: 12px;
1852
+ align-items: center;
1853
+ padding: 8px 10px;
1854
+ border-radius: 8px;
1855
+ background: transparent;
1856
+ color: #fff;
1857
+ text-align: left;
1858
+ cursor: pointer;
1859
+ transition: background 0.15s;
1860
+ }
1861
+ .np-queue-row:hover { background: rgba(255, 255, 255, 0.07); }
1862
+ .np-queue-row.is-current { background: rgba(124, 58, 237, 0.18); }
1863
+ .np-queue-cover {
1864
+ position: relative;
1865
+ width: 44px; height: 44px;
1866
+ border-radius: 6px;
1867
+ overflow: hidden;
1868
+ background: var(--ms-surface-2);
1869
+ flex-shrink: 0;
1870
+ }
1871
+ .np-queue-cover img { width: 100%; height: 100%; object-fit: cover; }
1872
+ .np-queue-eq {
1873
+ position: absolute;
1874
+ inset: 0;
1875
+ background: rgba(0, 0, 0, 0.55);
1876
+ display: flex;
1877
+ align-items: flex-end;
1878
+ justify-content: center;
1879
+ gap: 2px;
1880
+ padding-bottom: 6px;
1881
+ }
1882
+ .np-queue-eq i {
1883
+ width: 3px; height: 12px;
1884
+ background: var(--ms-accent-hot);
1885
+ border-radius: 1px;
1886
+ animation: eqBars 1s ease-in-out infinite;
1887
+ animation-play-state: paused;
1888
+ transform: scaleY(0.4);
1889
+ transform-origin: bottom center;
1890
+ }
1891
+ .np-queue-eq i:nth-child(2) { animation-delay: 0.15s; height: 16px; }
1892
+ .np-queue-eq i:nth-child(3) { animation-delay: 0.3s; height: 10px; }
1893
+
1894
+ body.is-playing .np-queue-eq i { animation-play-state: running; }
1895
+ @keyframes eqBars {
1896
+ 0%, 100% { transform: scaleY(0.4); }
1897
+ 50% { transform: scaleY(1); }
1898
+ }
1899
+
1900
+ .np-lyric-line {
1901
+ display: block;
1902
+ padding: 6px 4px;
1903
+ line-height: 1.55;
1904
+ color: rgba(255, 255, 255, 0.55);
1905
+ transition: color 0.25s ease, transform 0.25s ease, font-weight 0.2s ease;
1906
+ font-size: 1rem;
1907
+ }
1908
+ .np-lyric-line.is-active {
1909
+ color: #fff;
1910
+ font-weight: 700;
1911
+ transform: scale(1.04);
1912
+ transform-origin: left center;
1913
+ text-shadow: 0 0 18px rgba(157, 110, 248, 0.55);
1914
+ }
1915
+ .np-lyric-line.is-loading,
1916
+ .np-lyric-line.is-empty {
1917
+ color: rgba(255, 255, 255, 0.5);
1918
+ font-style: italic;
1919
+ }
1920
+ .np-queue-text {
1921
+ display: flex;
1922
+ flex-direction: column;
1923
+ gap: 2px;
1924
+ min-width: 0;
1925
+ }
1926
+ .np-queue-title {
1927
+ font-size: 0.95rem;
1928
+ font-weight: 600;
1929
+ color: #fff;
1930
+ overflow: hidden;
1931
+ text-overflow: ellipsis;
1932
+ white-space: nowrap;
1933
+ }
1934
+ .np-queue-row.is-current .np-queue-title { color: var(--ms-accent-hot); }
1935
+ .np-queue-sub {
1936
+ font-size: 0.78rem;
1937
+ color: var(--ms-text-dim);
1938
+ overflow: hidden;
1939
+ text-overflow: ellipsis;
1940
+ white-space: nowrap;
1941
+ }
1942
+ .np-queue-tag {
1943
+ font-size: 0.7rem;
1944
+ color: var(--ms-text-dim);
1945
+ font-weight: 700;
1946
+ text-transform: uppercase;
1947
+ letter-spacing: 0.06em;
1948
+ flex-shrink: 0;
1949
+ }
1950
+
1951
+ .ms-link:hover {
1952
+ color: #fff;
1953
+ background: rgba(255, 255, 255, 0.06);
1954
+ }
1955
+ .ms-link:active { background: rgba(255, 255, 255, 0.12); }
1956
+
1957
+ .pb-cover-btn { cursor: pointer; }
1958
+
1959
+ @media (max-width: 720px) {
1960
+ .np-inner {
1961
+ padding: 14px 16px 22px;
1962
+ gap: 14px;
1963
+ }
1964
+ .np-stage { aspect-ratio: 1 / 1; }
1965
+ .np-tools { gap: 8px; }
1966
+ .np-tool { padding: 8px 12px; font-size: 0.78rem; }
1967
+ .np-tool span { display: inline; }
1968
+ .np-title { font-size: 1.1rem; }
1969
+ .np-artist { font-size: 0.88rem; }
1970
+ .np-controls { gap: 18px; }
1971
+ .np-play-big { width: 64px; height: 64px; }
1972
+ .np-play-big svg { width: 26px; height: 26px; }
1973
+ .np-queue-row { grid-template-columns: 40px 1fr auto; }
1974
+ .np-queue-cover { width: 40px; height: 40px; }
1975
+ }
1976
+
1977
+ @media (max-width: 380px) {
1978
+ .np-tool span { display: none; }
1979
+ .np-tool { padding: 8px 10px; }
1980
+ .np-controls { gap: 12px; }
1981
+ .np-controls .pb-icon-btn { width: 36px; height: 36px; }
1982
+ .np-play-big { width: 58px; height: 58px; }
1983
+ }
1984
+
1985
+ @media (min-width: 721px) and (max-width: 1024px) {
1986
+ .np-inner { max-width: 460px; padding: 22px 28px 30px; }
1987
+ }
1988
+
1989
+ @media (min-width: 1200px) {
1990
+ .np-inner { max-width: 560px; }
1991
+ .np-stage { max-height: 420px; }
1992
+ }
1993
+
1994
+ .np-inner {
1995
+ grid-template-rows: auto minmax(0, 1fr) auto auto auto auto;
1996
+ align-content: stretch;
1997
+ gap: 14px;
1998
+ padding: 14px 22px max(20px, env(safe-area-inset-bottom));
1999
+ overflow: hidden;
2000
+ overscroll-behavior: contain;
2001
+ box-sizing: border-box;
2002
+ }
2003
+ .np-stage {
2004
+ max-height: min(60vh, 480px);
2005
+ min-height: 0;
2006
+ }
2007
+ @media (max-height: 780px) {
2008
+ .np-inner { gap: 10px; padding: 10px 18px max(14px, env(safe-area-inset-bottom)); }
2009
+ .np-stage { max-height: min(52vh, 400px); }
2010
+ .np-controls { gap: 14px; }
2011
+ .np-tools { padding-top: 0; gap: 8px; }
2012
+ .np-tool { padding: 7px 12px; font-size: 0.78rem; }
2013
+ }
2014
+ @media (max-height: 640px) {
2015
+ .np-inner { gap: 6px; padding: 8px 14px max(10px, env(safe-area-inset-bottom)); }
2016
+ .np-stage { max-height: min(42vh, 300px); }
2017
+ .np-info { padding: 6px 10px; }
2018
+ .np-title { font-size: 1rem; margin-bottom: 2px; }
2019
+ .np-artist { font-size: 0.82rem; }
2020
+ .np-play-big { width: 54px; height: 54px; }
2021
+ .np-play-big svg { width: 22px; height: 22px; }
2022
+ .np-ctl { width: 38px; height: 38px; }
2023
+ .np-ctl svg { width: 22px; height: 22px; }
2024
+ }
2025
+ @media (max-height: 520px) {
2026
+ .np-stage { max-height: 30vh; }
2027
+ .np-inner { gap: 4px; padding: 6px 12px max(8px, env(safe-area-inset-bottom)); }
2028
+ .np-tool span { display: none; }
2029
+ .np-tool { padding: 6px 10px; }
2030
+ }
2031
+
2032
+ body.np-lyrics-open .np-inner {
2033
+ grid-template-rows: auto minmax(140px, 1fr) auto auto auto auto;
2034
+ align-content: stretch;
2035
+ overflow: hidden;
2036
+ }
2037
+ body.np-lyrics-open .np-stage {
2038
+ width: 100%;
2039
+ max-width: 100%;
2040
+ aspect-ratio: auto;
2041
+ max-height: none;
2042
+ height: 100%;
2043
+ border-radius: 8px;
2044
+ }
2045
+
2046
+ .np-back-btn {
2047
+ background: rgba(255, 255, 255, 0.08);
2048
+ border: 1px solid rgba(255, 255, 255, 0.10);
2049
+ padding: 0 12px 0 8px !important;
2050
+ min-width: auto !important;
2051
+ border-radius: 18px !important;
2052
+ height: 36px;
2053
+ }
2054
+ .np-back-btn span { display: inline !important; padding-right: 0 !important; }
2055
+ .np-back-btn:hover { background: rgba(255, 255, 255, 0.16); }
2056
+
2057
+ .np-lyrics {
2058
+ align-items: stretch;
2059
+ justify-content: center;
2060
+ padding: 28px 18px;
2061
+ overscroll-behavior: contain;
2062
+ }
2063
+ .np-lyrics-inner {
2064
+ width: 100%;
2065
+ max-width: 640px;
2066
+ height: 100%;
2067
+ margin: 0 auto;
2068
+ text-align: center;
2069
+ display: flex;
2070
+ flex-direction: column;
2071
+ min-height: 0;
2072
+ }
2073
+ .np-lyrics-inner h3 { text-align: center; }
2074
+ .np-lyrics-head { flex: 0 0 auto; justify-content: center; gap: 14px; position: relative; }
2075
+ .np-lyrics-head .np-panel-close { position: absolute; right: 0; top: 50%; transform: translateY(-50%); }
2076
+ .np-lyrics-body {
2077
+ flex: 1 1 0;
2078
+ min-height: 0;
2079
+ text-align: center;
2080
+ font-size: 1.45rem;
2081
+ line-height: 1.5;
2082
+ margin: 0;
2083
+ color: #fff;
2084
+
2085
+ padding: 45% 6px;
2086
+ overflow-y: auto;
2087
+ overflow-x: hidden;
2088
+
2089
+ -webkit-overflow-scrolling: touch;
2090
+ scrollbar-width: none;
2091
+
2092
+ -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
2093
+ mask-image: linear-gradient(180deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
2094
+ }
2095
+ .np-lyrics-body::-webkit-scrollbar { width: 0; height: 0; display: none; }
2096
+ .np-lyrics-meta { flex: 0 0 auto; padding-top: 8px; }
2097
+
2098
+ .np-lyric-line {
2099
+ display: block;
2100
+ padding: 10px 6px;
2101
+ line-height: 1.35;
2102
+ font-size: 1.4rem;
2103
+
2104
+ font-weight: 500;
2105
+ letter-spacing: -0.005em;
2106
+ color: rgba(255, 255, 255, 0.55);
2107
+ transform: scale(0.96);
2108
+ transform-origin: center;
2109
+ transition: opacity 0.45s cubic-bezier(0.22, 1, 0.36, 1),
2110
+ transform 0.55s cubic-bezier(0.22, 1, 0.36, 1),
2111
+ color 0.4s cubic-bezier(0.22, 1, 0.36, 1),
2112
+ font-weight 0.25s ease,
2113
+ text-shadow 0.5s ease;
2114
+ text-align: center;
2115
+ will-change: transform, color;
2116
+
2117
+ max-width: 100%;
2118
+ word-break: break-word;
2119
+ overflow-wrap: anywhere;
2120
+ hyphens: auto;
2121
+ white-space: normal;
2122
+ }
2123
+
2124
+ .np-lyric-line.is-past {
2125
+ color: rgba(255, 255, 255, 0.32);
2126
+ font-weight: 500;
2127
+ }
2128
+
2129
+ .np-lyric-line.is-active {
2130
+ transform: scale(1.12);
2131
+ color: #ffffff;
2132
+ font-weight: 900;
2133
+ letter-spacing: 0;
2134
+ text-shadow: 0 0 22px rgba(167, 139, 250, 0.55),
2135
+ 0 2px 14px rgba(0, 0, 0, 0.55);
2136
+ }
2137
+
2138
+ .np-lyric-line.is-active ~ .np-lyric-line {
2139
+ color: rgba(255, 255, 255, 0.55);
2140
+ font-weight: 500;
2141
+ transform: scale(0.96);
2142
+ }
2143
+ .np-lyric-line.is-loading,
2144
+ .np-lyric-line.is-empty {
2145
+ transform: none;
2146
+ font-style: italic;
2147
+ font-weight: 500;
2148
+ font-size: 1rem;
2149
+ color: rgba(255, 255, 255, 0.7);
2150
+ }
2151
+
2152
+ @media (max-width: 600px) {
2153
+ .np-lyrics { padding: 18px 10px; }
2154
+ .np-lyrics-body { font-size: 1.15rem; }
2155
+ .np-lyric-line { font-size: 1.15rem; padding: 8px 4px; }
2156
+ .np-lyric-line.is-active { transform: scale(1.08); }
2157
+ }
2158
+
2159
+ .np-info {
2160
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.10), rgba(255, 255, 255, 0.02));
2161
+ border: 1px solid rgba(255, 255, 255, 0.06);
2162
+ border-radius: 14px;
2163
+ padding: 10px 14px;
2164
+ }
2165
+ .np-like {
2166
+ width: 44px !important;
2167
+ height: 44px !important;
2168
+ background: rgba(255, 255, 255, 0.06);
2169
+ border: 1px solid rgba(255, 255, 255, 0.10);
2170
+ color: rgba(255, 255, 255, 0.85);
2171
+ transition: color 0.18s ease, background 0.18s ease, transform 0.18s ease, border-color 0.18s ease, box-shadow 0.2s ease;
2172
+ }
2173
+ .np-like svg { width: 22px !important; height: 22px !important; transition: transform 0.18s ease; }
2174
+ .np-like:hover {
2175
+ background: rgba(244, 63, 94, 0.12);
2176
+ border-color: rgba(244, 63, 94, 0.45);
2177
+ color: #fff;
2178
+ transform: scale(1.06);
2179
+ }
2180
+ .np-like.is-liked {
2181
+ color: #fff;
2182
+ background: linear-gradient(135deg, #f43f5e, #ec4899);
2183
+ border-color: rgba(244, 63, 94, 0.65);
2184
+ box-shadow: 0 8px 22px rgba(244, 63, 94, 0.45);
2185
+ }
2186
+ .np-like.is-liked svg { fill: currentColor; transform: scale(1.05); }
2187
+ .np-like.just-liked { animation: npLikePop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) 1; }
2188
+ @keyframes npLikePop {
2189
+ 0% { transform: scale(1); }
2190
+ 40% { transform: scale(1.25); }
2191
+ 100% { transform: scale(1.06); }
2192
+ }
2193
+ @media (max-width: 600px) {
2194
+ .np-info { padding: 8px 12px; gap: 12px; }
2195
+ .np-like { width: 40px !important; height: 40px !important; }
2196
+ .np-like svg { width: 20px !important; height: 20px !important; }
2197
+ }
frontend/dist/music.html ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
6
+ <title>Music · anidoom</title>
7
+ <meta name="description" content="Listen to anime openings, endings and OSTs. Powered by AnimeThemes.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/music.css">
12
+ <style>
13
+ :root {
14
+ --header-h: 0px;
15
+ }
16
+ body {
17
+ font-family: 'Outfit', sans-serif;
18
+ margin: 0;
19
+ padding: 0;
20
+ }
21
+ </style>
22
+ </head>
23
+
24
+ <body class="music-body">
25
+ <main class="music-app" id="music-app">
26
+ <aside class="music-sidebar" id="music-sidebar">
27
+ <div class="ms-block">
28
+ <div class="ms-block-title">
29
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2h-4v-7h-6v7H5a2 2 0 0 1-2-2z"/></svg>
30
+ <span>Browse</span>
31
+ </div>
32
+ <a class="ms-link" data-route="#/" href="#/">
33
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20"/><path d="M12 2a14.5 14.5 0 0 1 0 20"/><line x1="2" y1="12" x2="22" y2="12"/></svg>
34
+ <span>Discover</span>
35
+ </a>
36
+ <a class="ms-link" data-route="#/recent" href="#/recent">
37
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
38
+ <span>Newest Themes</span>
39
+ </a>
40
+ <a class="ms-link" data-route="#/artists" href="#/artists">
41
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
42
+ <span>Artists</span>
43
+ </a>
44
+ </div>
45
+
46
+ <div class="ms-block">
47
+ <div class="ms-block-title">
48
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
49
+ <span>Library</span>
50
+ </div>
51
+ <a class="ms-link" data-route="#/liked" href="#/liked">
52
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
53
+ <span>Liked Songs</span>
54
+ </a>
55
+ <a class="ms-link" data-route="#/history" href="#/history">
56
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
57
+ <span>Recently Played</span>
58
+ </a>
59
+ </div>
60
+
61
+ <div class="ms-block">
62
+ <div class="ms-block-title">
63
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
64
+ <span>By Year</span>
65
+ </div>
66
+ <div class="ms-year-grid" id="ms-year-grid"></div>
67
+ </div>
68
+ </aside>
69
+
70
+ <section class="music-main" id="music-main">
71
+ <div class="ms-topbar">
72
+ <button class="ms-nav-btn" id="ms-back" aria-label="Back" disabled>
73
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
74
+ </button>
75
+ <button class="ms-nav-btn" id="ms-fwd" aria-label="Forward" disabled>
76
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
77
+ </button>
78
+
79
+ <div class="ms-search">
80
+ <span class="ms-search-icon">
81
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
82
+ </span>
83
+ <input id="ms-search-input" type="text" placeholder="What do you want to listen to?" autocomplete="off">
84
+ <button class="ms-search-clear" id="ms-search-clear" aria-label="Clear" hidden>
85
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
86
+ </button>
87
+ </div>
88
+
89
+ <button class="ms-sidebar-toggle" id="ms-sidebar-toggle" aria-label="Library">
90
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
91
+ </button>
92
+ </div>
93
+
94
+ <div class="ms-view" id="ms-view"></div>
95
+ </section>
96
+ </main>
97
+
98
+ <div class="ms-sidebar-backdrop" id="ms-sidebar-backdrop"></div>
99
+
100
+ <div class="player-bar" id="player-bar" data-empty="1">
101
+ <div class="pb-track">
102
+ <button class="pb-cover-btn" id="pb-expand" aria-label="Expand player">
103
+ <img class="pb-cover" id="pb-cover" alt="" referrerpolicy="no-referrer">
104
+ <span class="pb-cover-fallback" id="pb-cover-fallback" aria-hidden="true">
105
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
106
+ </span>
107
+ <span class="pb-cover-overlay">
108
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
109
+ </span>
110
+ </button>
111
+ <div class="pb-meta">
112
+ <button class="pb-title" id="pb-title" type="button">Pick a song to start</button>
113
+ <a class="pb-artist" id="pb-artist" href="#">—</a>
114
+ </div>
115
+ <button class="pb-like" id="pb-like" aria-label="Like">
116
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
117
+ </button>
118
+ <button class="pb-dismiss" id="pb-dismiss" aria-label="Hide player">
119
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
120
+ </button>
121
+ </div>
122
+
123
+ <div class="pb-center">
124
+ <div class="pb-controls">
125
+ <button class="pb-btn" id="pb-shuffle" aria-label="Shuffle">
126
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
127
+ </button>
128
+ <button class="pb-btn" id="pb-prev" aria-label="Previous">
129
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
130
+ </button>
131
+ <button class="pb-play" id="pb-play" aria-label="Play">
132
+ <svg id="pb-play-svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
133
+ </button>
134
+ <button class="pb-btn" id="pb-next" aria-label="Next">
135
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6z"/></svg>
136
+ </button>
137
+ <button class="pb-btn" id="pb-loop" aria-label="Loop">
138
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
139
+ </button>
140
+ </div>
141
+ <div class="pb-progress">
142
+ <span class="pb-time" id="pb-cur">0:00</span>
143
+ <div class="pb-bar" id="pb-seek">
144
+ <div class="pb-bar-fill" id="pb-seek-fill"></div>
145
+ <div class="pb-bar-knob" id="pb-seek-knob"></div>
146
+ </div>
147
+ <span class="pb-time" id="pb-dur">0:00</span>
148
+ </div>
149
+ </div>
150
+
151
+ <div class="pb-right">
152
+ <button class="pb-btn pb-mobile-play" id="pb-play-mobile" aria-label="Play">
153
+ <svg id="pb-play-mobile-svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
154
+ </button>
155
+ <button class="pb-btn" id="pb-mute" aria-label="Mute">
156
+ <svg id="pb-vol-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
157
+ </button>
158
+ <div class="pb-vol" id="pb-vol">
159
+ <div class="pb-bar-fill" id="pb-vol-fill"></div>
160
+ <div class="pb-bar-knob" id="pb-vol-knob"></div>
161
+ </div>
162
+ </div>
163
+
164
+ <audio id="pb-audio" preload="metadata"></audio>
165
+ </div>
166
+
167
+ <div class="np-modal" id="np-modal" hidden>
168
+ <div class="np-bg" id="np-bg"></div>
169
+ <div class="np-inner">
170
+ <div class="np-header">
171
+ <button class="np-icon-btn np-back-btn" id="np-close" aria-label="Back">
172
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
173
+ <span>Back</span>
174
+ </button>
175
+ <div class="np-source">
176
+ <div class="np-eyebrow">Playing from</div>
177
+ <a class="np-source-name" id="np-source-name" href="#">—</a>
178
+ </div>
179
+ <div style="width:36px"></div>
180
+ </div>
181
+
182
+ <div class="np-stage" id="np-stage">
183
+ <span class="np-cover-fallback" id="np-cover-fallback" aria-hidden="true">
184
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
185
+ </span>
186
+ <img class="np-cover" id="np-cover" alt="" referrerpolicy="no-referrer">
187
+ <video class="np-video" id="np-video" playsinline preload="metadata" hidden></video>
188
+ <div class="np-loading" id="np-loading" hidden><div class="np-spinner"></div></div>
189
+ <div class="np-lyrics" id="np-lyrics" hidden>
190
+ <div class="np-lyrics-inner">
191
+ <div class="np-lyrics-head">
192
+ <h3>Lyrics</h3>
193
+ <button class="np-panel-close" id="np-lyrics-close" aria-label="Close lyrics">
194
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
195
+ </button>
196
+ </div>
197
+ <p class="np-lyrics-body" id="np-lyrics-body">Pick a song to load lyrics.</p>
198
+ <div class="np-lyrics-meta" id="np-lyrics-meta"></div>
199
+ </div>
200
+ </div>
201
+ <div class="np-queue" id="np-queue" hidden>
202
+ <div class="np-queue-head">
203
+ <h3>Queue</h3>
204
+ <span id="np-queue-count">0 tracks</span>
205
+ <button class="np-panel-close" id="np-queue-close" aria-label="Close queue">
206
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
207
+ </button>
208
+ </div>
209
+ <div class="np-queue-list" id="np-queue-list"></div>
210
+ </div>
211
+ </div>
212
+
213
+ <div class="np-info">
214
+ <div class="np-info-text">
215
+ <a class="np-title" id="np-title" href="#">—</a>
216
+ <a class="np-artist" id="np-artist" href="#">—</a>
217
+ </div>
218
+ <button class="np-like" id="np-like" aria-label="Like">
219
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
220
+ </button>
221
+ </div>
222
+
223
+ <div class="np-progress">
224
+ <div class="np-bar" id="np-seek">
225
+ <div class="np-bar-fill" id="np-seek-fill"></div>
226
+ <div class="np-bar-knob" id="np-seek-knob"></div>
227
+ </div>
228
+ <div class="np-times">
229
+ <span id="np-cur">0:00</span>
230
+ <span id="np-dur">0:00</span>
231
+ </div>
232
+ </div>
233
+
234
+ <div class="np-controls">
235
+ <button class="np-ctl" id="np-shuffle" aria-label="Shuffle">
236
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
237
+ </button>
238
+ <button class="np-ctl" id="np-prev" aria-label="Previous">
239
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
240
+ </button>
241
+ <button class="np-play-big" id="np-play" aria-label="Play">
242
+ <svg id="np-play-svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
243
+ </button>
244
+ <button class="np-ctl" id="np-next" aria-label="Next">
245
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6z"/></svg>
246
+ </button>
247
+ <button class="np-ctl" id="np-loop" aria-label="Loop">
248
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
249
+ </button>
250
+ </div>
251
+
252
+ <div class="np-tools">
253
+ <button class="np-tool" id="np-video-btn" aria-label="Toggle video">
254
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
255
+ <span>Video</span>
256
+ </button>
257
+ <button class="np-tool" id="np-lyrics-btn" aria-label="Toggle lyrics">
258
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 10h12M4 14h16M4 18h8"/></svg>
259
+ <span>Lyrics</span>
260
+ </button>
261
+ <button class="np-tool" id="np-queue-btn" aria-label="Toggle queue">
262
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
263
+ <span>Queue</span>
264
+ </button>
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ <div id="toast-container"></div>
270
+
271
+ <script src="/music-api.js"></script>
272
+ <script src="/music.js"></script>
273
+ </body>
274
+ </html>
frontend/dist/music.js ADDED
@@ -0,0 +1,1729 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ const $ = (s, r) => (r || document).querySelector(s);
3
+ const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
4
+
5
+ const audio = $('#pb-audio');
6
+ const video = $('#np-video');
7
+ const view = $('#ms-view');
8
+ const playerBar = $('#player-bar');
9
+ const npModal = $('#np-modal');
10
+
11
+ let mediaMode = 'audio';
12
+ function activeMedia() { return mediaMode === 'video' ? video : audio; }
13
+
14
+ const STORE = {
15
+ liked: 'kv_music_liked',
16
+ history: 'kv_music_history',
17
+ vol: 'kv_music_vol',
18
+ };
19
+
20
+ const state = {
21
+ queue: [],
22
+ index: -1,
23
+ liked: new Set(JSON.parse(localStorage.getItem(STORE.liked) || '[]')),
24
+ history: JSON.parse(localStorage.getItem(STORE.history) || '[]'),
25
+ volume: parseFloat(localStorage.getItem(STORE.vol) || '0.8'),
26
+ muted: false,
27
+ loop: false,
28
+ shuffle: false,
29
+ navStack: [],
30
+ navFwd: [],
31
+ scrubbing: false,
32
+ };
33
+
34
+ const api = {
35
+ search: (q) => fetchAPI(`/themes/search?q=${encodeURIComponent(q)}`),
36
+ anime: (id) => fetchAPI(`/themes/anime/${encodeURIComponent(id)}`),
37
+ artist: (slug) => fetchAPI(`/themes/artist/${encodeURIComponent(slug)}`),
38
+ recent: () => fetchAPI('/themes/recent'),
39
+ year: (y) => fetchAPI(`/themes/year/${encodeURIComponent(y)}`),
40
+ artists: () => fetchAPI('/themes/top-artists'),
41
+ };
42
+
43
+ const esc = (s) => String(s == null ? '' : s).replace(/[<>&"']/g, c => ({
44
+ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;',
45
+ }[c]));
46
+
47
+ const fmtTime = (sec) => {
48
+ if (!isFinite(sec) || sec < 0) sec = 0;
49
+ const m = Math.floor(sec / 60);
50
+ const s = Math.floor(sec % 60);
51
+ return `${m}:${s < 10 ? '0' : ''}${s}`;
52
+ };
53
+
54
+ const placeholderCover = (label) => {
55
+ const t = (label || '?').slice(0, 1).toUpperCase();
56
+ return `<div class="ms-card-cover-fallback"><span style="font-size:2.4rem;font-weight:900;letter-spacing:-0.04em;">${esc(t)}</span></div>`;
57
+ };
58
+
59
+ function coverImg(url, label, opts) {
60
+ opts = opts || {};
61
+ const cls = opts.cls || 'ms-card-cover';
62
+ if (!url) return placeholderCover(label || '');
63
+ return `<img class="${cls}" data-cover-fallback="${esc(label || '')}"`
64
+ + ` src="${esc(url)}" alt="" loading="lazy" referrerpolicy="no-referrer">`;
65
+ }
66
+
67
+ document.addEventListener('error', (e) => {
68
+ const img = e.target;
69
+ if (!(img instanceof HTMLImageElement)) return;
70
+ if (!img.dataset || img.dataset.coverFallback == null) return;
71
+ const label = img.dataset.coverFallback;
72
+ const wrap = img.parentNode;
73
+ if (!wrap) return;
74
+
75
+ const tmp = document.createElement('div');
76
+ tmp.innerHTML = placeholderCover(label);
77
+ const node = tmp.firstChild;
78
+ if (node) wrap.replaceChild(node, img);
79
+ }, true);
80
+
81
+ const coverUrl = (anime) => {
82
+ if (!anime) return '';
83
+ const imgs = anime.images || [];
84
+ const large = imgs.find(i => i.facet === 'Large Cover');
85
+ const small = imgs.find(i => i.facet === 'Small Cover');
86
+ return (large || small || imgs[0])?.link || '';
87
+ };
88
+
89
+ const artistImageUrl = (artist) => {
90
+ if (!artist) return '';
91
+ const imgs = artist.images || [];
92
+ const large = imgs.find(i => i.facet === 'Large Cover');
93
+ const small = imgs.find(i => i.facet === 'Small Cover');
94
+ return (large || small || imgs[0])?.link || '';
95
+ };
96
+
97
+ function trackFromTheme(anime, theme, animeOverride) {
98
+ const animeRef = animeOverride || theme.anime || anime;
99
+ if (!animeRef) return null;
100
+ const entries = theme.animethemeentries || [];
101
+ let pickedAudio = null, pickedVideo = null, pickedEntry = null;
102
+ for (const e of entries) {
103
+ for (const v of (e.videos || [])) {
104
+ if (v.audio && v.audio.link) {
105
+ pickedAudio = v.audio;
106
+ pickedVideo = v;
107
+ pickedEntry = e;
108
+ break;
109
+ }
110
+ }
111
+ if (pickedAudio) break;
112
+ }
113
+ if (!pickedAudio) return null;
114
+ const cover = coverUrl(animeRef) || (theme.song?.artists?.[0] && artistImageUrl(theme.song.artists[0])) || '';
115
+ const slug = theme.slug || `${theme.type || ''}${theme.sequence || ''}`;
116
+ return {
117
+ id: `${theme.id}-${pickedAudio.id}`,
118
+ themeId: theme.id,
119
+ title: theme.song?.title || slug || 'Untitled',
120
+ type: slug,
121
+ typeKind: theme.type || '',
122
+ artists: theme.song?.artists || [],
123
+ animeId: animeRef.id,
124
+ animeName: animeRef.name,
125
+ animeSlug: animeRef.slug,
126
+ cover,
127
+ audio: pickedAudio.link,
128
+ videoLink: pickedVideo?.link,
129
+ episodes: pickedEntry?.episodes || '',
130
+ };
131
+ }
132
+
133
+ function tracksFromAnime(anime) {
134
+ return (anime.animethemes || [])
135
+ .map(t => trackFromTheme(anime, t))
136
+ .filter(Boolean);
137
+ }
138
+
139
+ function persistLiked() { localStorage.setItem(STORE.liked, JSON.stringify(Array.from(state.liked))); }
140
+ function persistHistory() { localStorage.setItem(STORE.history, JSON.stringify(state.history.slice(0, 60))); }
141
+ function persistVol() { localStorage.setItem(STORE.vol, String(state.volume)); }
142
+
143
+ function isLiked(track) { return state.liked.has(track.id); }
144
+ function toggleLike(track) {
145
+ if (state.liked.has(track.id)) {
146
+ state.liked.delete(track.id);
147
+ try { localStorage.removeItem(`kv_music_track_${track.id}`); } catch (_) { }
148
+ } else {
149
+ state.liked.add(track.id);
150
+ try { localStorage.setItem(`kv_music_track_${track.id}`, JSON.stringify(track)); } catch (_) { }
151
+ }
152
+ persistLiked();
153
+ updatePlayerLike();
154
+ $$(`.ms-track-like[data-tid="${CSS.escape(track.id)}"]`).forEach(b => {
155
+ b.classList.toggle('is-liked', state.liked.has(track.id));
156
+ });
157
+ }
158
+
159
+ function pushHistory(track) {
160
+ try { localStorage.setItem(`kv_music_track_${track.id}`, JSON.stringify(track)); } catch (_) { }
161
+ state.history = [track.id, ...state.history.filter(id => id !== track.id)].slice(0, 60);
162
+ persistHistory();
163
+ }
164
+
165
+ function loadStoredTracks(ids) {
166
+ const out = [];
167
+ for (const id of ids) {
168
+ try {
169
+ const raw = localStorage.getItem(`kv_music_track_${id}`);
170
+ if (raw) out.push(JSON.parse(raw));
171
+ } catch (_) { }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ function play(queue, idx) {
177
+ if (!queue || !queue.length) return;
178
+ state.queue = queue.slice();
179
+ state.index = Math.max(0, Math.min(idx || 0, state.queue.length - 1));
180
+ loadCurrent(true);
181
+ renderQueue();
182
+
183
+ if (window.innerWidth <= 720) openNP();
184
+ }
185
+
186
+ function jumpTo(idx) {
187
+ if (idx < 0 || idx >= state.queue.length) return;
188
+ state.index = idx;
189
+ loadCurrent(true);
190
+ renderQueue();
191
+ }
192
+
193
+ function renderQueue() {
194
+ const list = $('#np-queue-list');
195
+ const count = $('#np-queue-count');
196
+ if (!list || !count) return;
197
+ const q = state.queue;
198
+ count.textContent = q.length ? `${q.length} track${q.length > 1 ? 's' : ''}` : 'Empty';
199
+ if (!q.length) {
200
+ list.innerHTML = `<div class="np-queue-empty">Pick a song to start playing.</div>`;
201
+ return;
202
+ }
203
+ list.innerHTML = q.map((t, i) => `
204
+ <button class="np-queue-row${i === state.index ? ' is-current' : ''}" data-qjump="${i}">
205
+ <span class="np-queue-cover">
206
+ ${t.cover ? `<img src="${esc(t.cover)}" alt="" referrerpolicy="no-referrer">` : ''}
207
+ ${i === state.index ? '<span class="np-queue-eq"><i></i><i></i><i></i></span>' : ''}
208
+ </span>
209
+ <span class="np-queue-text">
210
+ <span class="np-queue-title">${esc(t.title)}</span>
211
+ <span class="np-queue-sub">${esc((t.artists || []).map(a => a.name).join(', ') || t.animeName || '')}</span>
212
+ </span>
213
+ <span class="np-queue-tag">${i < state.index ? 'Played' : (i === state.index ? 'Now' : 'Next')}</span>
214
+ </button>
215
+ `).join('');
216
+ $$('[data-qjump]', list).forEach(btn => {
217
+ btn.addEventListener('click', () => jumpTo(parseInt(btn.dataset.qjump, 10)));
218
+ });
219
+
220
+ const cur = list.querySelector('.is-current');
221
+ if (cur) cur.scrollIntoView({ block: 'nearest' });
222
+ }
223
+
224
+ function loadCurrent(autoplay) {
225
+ const t = state.queue[state.index];
226
+ if (!t) return;
227
+ playerBar.dataset.empty = '0';
228
+
229
+ document.body.classList.remove('pb-hidden');
230
+
231
+ if (mediaMode === 'video' && !t.videoLink) {
232
+ setVideoMode(false, false);
233
+ }
234
+
235
+ if (mediaMode === 'video') {
236
+ video.src = t.videoLink;
237
+ try { audio.removeAttribute('src'); audio.load(); } catch (_) { }
238
+ video.muted = state.muted;
239
+ video.volume = state.volume;
240
+ } else {
241
+ audio.src = t.audio;
242
+ try { video.removeAttribute('src'); video.load(); } catch (_) { }
243
+ audio.muted = state.muted;
244
+ audio.volume = state.volume;
245
+ }
246
+
247
+ if (autoplay) {
248
+ const p = activeMedia().play();
249
+ if (p && p.catch) p.catch(() => { });
250
+ }
251
+ renderPlayerMeta(t);
252
+ pushHistory(t);
253
+ updateNowPlayingMarkers();
254
+ }
255
+
256
+ function setVideoMode(on, reload = true) {
257
+ const t = state.queue[state.index];
258
+ if (on && (!t || !t.videoLink)) {
259
+ toast && toast('No video available for this track');
260
+ return;
261
+ }
262
+ const wasPaused = activeMedia().paused;
263
+ const ct = activeMedia().currentTime || 0;
264
+ activeMedia().pause();
265
+
266
+ mediaMode = on ? 'video' : 'audio';
267
+ document.body.classList.toggle('np-video-on', mediaMode === 'video');
268
+ $('#np-video-btn').classList.toggle('is-on', mediaMode === 'video');
269
+ const npVid = $('#np-video');
270
+ const npCov = $('#np-cover');
271
+ if (npVid) npVid.hidden = mediaMode !== 'video';
272
+ if (npCov) npCov.hidden = mediaMode === 'video';
273
+
274
+ if (!t) return;
275
+
276
+ if (reload) {
277
+ if (mediaMode === 'video') {
278
+ video.src = t.videoLink;
279
+ try { audio.removeAttribute('src'); audio.load(); } catch (_) { }
280
+ } else {
281
+ audio.src = t.audio;
282
+ try { video.removeAttribute('src'); video.load(); } catch (_) { }
283
+ }
284
+ const m = activeMedia();
285
+ m.muted = state.muted;
286
+ m.volume = state.volume;
287
+ const onMeta = () => {
288
+ try { m.currentTime = ct; } catch (_) { }
289
+ if (!wasPaused) m.play().catch(() => { });
290
+ m.removeEventListener('loadedmetadata', onMeta);
291
+ };
292
+ m.addEventListener('loadedmetadata', onMeta);
293
+ }
294
+ }
295
+
296
+ function toast(msg) {
297
+ const c = $('#toast-container');
298
+ if (!c) return;
299
+ const el = document.createElement('div');
300
+ el.className = 'kv-toast';
301
+ el.textContent = msg;
302
+ c.appendChild(el);
303
+ setTimeout(() => el.classList.add('show'), 10);
304
+ setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 300); }, 2200);
305
+ }
306
+
307
+ function next(auto) {
308
+ if (!state.queue.length) return;
309
+ if (state.shuffle && state.queue.length > 1) {
310
+ let n;
311
+ do { n = Math.floor(Math.random() * state.queue.length); } while (n === state.index);
312
+ state.index = n;
313
+ } else {
314
+ state.index = state.index + 1;
315
+ if (state.index >= state.queue.length) {
316
+ if (state.loop || !auto) state.index = 0;
317
+ else { state.index = state.queue.length - 1; activeMedia().pause(); return; }
318
+ }
319
+ }
320
+ loadCurrent(true);
321
+ renderQueue();
322
+ }
323
+
324
+ function prev() {
325
+ if (!state.queue.length) return;
326
+ if (activeMedia().currentTime > 3) { activeMedia().currentTime = 0; return; }
327
+ state.index = (state.index - 1 + state.queue.length) % state.queue.length;
328
+ loadCurrent(true);
329
+ renderQueue();
330
+ }
331
+
332
+ function togglePlay() {
333
+ if (playerBar.dataset.empty === '1') return;
334
+ const m = activeMedia();
335
+ if (m.paused) m.play().catch(() => { });
336
+ else m.pause();
337
+ }
338
+
339
+ function setCoverImg(imgEl, btnEl, url) {
340
+ if (!imgEl) return;
341
+ if (url) {
342
+ imgEl.classList.remove('is-failed');
343
+ if (imgEl.getAttribute('src') !== url) imgEl.src = url;
344
+ if (btnEl) btnEl.classList.add('has-cover');
345
+ } else {
346
+ imgEl.removeAttribute('src');
347
+ imgEl.classList.add('is-failed');
348
+ if (btnEl) btnEl.classList.remove('has-cover');
349
+ }
350
+ }
351
+
352
+ function renderPlayerMeta(t) {
353
+ const pbCover = $('#pb-cover');
354
+ const pbCoverBtn = $('#pb-expand');
355
+ setCoverImg(pbCover, pbCoverBtn, t.cover || '');
356
+ const titleEl = $('#pb-title');
357
+ titleEl.textContent = t.title;
358
+ titleEl.dataset.animeId = t.animeId || '';
359
+ const artistEl = $('#pb-artist');
360
+ const artistsLabel = (t.artists && t.artists.length)
361
+ ? t.artists.map(a => a.name).join(', ')
362
+ : (t.animeName || '—');
363
+ if (t.artists && t.artists.length) {
364
+ const first = t.artists[0];
365
+ artistEl.textContent = artistsLabel;
366
+ artistEl.href = `#/artist/${encodeURIComponent(first.slug || first.id)}`;
367
+ } else {
368
+ artistEl.textContent = artistsLabel;
369
+ artistEl.href = `#/anime/${encodeURIComponent(t.animeId)}`;
370
+ }
371
+
372
+ const npCover = $('#np-cover');
373
+ const npTitle = $('#np-title');
374
+ const npArtist = $('#np-artist');
375
+ const npSource = $('#np-source-name');
376
+ const npBg = $('#np-bg');
377
+ setCoverImg(npCover, null, t.cover || '');
378
+ if (npTitle) { npTitle.textContent = t.title; npTitle.href = `#/anime/${encodeURIComponent(t.animeId)}`; }
379
+ if (npArtist) {
380
+ npArtist.textContent = artistsLabel;
381
+ const first = (t.artists || [])[0];
382
+ npArtist.href = first ? `#/artist/${encodeURIComponent(first.slug || first.id)}` : `#/anime/${encodeURIComponent(t.animeId)}`;
383
+ }
384
+ if (npSource) { npSource.textContent = t.animeName || '—'; npSource.href = `#/anime/${encodeURIComponent(t.animeId)}`; }
385
+ if (npBg && t.cover) npBg.style.backgroundImage = `url("${t.cover}")`;
386
+
387
+ const vidBtn = $('#np-video-btn');
388
+ if (vidBtn) vidBtn.classList.toggle('is-disabled', !t.videoLink);
389
+
390
+ const lyricsMeta = $('#np-lyrics-meta');
391
+ if (lyricsMeta) {
392
+ lyricsMeta.innerHTML = `
393
+ <div><span>Anime</span><strong>${esc(t.animeName || '—')}</strong></div>
394
+ <div><span>Type</span><strong>${esc(t.type || '—')}</strong></div>
395
+ <div><span>Episodes</span><strong>${esc(t.episodes || '—')}</strong></div>
396
+ <div><span>Artists</span><strong>${esc((t.artists || []).map(a => a.name).join(', ') || '—')}</strong></div>
397
+ `;
398
+ }
399
+
400
+ _syncedLines = [];
401
+ _activeLyricIdx = -1;
402
+ const lyricsBody = $('#np-lyrics-body');
403
+ if (lyricsBody) {
404
+ lyricsBody.dataset.trackId = t.id;
405
+ lyricsBody.innerHTML = '<span class="np-lyric-line is-loading">Loading lyrics…</span>';
406
+ }
407
+ fetchLyricsFor(t);
408
+
409
+ updatePlayerLike();
410
+ }
411
+
412
+ let _lyricsCache = {};
413
+ let _syncedLines = [];
414
+ let _rawSyncedLines = [];
415
+ let _lyricsDuration = 0;
416
+ let _activeLyricIdx = -1;
417
+ let _lyricRaf = null;
418
+
419
+ function parseLRC(lrc) {
420
+ const out = [];
421
+ const lineRe = /^((?:\[\d+:\d+(?:\.\d+)?\])+)(.*)$/;
422
+ const tagRe = /\[(\d+):(\d+(?:\.\d+)?)\]/g;
423
+ for (const raw of String(lrc).split(/\r?\n/)) {
424
+ const m = raw.match(lineRe);
425
+ if (!m) continue;
426
+ const text = m[2].trim();
427
+ let tag;
428
+ while ((tag = tagRe.exec(m[1])) !== null) {
429
+ const t = parseInt(tag[1], 10) * 60 + parseFloat(tag[2]);
430
+ out.push({ t, text });
431
+ }
432
+ }
433
+ out.sort((a, b) => a.t - b.t);
434
+ return out;
435
+ }
436
+
437
+ function renderPlainLyrics(text) {
438
+ const body = $('#np-lyrics-body');
439
+ if (!body) return;
440
+ if (!text || !text.trim()) {
441
+ body.innerHTML = '<span class="np-lyric-line is-empty">We couldn\'t find lyrics for this track yet.</span>';
442
+ return;
443
+ }
444
+ const lines = text.split(/\r?\n/);
445
+ body.innerHTML = lines
446
+ .map(l => `<div class="np-lyric-line">${esc(l) || '&nbsp;'}</div>`)
447
+ .join('');
448
+ }
449
+
450
+ function renderSyncedLyrics(lines) {
451
+ const body = $('#np-lyrics-body');
452
+ if (!body) return;
453
+
454
+ body.style.transform = '';
455
+ body.scrollTop = 0;
456
+ body.innerHTML = lines
457
+ .map((l, i) => `<div class="np-lyric-line" data-i="${i}">${esc(l.text) || '&nbsp;'}</div>`)
458
+ .join('');
459
+ }
460
+
461
+ function recomputeSyncedLines() {
462
+ if (!_rawSyncedLines.length) { _syncedLines = []; return; }
463
+ _syncedLines = _rawSyncedLines.slice();
464
+ _activeLyricIdx = -1;
465
+ }
466
+
467
+ function updateLyricsHighlight(currentTime) {
468
+ if (!_syncedLines.length) return;
469
+
470
+ let lo = 0, hi = _syncedLines.length - 1, idx = -1;
471
+ while (lo <= hi) {
472
+ const mid = (lo + hi) >> 1;
473
+ if (_syncedLines[mid].t <= currentTime) { idx = mid; lo = mid + 1; }
474
+ else hi = mid - 1;
475
+ }
476
+ if (idx === _activeLyricIdx) return;
477
+ _activeLyricIdx = idx;
478
+ const body = $('#np-lyrics-body');
479
+ if (!body) return;
480
+ const lines = body.querySelectorAll('.np-lyric-line');
481
+ lines.forEach((el, i) => {
482
+ el.classList.toggle('is-active', i === idx);
483
+ el.classList.toggle('is-past', i < idx);
484
+ });
485
+ if (idx < 0) return;
486
+ const active = lines[idx];
487
+ if (!active) return;
488
+
489
+ const viewH = body.clientHeight;
490
+ if (viewH <= 0) return;
491
+
492
+ const ACTIVE_RATIO = 0.42;
493
+ const target = active.offsetTop + (active.offsetHeight / 2) - (viewH * ACTIVE_RATIO);
494
+ scrollLyricsTo(body, Math.max(0, target));
495
+ }
496
+
497
+ let _lyricScrollRaf = 0;
498
+ function scrollLyricsTo(body, target) {
499
+ if (!body) return;
500
+ target = Math.max(0, Math.min(target, body.scrollHeight - body.clientHeight));
501
+ if (_lyricScrollRaf) cancelAnimationFrame(_lyricScrollRaf);
502
+ const start = body.scrollTop;
503
+ const delta = target - start;
504
+ if (Math.abs(delta) < 0.5) { body.scrollTop = target; return; }
505
+
506
+ const dist = Math.abs(delta);
507
+ const dur = Math.min(900, Math.max(280, dist * 0.9));
508
+ const t0 = performance.now();
509
+ const ease = (x) => 1 - Math.pow(1 - x, 3);
510
+ const step = (now) => {
511
+ const k = Math.min(1, (now - t0) / dur);
512
+ body.scrollTop = start + delta * ease(k);
513
+ if (k < 1) _lyricScrollRaf = requestAnimationFrame(step);
514
+ else _lyricScrollRaf = 0;
515
+ };
516
+ _lyricScrollRaf = requestAnimationFrame(step);
517
+ }
518
+
519
+ function cleanLyricTitle(raw) {
520
+ if (!raw) return '';
521
+ let s = String(raw);
522
+
523
+ s = s.replace(/^\s*(?:OP|ED|IN)\s*\d*\s*[-–:]\s*/i, '');
524
+ s = s.replace(/^\s*(?:Opening|Ending|Insert\s*Song|Theme)\s*\d*\s*[-–:]\s*/i, '');
525
+
526
+ s = s.replace(/\([^)]*\)/g, '');
527
+ s = s.replace(/\[[^\]]*\]/g, '');
528
+
529
+ s = s.replace(/^[\s"'“”『「]+|[\s"'“”』」]+$/g, '');
530
+ return s.trim();
531
+ }
532
+
533
+ function waitForAudioDuration(m, timeoutMs) {
534
+ return new Promise((resolve) => {
535
+ if (!m) return resolve(0);
536
+ if (isFinite(m.duration) && m.duration > 0) return resolve(m.duration);
537
+ let done = false;
538
+ const finish = (v) => {
539
+ if (done) return;
540
+ done = true;
541
+ m.removeEventListener('loadedmetadata', onMeta);
542
+ m.removeEventListener('durationchange', onMeta);
543
+ resolve(v);
544
+ };
545
+ const onMeta = () => {
546
+ if (isFinite(m.duration) && m.duration > 0) finish(m.duration);
547
+ };
548
+ m.addEventListener('loadedmetadata', onMeta);
549
+ m.addEventListener('durationchange', onMeta);
550
+ setTimeout(() => finish(0), timeoutMs || 4000);
551
+ });
552
+ }
553
+
554
+ async function fetchLyricsFor(track) {
555
+ if (!track) return;
556
+ const lyricsBody = $('#np-lyrics-body');
557
+ if (!lyricsBody) return;
558
+ const key = track.id;
559
+ const apply = (data) => {
560
+ if (lyricsBody.dataset.trackId !== key) return;
561
+ const synced = data && data.synced;
562
+ const plain = data && data.lyrics;
563
+ _lyricsDuration = (data && Number(data.duration)) || 0;
564
+ if (synced) {
565
+ _rawSyncedLines = parseLRC(synced).filter(l => l.text || l.text === '');
566
+ if (_rawSyncedLines.length) {
567
+ recomputeSyncedLines();
568
+ renderSyncedLyrics(_syncedLines);
569
+ return;
570
+ }
571
+ }
572
+ _rawSyncedLines = [];
573
+ _syncedLines = [];
574
+ renderPlainLyrics(plain || (synced ? String(synced).replace(/^\[\d+:\d+(?:\.\d+)?\]\s?/gm, '') : ''));
575
+ };
576
+
577
+ const audioDur = await waitForAudioDuration(activeMedia(), 4000);
578
+ if (lyricsBody.dataset.trackId !== key) return;
579
+
580
+ const durBucket = audioDur ? Math.round(audioDur / 5) * 5 : 0;
581
+ const cacheKey = `${key}|${durBucket}`;
582
+ if (_lyricsCache[cacheKey] != null) { apply(_lyricsCache[cacheKey]); return; }
583
+
584
+ const title = cleanLyricTitle(track.title || '');
585
+
586
+ const firstArtist = ((track.artists || [])[0] || {}).name || '';
587
+ try {
588
+ const durQs = audioDur ? `&duration=${audioDur.toFixed(2)}` : '';
589
+ const url = `/api/lyrics?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(firstArtist)}${durQs}`;
590
+ const res = await fetch(url);
591
+ const data = await res.json();
592
+ _lyricsCache[cacheKey] = data;
593
+ apply(data);
594
+ } catch (_) {
595
+ apply({});
596
+ }
597
+ }
598
+
599
+ function updatePlayerLike() {
600
+ const t = state.queue[state.index];
601
+ const btn = $('#pb-like');
602
+ const npBtn = $('#np-like');
603
+ const liked = !!(t && state.liked.has(t.id));
604
+ if (btn) btn.classList.toggle('is-liked', liked);
605
+ if (npBtn) npBtn.classList.toggle('is-liked', liked);
606
+ }
607
+
608
+ function updateNowPlayingMarkers() {
609
+ const t = state.queue[state.index];
610
+ $$('.ms-track').forEach(row => {
611
+ row.classList.toggle('is-playing', !!(t && row.dataset.tid === t.id));
612
+ });
613
+ }
614
+
615
+ function setPlayUI(playing) {
616
+ const pause = '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>';
617
+ const play = '<path d="M8 5v14l11-7z"/>';
618
+ const ic = playing ? pause : play;
619
+ const set = (id) => {
620
+ const el = $('#' + id);
621
+ if (el) el.outerHTML = `<svg id="${id}" viewBox="0 0 24 24" fill="currentColor">${ic}</svg>`;
622
+ };
623
+ set('pb-play-svg');
624
+ set('pb-play-mobile-svg');
625
+ set('np-play-svg');
626
+
627
+ document.body.classList.toggle('is-playing', !!playing);
628
+ }
629
+
630
+ function openNP() {
631
+ if (playerBar.dataset.empty === '1') return;
632
+ npModal.hidden = false;
633
+ document.body.classList.add('np-open');
634
+
635
+ $('#np-shuffle').classList.toggle('is-on', state.shuffle);
636
+ $('#np-loop').classList.toggle('is-on', state.loop);
637
+ $('#np-video-btn').classList.toggle('is-on', mediaMode === 'video');
638
+ setSeek();
639
+ updatePlayerLike();
640
+ }
641
+ function closeNP() {
642
+ npModal.hidden = true;
643
+ document.body.classList.remove('np-open');
644
+ document.body.classList.remove('np-lyrics-open');
645
+ }
646
+
647
+ function setVolUI() {
648
+ const v = state.muted ? 0 : state.volume;
649
+ $('#pb-vol-fill').style.width = (v * 100) + '%';
650
+ const knob = $('#pb-vol-knob');
651
+ if (knob) knob.style.left = (v * 100) + '%';
652
+ const svg = $('#pb-vol-svg');
653
+ if (!svg) return;
654
+ let path;
655
+ if (v === 0) path = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>';
656
+ else if (v < 0.5) path = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>';
657
+ else path = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>';
658
+ svg.innerHTML = path;
659
+ }
660
+
661
+ function setSeek(t) {
662
+ const m = activeMedia();
663
+ const dur = m.duration;
664
+ const cur = t == null ? m.currentTime : t;
665
+ $('#pb-cur').textContent = fmtTime(cur);
666
+ $('#pb-dur').textContent = fmtTime(dur);
667
+ const pct = dur ? (cur / dur) * 100 : 0;
668
+ $('#pb-seek-fill').style.width = pct + '%';
669
+ const knob = $('#pb-seek-knob');
670
+ if (knob) knob.style.left = pct + '%';
671
+
672
+ const npCur = $('#np-cur'), npDur = $('#np-dur'), npFill = $('#np-seek-fill'), npKnob = $('#np-seek-knob');
673
+ if (npCur) npCur.textContent = fmtTime(cur);
674
+ if (npDur) npDur.textContent = fmtTime(dur);
675
+ if (npFill) npFill.style.width = pct + '%';
676
+ if (npKnob) npKnob.style.left = pct + '%';
677
+ }
678
+
679
+ function bindBar(bar, onChange) {
680
+ let pending = null;
681
+ let rafId = 0;
682
+ const flush = () => {
683
+ rafId = 0;
684
+ if (pending != null) { onChange(pending); pending = null; }
685
+ };
686
+ const schedule = (pct) => {
687
+ pending = pct;
688
+ if (!rafId) rafId = requestAnimationFrame(flush);
689
+ };
690
+ const setFromEvent = (e) => {
691
+ const r = bar.getBoundingClientRect();
692
+ const x = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
693
+ const pct = Math.max(0, Math.min(1, x / r.width));
694
+ schedule(pct);
695
+ };
696
+ let dragging = false;
697
+ const onUp = () => {
698
+ dragging = false;
699
+ state.scrubbing = false;
700
+ document.removeEventListener('mousemove', onMove);
701
+ document.removeEventListener('touchmove', onMove);
702
+ document.removeEventListener('mouseup', onUp);
703
+ document.removeEventListener('touchend', onUp);
704
+ if (rafId) { cancelAnimationFrame(rafId); rafId = 0; flush(); }
705
+ };
706
+ const onMove = (e) => { if (dragging) setFromEvent(e); };
707
+ bar.addEventListener('mousedown', (e) => {
708
+ dragging = true; state.scrubbing = true; setFromEvent(e);
709
+ document.addEventListener('mousemove', onMove);
710
+ document.addEventListener('mouseup', onUp);
711
+ });
712
+ bar.addEventListener('touchstart', (e) => {
713
+ dragging = true; state.scrubbing = true; setFromEvent(e);
714
+ document.addEventListener('touchmove', onMove, { passive: true });
715
+ document.addEventListener('touchend', onUp);
716
+ }, { passive: true });
717
+ bar.addEventListener('click', setFromEvent);
718
+ }
719
+
720
+ let _stageSpinnerTimer = 0;
721
+ function showStageLoading(on) {
722
+ const el = $('#np-loading');
723
+ if (!el) return;
724
+ if (on) {
725
+ if (_stageSpinnerTimer) return;
726
+ _stageSpinnerTimer = setTimeout(() => {
727
+ _stageSpinnerTimer = 0;
728
+ el.hidden = false;
729
+ }, 350);
730
+ } else {
731
+ if (_stageSpinnerTimer) { clearTimeout(_stageSpinnerTimer); _stageSpinnerTimer = 0; }
732
+ el.hidden = true;
733
+ }
734
+ }
735
+
736
+ function navigate(hash) {
737
+ if (location.hash !== hash) location.hash = hash;
738
+ else handleRoute();
739
+ }
740
+
741
+ function handleRoute() {
742
+ const hash = location.hash || '#/';
743
+ if (state.navStack[state.navStack.length - 1] !== hash) {
744
+ state.navStack.push(hash);
745
+ state.navFwd = [];
746
+ }
747
+ $('#ms-back').disabled = state.navStack.length <= 1;
748
+ $('#ms-fwd').disabled = state.navFwd.length === 0;
749
+
750
+ $$('.ms-link').forEach(l => {
751
+ l.classList.toggle('active', l.dataset.route === hash || (hash === '#/' && l.dataset.route === '#/'));
752
+ });
753
+
754
+ if (hash.startsWith('#/search?')) {
755
+ const q = decodeURIComponent(hash.split('q=')[1] || '');
756
+ $('#ms-search-input').value = q;
757
+ $('#ms-search-clear').hidden = !q;
758
+ renderSearch(q);
759
+ } else if (hash.startsWith('#/anime/')) {
760
+ renderAnime(decodeURIComponent(hash.split('/')[2] || ''));
761
+ } else if (hash.startsWith('#/artist/')) {
762
+ renderArtist(decodeURIComponent(hash.split('/')[2] || ''));
763
+ } else if (hash.startsWith('#/year/')) {
764
+ renderYear(hash.split('/')[2]);
765
+ } else if (hash === '#/recent') renderRecent();
766
+ else if (hash === '#/liked') renderLiked();
767
+ else if (hash === '#/history') renderHistory();
768
+ else if (hash === '#/artists') renderTopArtists();
769
+ else renderHome();
770
+
771
+ view.scrollTo({ top: 0 });
772
+ $('#music-main').scrollTo({ top: 0 });
773
+ closeSidebarIfMobile();
774
+ }
775
+
776
+ function loadingHTML(label) {
777
+ return `<div class="ms-loading"><div class="ms-spinner"></div><div>${esc(label || 'Loading…')}</div></div>`;
778
+ }
779
+
780
+ function emptyHTML(title, sub) {
781
+ return `<div class="ms-empty">
782
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
783
+ <h3>${esc(title)}</h3>
784
+ <p>${esc(sub || '')}</p>
785
+ </div>`;
786
+ }
787
+
788
+ function animeCardHTML(anime) {
789
+ const cover = coverUrl(anime);
790
+ const themesCount = (anime.animethemes || []).length;
791
+ const sub = themesCount ? `${themesCount} theme${themesCount > 1 ? 's' : ''} · ${anime.year || ''}` : `${anime.year || ''} · ${anime.media_format || ''}`;
792
+ return `<a class="ms-card" data-anime-id="${esc(anime.id)}" href="#/anime/${encodeURIComponent(anime.id)}">
793
+ <div class="ms-card-cover-wrap">
794
+ ${coverImg(cover, anime.name)}
795
+ <button class="ms-card-play" data-play-anime="${esc(anime.id)}" aria-label="Play">
796
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
797
+ </button>
798
+ </div>
799
+ <div class="ms-card-title">${esc(anime.name)}</div>
800
+ <div class="ms-card-sub">${esc(sub)}</div>
801
+ </a>`;
802
+ }
803
+
804
+ function artistCardHTML(artist) {
805
+ const img = artistImageUrl(artist);
806
+ return `<a class="ms-card is-artist" href="#/artist/${encodeURIComponent(artist.slug || artist.id)}">
807
+ <div class="ms-card-cover-wrap">
808
+ ${coverImg(img, artist.name)}
809
+ </div>
810
+ <div class="ms-card-title">${esc(artist.name)}</div>
811
+ <div class="ms-card-sub">Artist</div>
812
+ </a>`;
813
+ }
814
+
815
+ function songCardHTML(song) {
816
+ const themes = song.animethemes || [];
817
+ const firstAnime = themes[0]?.anime;
818
+ const artists = (song.artists || []).map(a => a.name).join(', ');
819
+ const href = firstAnime ? `#/anime/${encodeURIComponent(firstAnime.id)}` : '#/';
820
+ return `<a class="ms-card" href="${href}">
821
+ <div class="ms-card-cover-wrap">
822
+ ${placeholderCover(song.title)}
823
+ ${firstAnime ? `<button class="ms-card-play" data-play-song-anime="${esc(firstAnime.id)}" data-song-theme="${esc(themes[0].id)}" aria-label="Play">
824
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
825
+ </button>` : ''}
826
+ </div>
827
+ <div class="ms-card-title">${esc(song.title)}</div>
828
+ <div class="ms-card-sub">${esc(artists || (firstAnime?.name || ''))}</div>
829
+ </a>`;
830
+ }
831
+
832
+ function bindCardClicks(scope) {
833
+ $$('[data-play-anime]', scope).forEach(btn => {
834
+ btn.addEventListener('click', async (e) => {
835
+ e.preventDefault();
836
+ e.stopPropagation();
837
+ const id = btn.dataset.playAnime;
838
+ btn.disabled = true;
839
+ try {
840
+ const data = await api.anime(id);
841
+ const a = data.anime;
842
+ if (!a) return;
843
+ const tracks = tracksFromAnime(a);
844
+ if (tracks.length) play(tracks, 0);
845
+ } finally { btn.disabled = false; }
846
+ });
847
+ });
848
+ $$('[data-play-song-anime]', scope).forEach(btn => {
849
+ btn.addEventListener('click', async (e) => {
850
+ e.preventDefault();
851
+ e.stopPropagation();
852
+ const aid = btn.dataset.playSongAnime;
853
+ const themeId = btn.dataset.songTheme;
854
+ btn.disabled = true;
855
+ try {
856
+ const data = await api.anime(aid);
857
+ const a = data.anime;
858
+ if (!a) return;
859
+ const tracks = tracksFromAnime(a);
860
+ if (!tracks.length) return;
861
+ const idx = Math.max(0, tracks.findIndex(t => String(t.themeId) === String(themeId)));
862
+ play(tracks, idx);
863
+ } finally { btn.disabled = false; }
864
+ });
865
+ });
866
+ }
867
+
868
+ function renderHome() {
869
+ const hour = new Date().getHours();
870
+ const greet = hour < 5 ? 'Good evening' : hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
871
+
872
+ view.innerHTML = `
873
+ <div class="ms-home-hero">
874
+ <h1 class="ms-home-greet">${greet}</h1>
875
+ <div class="ms-quick-grid" id="home-quick"></div>
876
+ </div>
877
+
878
+ <div class="ms-section-title">
879
+ <h2>Newest themes</h2>
880
+ <a href="#/recent">View all</a>
881
+ </div>
882
+ <div class="ms-card-grid" id="home-recent">${loadingHTML()}</div>
883
+
884
+ <div class="ms-section-title">
885
+ <h2>Popular artists</h2>
886
+ <a href="#/artists">View all</a>
887
+ </div>
888
+ <div class="ms-card-grid" id="home-artists">${loadingHTML()}</div>
889
+
890
+ <div class="ms-section-title">
891
+ <h2>From your library</h2>
892
+ <a href="#/liked">View all</a>
893
+ </div>
894
+ <div id="home-liked"></div>
895
+
896
+ <div class="ms-section-title">
897
+ <h2>Browse by year</h2>
898
+ </div>
899
+ <div class="ms-card-grid ms-years-grid" id="home-years"></div>
900
+ `;
901
+
902
+ const quickEl = $('#home-quick');
903
+ const quickIds = [];
904
+ Array.from(state.liked).slice(0, 4).forEach(id => quickIds.push(id));
905
+ state.history.slice(0, 6).forEach(id => { if (!quickIds.includes(id)) quickIds.push(id); });
906
+ const quickTracks = loadStoredTracks(quickIds.slice(0, 6));
907
+ if (quickTracks.length) {
908
+ quickEl.innerHTML = quickTracks.map(t => `
909
+ <a class="ms-quick-tile" href="#/anime/${encodeURIComponent(t.animeId)}" data-quick-tid="${esc(t.id)}">
910
+ ${t.cover ? `<img class="ms-quick-cover" src="${esc(t.cover)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : `<div class="ms-quick-cover" style="background:linear-gradient(135deg,#4c1d95,#1e1b4b)"></div>`}
911
+ <span class="ms-quick-name">${esc(t.title)}</span>
912
+ <button class="ms-quick-play" data-play-quick="${esc(t.id)}" aria-label="Play">
913
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
914
+ </button>
915
+ </a>
916
+ `).join('');
917
+ $$('[data-play-quick]', quickEl).forEach(btn => {
918
+ btn.addEventListener('click', (e) => {
919
+ e.preventDefault(); e.stopPropagation();
920
+ const t = quickTracks.find(x => x.id === btn.dataset.playQuick);
921
+ if (t) play([t], 0);
922
+ });
923
+ });
924
+ } else {
925
+ quickEl.innerHTML = `<div class="ms-quick-empty">Like songs and they'll show up here for one-tap play.</div>`;
926
+ }
927
+
928
+ api.recent().then(d => {
929
+ const items = (d.anime || []).slice(0, 12);
930
+ $('#home-recent').innerHTML = items.length
931
+ ? items.map(animeCardHTML).join('')
932
+ : emptyHTML('No themes found', '');
933
+ bindCardClicks($('#home-recent'));
934
+ }).catch(() => { $('#home-recent').innerHTML = emptyHTML('Could not load', 'Try again in a moment.'); });
935
+
936
+ api.artists().then(d => {
937
+ const items = (d.artists || []).slice(0, 8);
938
+ $('#home-artists').innerHTML = items.length
939
+ ? items.map(artistCardHTML).join('')
940
+ : emptyHTML('No artists', '');
941
+ }).catch(() => { $('#home-artists').innerHTML = emptyHTML('Could not load', 'Try again in a moment.'); });
942
+
943
+ const likedEl = $('#home-liked');
944
+ const likedTracks = loadStoredTracks(Array.from(state.liked).slice(0, 6));
945
+ if (likedTracks.length) {
946
+ likedEl.innerHTML = `<div class="ms-card-grid">${likedTracks.map(t => `
947
+ <div class="ms-card" data-tid="${esc(t.id)}">
948
+ <div class="ms-card-cover-wrap">
949
+ ${t.cover ? `<img class="ms-card-cover" src="${esc(t.cover)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : placeholderCover(t.title)}
950
+ <button class="ms-card-play" data-play-liked="${esc(t.id)}" aria-label="Play">
951
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
952
+ </button>
953
+ </div>
954
+ <div class="ms-card-title">${esc(t.title)}</div>
955
+ <div class="ms-card-sub">${esc((t.artists || []).map(a => a.name).join(', ') || t.animeName || '')}</div>
956
+ </div>
957
+ `).join('')}</div>`;
958
+ $$('[data-play-liked]', likedEl).forEach(btn => {
959
+ btn.addEventListener('click', (e) => {
960
+ e.preventDefault(); e.stopPropagation();
961
+ const t = likedTracks.find(x => x.id === btn.dataset.playLiked);
962
+ if (t) play([t], 0);
963
+ });
964
+ });
965
+ } else {
966
+ likedEl.innerHTML = `<div class="ms-empty" style="padding:36px 12px"><h3>Your library is empty</h3><p>Like songs to see them here.</p></div>`;
967
+ }
968
+
969
+ const yearsEl = $('#home-years');
970
+ const cy = new Date().getFullYear();
971
+ const years = [];
972
+ for (let y = cy; y >= cy - 7; y--) years.push(y);
973
+ const yearGradients = [
974
+ ['#7c3aed', '#1e1b4b'], ['#5b21b6', '#0f172a'], ['#4c1d95', '#1e293b'],
975
+ ['#9d6ef8', '#312e81'], ['#6d28d9', '#0b1023'], ['#7c3aed', '#0f0d1f'],
976
+ ['#5b21b6', '#1f1430'], ['#4c1d95', '#0a0712'],
977
+ ];
978
+ yearsEl.innerHTML = years.map((y, i) => {
979
+ const [a, b] = yearGradients[i % yearGradients.length];
980
+ return `<a class="ms-year-card" href="#/year/${y}">
981
+ <div class="ms-year-cover" style="background:linear-gradient(135deg, ${a} 0%, ${b} 100%);">
982
+ <span class="ms-year-num">${y}</span>
983
+ </div>
984
+ <div class="ms-card-title">${y}</div>
985
+ <div class="ms-card-sub">Themes from ${y}</div>
986
+ </a>`;
987
+ }).join('');
988
+ }
989
+
990
+ function renderRecent() {
991
+ view.innerHTML = `<div class="ms-section-title" style="margin-top:14px"><h2>Newest themes</h2></div>
992
+ <div class="ms-card-grid" id="recent-grid">${loadingHTML()}</div>`;
993
+ api.recent().then(d => {
994
+ const items = d.anime || [];
995
+ $('#recent-grid').innerHTML = items.length
996
+ ? items.map(animeCardHTML).join('')
997
+ : emptyHTML('No themes available', '');
998
+ bindCardClicks($('#recent-grid'));
999
+ });
1000
+ }
1001
+
1002
+ function renderYear(y) {
1003
+ view.innerHTML = `<div class="ms-section-title" style="margin-top:14px"><h2>Themes from ${esc(y)}</h2></div>
1004
+ <div class="ms-card-grid" id="year-grid">${loadingHTML()}</div>`;
1005
+ api.year(y).then(d => {
1006
+ const items = d.anime || [];
1007
+ $('#year-grid').innerHTML = items.length
1008
+ ? items.map(animeCardHTML).join('')
1009
+ : emptyHTML('Nothing here yet', `No themes catalogued for ${y}.`);
1010
+ bindCardClicks($('#year-grid'));
1011
+ });
1012
+ }
1013
+
1014
+ function renderTopArtists() {
1015
+ view.innerHTML = `<div class="ms-section-title" style="margin-top:14px"><h2>Artists</h2></div>
1016
+ <div class="ms-card-grid" id="artists-grid">${loadingHTML()}</div>`;
1017
+ api.artists().then(d => {
1018
+ const items = d.artists || [];
1019
+ $('#artists-grid').innerHTML = items.length
1020
+ ? items.map(artistCardHTML).join('')
1021
+ : emptyHTML('No artists', '');
1022
+ });
1023
+ }
1024
+
1025
+ function renderLiked() {
1026
+ const tracks = loadStoredTracks(Array.from(state.liked));
1027
+ if (!tracks.length) {
1028
+ view.innerHTML = `<div class="ms-album-hero">
1029
+ <div class="ms-album-cover" style="display:flex;align-items:center;justify-content:center;">
1030
+ <svg viewBox="0 0 24 24" width="80" height="80" fill="#fff" style="opacity:0.85"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
1031
+ </div>
1032
+ <div class="ms-album-info">
1033
+ <div class="ms-album-eyebrow">Playlist</div>
1034
+ <h1 class="ms-album-title">Liked Songs</h1>
1035
+ <div class="ms-album-meta">0 tracks</div>
1036
+ </div>
1037
+ </div>${emptyHTML('No liked songs yet', 'Tap the heart on any track to save it here.')}`;
1038
+ return;
1039
+ }
1040
+ renderTracklist({
1041
+ eyebrow: 'Playlist',
1042
+ title: 'Liked Songs',
1043
+ cover: tracks[0].cover,
1044
+ countLabel: `${tracks.length} track${tracks.length > 1 ? 's' : ''}`,
1045
+ tracks,
1046
+ asPlaylist: true,
1047
+ });
1048
+ }
1049
+
1050
+ function renderHistory() {
1051
+ const tracks = loadStoredTracks(state.history);
1052
+ if (!tracks.length) {
1053
+ view.innerHTML = emptyHTML('Nothing recently played', 'Play some songs to build your history.');
1054
+ return;
1055
+ }
1056
+ renderTracklist({
1057
+ eyebrow: 'History',
1058
+ title: 'Recently Played',
1059
+ cover: tracks[0].cover,
1060
+ countLabel: `${tracks.length} track${tracks.length > 1 ? 's' : ''}`,
1061
+ tracks,
1062
+ asPlaylist: true,
1063
+ });
1064
+ }
1065
+
1066
+ async function renderAnime(id) {
1067
+ view.innerHTML = loadingHTML('Loading album…');
1068
+ try {
1069
+ const data = await api.anime(id);
1070
+ const a = data.anime;
1071
+ if (!a) { view.innerHTML = emptyHTML('Not found', 'This anime has no themes catalogued.'); return; }
1072
+ const tracks = tracksFromAnime(a);
1073
+ renderTracklist({
1074
+ eyebrow: a.media_format || 'Anime',
1075
+ title: a.name,
1076
+ cover: coverUrl(a),
1077
+ countLabel: `${tracks.length} track${tracks.length === 1 ? '' : 's'} · ${a.year || ''}${a.season ? ' · ' + a.season : ''}`,
1078
+ tracks,
1079
+ asPlaylist: false,
1080
+ });
1081
+ } catch (e) {
1082
+ view.innerHTML = emptyHTML('Could not load', 'Please try again.');
1083
+ }
1084
+ }
1085
+
1086
+ async function renderArtist(slug) {
1087
+ view.innerHTML = loadingHTML('Loading artist…');
1088
+ try {
1089
+ const data = await api.artist(slug);
1090
+ const a = data.artist;
1091
+ if (!a) { view.innerHTML = emptyHTML('Artist not found', ''); return; }
1092
+ const tracks = [];
1093
+ for (const song of (a.songs || [])) {
1094
+ for (const theme of (song.animethemes || [])) {
1095
+ if (!theme.anime) continue;
1096
+ const merged = { ...theme, song };
1097
+ const tk = trackFromTheme(theme.anime, merged);
1098
+ if (tk) tracks.push(tk);
1099
+ }
1100
+ }
1101
+ renderTracklist({
1102
+ eyebrow: 'Artist',
1103
+ title: a.name,
1104
+ cover: artistImageUrl(a),
1105
+ round: true,
1106
+ countLabel: `${tracks.length} track${tracks.length === 1 ? '' : 's'}`,
1107
+ tracks,
1108
+ asPlaylist: true,
1109
+ });
1110
+ } catch (e) {
1111
+ view.innerHTML = emptyHTML('Could not load', 'Please try again.');
1112
+ }
1113
+ }
1114
+
1115
+ function renderTracklist({ eyebrow, title, cover, countLabel, tracks, asPlaylist, round }) {
1116
+ const heroCoverHTML = cover
1117
+ ? `<img src="${esc(cover)}" alt="" referrerpolicy="no-referrer">`
1118
+ : `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:4rem;font-weight:900;">${esc(title.slice(0, 1))}</div>`;
1119
+ view.innerHTML = `
1120
+ <div class="ms-album-hero">
1121
+ <div class="ms-album-cover" ${round ? 'style="border-radius:50%;"' : ''}>${heroCoverHTML}</div>
1122
+ <div class="ms-album-info">
1123
+ <div class="ms-album-eyebrow">${esc(eyebrow)}</div>
1124
+ <h1 class="ms-album-title">${esc(title)}</h1>
1125
+ <div class="ms-album-meta">${esc(countLabel)}</div>
1126
+ </div>
1127
+ </div>
1128
+ <div class="ms-album-actions">
1129
+ <button class="ms-play-big" id="album-play" aria-label="Play all">
1130
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
1131
+ </button>
1132
+ <button class="ms-icon-btn" id="album-shuffle" aria-label="Shuffle">
1133
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
1134
+ </button>
1135
+ </div>
1136
+ <div class="ms-tracks">
1137
+ ${tracks.length ? `
1138
+ <div class="ms-tracks-head">
1139
+ <span style="text-align:center">#</span>
1140
+ <span>Title</span>
1141
+ <span class="ms-th-anime">${asPlaylist ? 'Anime' : 'Type'}</span>
1142
+ <span class="ms-th-tag">${asPlaylist ? 'Type' : 'Episodes'}</span>
1143
+ <span></span>
1144
+ </div>
1145
+ ${tracks.map((t, i) => trackRowHTML(t, i, asPlaylist)).join('')}
1146
+ ` : emptyHTML('No tracks found', '')}
1147
+ </div>
1148
+ `;
1149
+ const playAll = $('#album-play');
1150
+ if (playAll) playAll.addEventListener('click', () => { if (tracks.length) play(tracks, 0); });
1151
+ const shuf = $('#album-shuffle');
1152
+ if (shuf) shuf.addEventListener('click', () => {
1153
+ if (!tracks.length) return;
1154
+ const shuffled = tracks.slice().sort(() => Math.random() - 0.5);
1155
+ state.shuffle = false;
1156
+ play(shuffled, 0);
1157
+ });
1158
+ bindTrackRows(tracks);
1159
+ updateNowPlayingMarkers();
1160
+ }
1161
+
1162
+ function trackRowHTML(t, i, asPlaylist) {
1163
+ const tagClass = (t.typeKind || '').toUpperCase() === 'ED' ? 'ed' : ((t.typeKind || '').toUpperCase() === 'IN' ? 'in' : '');
1164
+ const artistsText = (t.artists || []).map(a => `<a href="#/artist/${encodeURIComponent(a.slug || a.id)}" data-stop>${esc(a.name)}</a>`).join(', ');
1165
+ const middleCol = asPlaylist
1166
+ ? `<a class="ms-track-anime" href="#/anime/${encodeURIComponent(t.animeId)}" data-stop>${esc(t.animeName)}</a>`
1167
+ : `<span class="ms-track-tag-cell"><span class="ms-track-tag ${tagClass}">${esc(t.type)}</span></span>`;
1168
+ const fourthCol = asPlaylist
1169
+ ? `<span class="ms-track-tag-cell"><span class="ms-track-tag ${tagClass}">${esc(t.type)}</span></span>`
1170
+ : `<span class="ms-track-anime">${esc(t.episodes || '—')}</span>`;
1171
+ return `<div class="ms-track" data-tid="${esc(t.id)}" data-idx="${i}">
1172
+ <div class="ms-track-num">
1173
+ <span class="ms-num">${i + 1}</span>
1174
+ <span class="ms-now-bars"><span></span><span></span><span></span></span>
1175
+ <span class="ms-play-mini"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></span>
1176
+ </div>
1177
+ <div class="ms-track-meta">
1178
+ ${t.cover ? `<img class="ms-track-cover" src="${esc(t.cover)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : '<div class="ms-track-cover"></div>'}
1179
+ <div class="ms-track-text">
1180
+ <div class="ms-track-title">${esc(t.title)}</div>
1181
+ <div class="ms-track-sub">${artistsText || esc(t.animeName)}</div>
1182
+ </div>
1183
+ </div>
1184
+ ${middleCol}
1185
+ ${fourthCol}
1186
+ <button class="ms-track-like ${state.liked.has(t.id) ? 'is-liked' : ''}" data-tid="${esc(t.id)}" data-stop aria-label="Like">
1187
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
1188
+ </button>
1189
+ </div>`;
1190
+ }
1191
+
1192
+ function bindTrackRows(tracks) {
1193
+ $$('.ms-track', view).forEach(row => {
1194
+ row.addEventListener('click', (e) => {
1195
+ if (e.target.closest('[data-stop]')) return;
1196
+ const idx = parseInt(row.dataset.idx, 10);
1197
+ if (!isNaN(idx)) play(tracks, idx);
1198
+ });
1199
+ });
1200
+ $$('.ms-track-like', view).forEach(btn => {
1201
+ btn.addEventListener('click', (e) => {
1202
+ e.preventDefault(); e.stopPropagation();
1203
+ const id = btn.dataset.tid;
1204
+ const t = tracks.find(x => x.id === id);
1205
+ if (t) toggleLike(t);
1206
+ });
1207
+ });
1208
+ }
1209
+
1210
+ function songRowHTML(track, idx) {
1211
+ const artistsText = (track.artists || []).map(a => `<a href="#/artist/${encodeURIComponent(a.slug || a.id)}" data-stop>${esc(a.name)}</a>`).join(', ');
1212
+ return `<div class="ms-song-row" data-tid="${esc(track.id)}" data-idx="${idx}">
1213
+ <div class="ms-song-cover-wrap">
1214
+ ${track.cover ? `<img class="ms-song-cover" src="${esc(track.cover)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : '<div class="ms-song-cover"></div>'}
1215
+ <span class="ms-song-play"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></span>
1216
+ </div>
1217
+ <div class="ms-song-text">
1218
+ <div class="ms-song-title">${esc(track.title)}</div>
1219
+ <div class="ms-song-sub">${artistsText || '<a href="#/anime/' + encodeURIComponent(track.animeId) + '" data-stop>' + esc(track.animeName) + '</a>'}</div>
1220
+ </div>
1221
+ <button class="ms-song-like ${state.liked.has(track.id) ? 'is-liked' : ''}" data-tid="${esc(track.id)}" data-stop aria-label="Like">
1222
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
1223
+ </button>
1224
+ </div>`;
1225
+ }
1226
+
1227
+ function bindSongRows(scope, tracks) {
1228
+ $$('.ms-song-row', scope).forEach(row => {
1229
+ row.addEventListener('click', (e) => {
1230
+ if (e.target.closest('[data-stop]')) return;
1231
+ const idx = parseInt(row.dataset.idx, 10);
1232
+ if (!isNaN(idx) && tracks[idx]) play(tracks, idx);
1233
+ });
1234
+ });
1235
+ $$('.ms-song-like', scope).forEach(btn => {
1236
+ btn.addEventListener('click', (e) => {
1237
+ e.preventDefault(); e.stopPropagation();
1238
+ const id = btn.dataset.tid;
1239
+ const t = tracks.find(x => x.id === id);
1240
+ if (t) toggleLike(t);
1241
+ });
1242
+ });
1243
+ }
1244
+
1245
+ async function renderSearch(q) {
1246
+ if (!q) { renderHome(); return; }
1247
+ view.innerHTML = loadingHTML(`Searching "${q}"…`);
1248
+ const filter = (state.searchFilter || 'all');
1249
+ try {
1250
+ const data = await api.search(q);
1251
+ window.__lastSearch = data;
1252
+ const anime = data.anime || [];
1253
+ const themes = data.animethemes || [];
1254
+ const artists = data.artists || [];
1255
+ const songs = data.songs || [];
1256
+
1257
+ const themeTracks = themes
1258
+ .map(th => th.anime ? trackFromTheme(th.anime, th) : null)
1259
+ .filter(Boolean);
1260
+
1261
+ const tabsHTML = `<div class="ms-search-tabs">
1262
+ ${['all','anime','songs','artists'].map(f =>
1263
+ `<button class="ms-tab ${filter===f?'active':''}" data-filter="${f}">${f[0].toUpperCase()+f.slice(1)}</button>`
1264
+ ).join('')}
1265
+ </div>`;
1266
+
1267
+ const noResults = !anime.length && !artists.length && !songs.length && !themeTracks.length;
1268
+ if (noResults) {
1269
+ view.innerHTML = `${tabsHTML}${emptyHTML(`No results for "${q}"`, 'Try a different anime, song, or artist.')}`;
1270
+ bindSearchTabs(q);
1271
+ return;
1272
+ }
1273
+
1274
+ let topResultHTML = '';
1275
+ const topAnime = anime[0];
1276
+ if (topAnime && (filter === 'all' || filter === 'anime')) {
1277
+ const cover = coverUrl(topAnime);
1278
+ topResultHTML = `<div class="ms-top-result">
1279
+ <div>
1280
+ <div class="ms-section-title" style="margin:0 0 14px"><h2>Top result</h2></div>
1281
+ <a class="ms-top-card" href="#/anime/${encodeURIComponent(topAnime.id)}">
1282
+ ${cover ? `<img class="ms-top-cover" src="${esc(cover)}" alt="" referrerpolicy="no-referrer">` : `<div class="ms-top-cover" style="background:linear-gradient(135deg,#4c1d95,#1e1b4b)"></div>`}
1283
+ <div class="ms-top-name">${esc(topAnime.name)}</div>
1284
+ <span class="ms-top-tag">Anime${topAnime.year ? ' · ' + esc(topAnime.year) : ''}</span>
1285
+ <button class="ms-top-play" data-play-anime="${esc(topAnime.id)}" aria-label="Play">
1286
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
1287
+ </button>
1288
+ </a>
1289
+ </div>
1290
+ ${themeTracks.length ? `<div class="ms-top-songs">
1291
+ <h3>Songs</h3>
1292
+ <div class="ms-songlist" id="search-top-songs">
1293
+ ${themeTracks.slice(0, 4).map((tk, i) => songRowHTML(tk, i)).join('')}
1294
+ </div>
1295
+ </div>` : ''}
1296
+ </div>`;
1297
+ }
1298
+
1299
+ const animeBlock = (filter === 'all' || filter === 'anime') && anime.length ? `<div class="ms-search-block">
1300
+ <div class="ms-section-title"><h2>Anime</h2></div>
1301
+ <div class="ms-card-grid">${anime.slice(0, filter === 'anime' ? 24 : 8).map(animeCardHTML).join('')}</div>
1302
+ </div>` : '';
1303
+
1304
+ const artistsBlock = (filter === 'all' || filter === 'artists') && artists.length ? `<div class="ms-search-block">
1305
+ <div class="ms-section-title"><h2>Artists</h2></div>
1306
+ <div class="ms-card-grid">${artists.slice(0, filter === 'artists' ? 24 : 8).map(artistCardHTML).join('')}</div>
1307
+ </div>` : '';
1308
+
1309
+ let songsBlock = '';
1310
+ if ((filter === 'all' || filter === 'songs')) {
1311
+ const songTracksAll = themeTracks.length ? themeTracks : [];
1312
+ if (songTracksAll.length) {
1313
+ const limit = filter === 'songs' ? songTracksAll.length : 8;
1314
+ songsBlock = `<div class="ms-search-block">
1315
+ <div class="ms-section-title"><h2>Songs</h2></div>
1316
+ <div class="ms-songlist" id="search-songs-list">
1317
+ ${songTracksAll.slice(0, limit).map((tk, i) => songRowHTML(tk, i)).join('')}
1318
+ </div>
1319
+ </div>`;
1320
+ } else if (songs.length) {
1321
+ songsBlock = `<div class="ms-search-block">
1322
+ <div class="ms-section-title"><h2>Songs</h2></div>
1323
+ <div class="ms-card-grid">${songs.slice(0, 8).map(songCardHTML).join('')}</div>
1324
+ </div>`;
1325
+ }
1326
+ }
1327
+
1328
+ view.innerHTML = `
1329
+ ${tabsHTML}
1330
+ ${topResultHTML}
1331
+ ${animeBlock}
1332
+ ${songsBlock}
1333
+ ${artistsBlock}
1334
+ `;
1335
+
1336
+ bindSearchTabs(q);
1337
+ bindCardClicks(view);
1338
+
1339
+ const topListEl = $('#search-top-songs');
1340
+ if (topListEl) bindSongRows(topListEl, themeTracks.slice(0, 4));
1341
+ const songsListEl = $('#search-songs-list');
1342
+ if (songsListEl) {
1343
+ const limit = filter === 'songs' ? themeTracks.length : 8;
1344
+ bindSongRows(songsListEl, themeTracks.slice(0, limit));
1345
+ }
1346
+ updateNowPlayingMarkers();
1347
+ } catch (e) {
1348
+ console.error('[music] search failed', e);
1349
+ view.innerHTML = emptyHTML('Search failed', 'Please try again.');
1350
+ }
1351
+ }
1352
+
1353
+ function bindSearchTabs(q) {
1354
+ $$('.ms-tab', view).forEach(tab => {
1355
+ tab.addEventListener('click', () => {
1356
+ state.searchFilter = tab.dataset.filter;
1357
+ renderSearch(q);
1358
+ });
1359
+ });
1360
+ }
1361
+
1362
+ function closeSidebarIfMobile() {
1363
+ if (window.innerWidth <= 1024) {
1364
+ $('#music-sidebar').classList.remove('open');
1365
+ $('#ms-sidebar-backdrop').classList.remove('active');
1366
+ }
1367
+ }
1368
+
1369
+ function openYearSidebarLinks() {
1370
+ const yearGrid = $('#ms-year-grid');
1371
+ const cy = new Date().getFullYear();
1372
+ const years = [];
1373
+ for (let y = cy; y >= cy - 9; y--) years.push(y);
1374
+ yearGrid.innerHTML = years.map(y => `<a data-route="#/year/${y}" href="#/year/${y}">${y}</a>`).join('');
1375
+ }
1376
+
1377
+ function init() {
1378
+ audio.volume = state.volume;
1379
+ setVolUI();
1380
+
1381
+ openYearSidebarLinks();
1382
+
1383
+ let searchTimer;
1384
+ const searchInput = $('#ms-search-input');
1385
+ searchInput.addEventListener('input', () => {
1386
+ const q = searchInput.value.trim();
1387
+ $('#ms-search-clear').hidden = !q;
1388
+ clearTimeout(searchTimer);
1389
+ if (!q) {
1390
+ if (location.hash.startsWith('#/search')) navigate('#/');
1391
+ return;
1392
+ }
1393
+ searchTimer = setTimeout(() => {
1394
+ navigate('#/search?q=' + encodeURIComponent(q));
1395
+ }, 320);
1396
+ });
1397
+ searchInput.addEventListener('keydown', (e) => {
1398
+ if (e.key === 'Escape') { searchInput.value = ''; navigate('#/'); }
1399
+ });
1400
+ $('#ms-search-clear').addEventListener('click', () => {
1401
+ searchInput.value = '';
1402
+ $('#ms-search-clear').hidden = true;
1403
+ navigate('#/');
1404
+ searchInput.focus();
1405
+ });
1406
+
1407
+ $('#pb-play').addEventListener('click', togglePlay);
1408
+ $('#pb-play-mobile').addEventListener('click', togglePlay);
1409
+ $('#pb-prev').addEventListener('click', prev);
1410
+ $('#pb-next').addEventListener('click', () => next(false));
1411
+ $('#pb-shuffle').addEventListener('click', () => {
1412
+ state.shuffle = !state.shuffle;
1413
+ $('#pb-shuffle').classList.toggle('is-on', state.shuffle);
1414
+ });
1415
+ $('#pb-loop').addEventListener('click', () => {
1416
+ state.loop = !state.loop;
1417
+ $('#pb-loop').classList.toggle('is-on', state.loop);
1418
+ audio.loop = false;
1419
+ });
1420
+ $('#pb-mute').addEventListener('click', () => {
1421
+ state.muted = !state.muted;
1422
+ audio.muted = state.muted;
1423
+ video.muted = state.muted;
1424
+ setVolUI();
1425
+ });
1426
+ $('#pb-like').addEventListener('click', () => {
1427
+ const t = state.queue[state.index];
1428
+ if (t) toggleLike(t);
1429
+ });
1430
+
1431
+ bindBar($('#pb-seek'), (pct) => {
1432
+ const m = activeMedia();
1433
+ if (m.duration) m.currentTime = pct * m.duration;
1434
+ setSeek(pct * (m.duration || 0));
1435
+ });
1436
+ bindBar($('#pb-vol'), (pct) => {
1437
+ state.volume = pct;
1438
+ state.muted = false;
1439
+ audio.muted = false;
1440
+ video.muted = false;
1441
+ audio.volume = pct;
1442
+ video.volume = pct;
1443
+ persistVol();
1444
+ setVolUI();
1445
+ });
1446
+
1447
+ [audio, video].forEach((m) => {
1448
+ m.addEventListener('play', () => {
1449
+ if (m === activeMedia()) { state.isPlaying = true; setPlayUI(true); }
1450
+ if (m === video) showStageLoading(false);
1451
+ });
1452
+ m.addEventListener('pause', () => {
1453
+ if (m === activeMedia()) { state.isPlaying = false; setPlayUI(false); }
1454
+ if (m === video) showStageLoading(false);
1455
+ });
1456
+ m.addEventListener('ended', () => {
1457
+ if (m !== activeMedia()) return;
1458
+ if (state.loop) { m.currentTime = 0; m.play().catch(() => { }); }
1459
+ else next(true);
1460
+ });
1461
+ m.addEventListener('timeupdate', () => {
1462
+ if (m === activeMedia() && !state.scrubbing) setSeek();
1463
+ if (m === activeMedia() && _syncedLines.length) updateLyricsHighlight(m.currentTime);
1464
+ });
1465
+
1466
+ m.addEventListener('play', () => {
1467
+ if (m !== activeMedia()) return;
1468
+ if (_lyricRaf) cancelAnimationFrame(_lyricRaf);
1469
+ const tick = () => {
1470
+ if (m !== activeMedia() || m.paused || m.ended) {
1471
+ _lyricRaf = null;
1472
+ return;
1473
+ }
1474
+ if (_syncedLines.length && document.body.classList.contains('np-lyrics-open')) {
1475
+ updateLyricsHighlight(m.currentTime);
1476
+ }
1477
+ _lyricRaf = requestAnimationFrame(tick);
1478
+ };
1479
+ _lyricRaf = requestAnimationFrame(tick);
1480
+ });
1481
+ m.addEventListener('loadedmetadata', () => {
1482
+ if (m !== activeMedia()) return;
1483
+ setSeek();
1484
+
1485
+ if (_rawSyncedLines.length) {
1486
+ recomputeSyncedLines();
1487
+ renderSyncedLyrics(_syncedLines);
1488
+ }
1489
+ });
1490
+ m.addEventListener('durationchange', () => {
1491
+ if (m !== activeMedia() || !_rawSyncedLines.length) return;
1492
+ recomputeSyncedLines();
1493
+ renderSyncedLyrics(_syncedLines);
1494
+ });
1495
+
1496
+ m.addEventListener('seeked', () => {
1497
+ if (m !== activeMedia() || !_syncedLines.length) return;
1498
+ _activeLyricIdx = -2;
1499
+ updateLyricsHighlight(m.currentTime || 0);
1500
+ });
1501
+ m.addEventListener('error', () => {
1502
+ if (m !== activeMedia()) return;
1503
+ if (m === video) showStageLoading(false);
1504
+ const t = state.queue[state.index];
1505
+ if (t) console.warn('[music] failed to play', t.title);
1506
+ setTimeout(() => next(true), 800);
1507
+ });
1508
+ });
1509
+
1510
+ video.addEventListener('waiting', () => { if (mediaMode === 'video' && !video.paused) showStageLoading(true); });
1511
+ video.addEventListener('loadstart', () => { if (mediaMode === 'video') showStageLoading(true); });
1512
+ video.addEventListener('seeking', () => { if (mediaMode === 'video') showStageLoading(true); });
1513
+ video.addEventListener('stalled', () => { if (mediaMode === 'video' && !video.paused) showStageLoading(true); });
1514
+ video.addEventListener('canplay', () => showStageLoading(false));
1515
+ video.addEventListener('canplaythrough', () => showStageLoading(false));
1516
+ video.addEventListener('playing', () => showStageLoading(false));
1517
+ video.addEventListener('seeked', () => showStageLoading(false));
1518
+ video.addEventListener('progress', () => { if (video.readyState >= 3) showStageLoading(false); });
1519
+
1520
+ $('#pb-expand').addEventListener('click', openNP);
1521
+ $('#np-close').addEventListener('click', closeNP);
1522
+ $('#np-shuffle').addEventListener('click', () => {
1523
+ state.shuffle = !state.shuffle;
1524
+ $('#pb-shuffle').classList.toggle('is-on', state.shuffle);
1525
+ $('#np-shuffle').classList.toggle('is-on', state.shuffle);
1526
+ });
1527
+ $('#np-loop').addEventListener('click', () => {
1528
+ state.loop = !state.loop;
1529
+ $('#pb-loop').classList.toggle('is-on', state.loop);
1530
+ $('#np-loop').classList.toggle('is-on', state.loop);
1531
+ });
1532
+ $('#np-prev').addEventListener('click', prev);
1533
+ $('#np-next').addEventListener('click', () => next(false));
1534
+ $('#np-play').addEventListener('click', togglePlay);
1535
+ $('#np-like').addEventListener('click', () => {
1536
+ const t = state.queue[state.index];
1537
+ if (!t) return;
1538
+ toggleLike(t);
1539
+ const btn = $('#np-like');
1540
+ if (btn) {
1541
+ btn.classList.remove('just-liked');
1542
+
1543
+ void btn.offsetWidth;
1544
+ btn.classList.add('just-liked');
1545
+ }
1546
+ });
1547
+ bindBar($('#np-seek'), (pct) => {
1548
+ const m = activeMedia();
1549
+ if (m.duration) m.currentTime = pct * m.duration;
1550
+ setSeek(pct * (m.duration || 0));
1551
+ });
1552
+ function setPanel(which) {
1553
+
1554
+ $('#np-lyrics').hidden = which !== 'lyrics';
1555
+ $('#np-queue').hidden = which !== 'queue';
1556
+ $('#np-lyrics-btn').classList.toggle('is-on', which === 'lyrics');
1557
+ $('#np-queue-btn').classList.toggle('is-on', which === 'queue');
1558
+
1559
+ document.body.classList.toggle('np-lyrics-open', which === 'lyrics');
1560
+ if (which === 'queue') renderQueue();
1561
+
1562
+ if (which === 'lyrics' && _syncedLines.length) {
1563
+ _activeLyricIdx = -2;
1564
+ requestAnimationFrame(() => {
1565
+ updateLyricsHighlight(activeMedia().currentTime || 0);
1566
+ });
1567
+ }
1568
+ }
1569
+ $('#np-video-btn').addEventListener('click', () => {
1570
+ const t = state.queue[state.index];
1571
+ if (!t) return;
1572
+ if (!t.videoLink) { toast('No video available for this track'); return; }
1573
+ setPanel(null);
1574
+ setVideoMode(mediaMode !== 'video');
1575
+ });
1576
+ $('#np-lyrics-btn').addEventListener('click', () => {
1577
+ const showing = $('#np-lyrics').hidden;
1578
+ setPanel(showing ? 'lyrics' : null);
1579
+ });
1580
+ $('#np-queue-btn').addEventListener('click', () => {
1581
+ const showing = $('#np-queue').hidden;
1582
+ setPanel(showing ? 'queue' : null);
1583
+ });
1584
+
1585
+ $('#np-lyrics-close')?.addEventListener('click', () => setPanel(null));
1586
+ $('#np-queue-close') ?.addEventListener('click', () => setPanel(null));
1587
+
1588
+ $('#np-bg').addEventListener('click', closeNP);
1589
+ document.addEventListener('keydown', (e) => {
1590
+ if (e.key === 'Escape' && !npModal.hidden) closeNP();
1591
+ });
1592
+
1593
+ $('#pb-title').addEventListener('click', () => {
1594
+ if (playerBar.dataset.empty === '1') return;
1595
+ openNP();
1596
+ });
1597
+
1598
+ $('#pb-dismiss')?.addEventListener('click', () => {
1599
+ try { audio.pause(); video.pause(); } catch (_) {}
1600
+ document.body.classList.add('pb-hidden');
1601
+ });
1602
+
1603
+ $('#ms-back').addEventListener('click', () => {
1604
+ if (state.navStack.length > 1) {
1605
+ const cur = state.navStack.pop();
1606
+ state.navFwd.push(cur);
1607
+ const prev = state.navStack[state.navStack.length - 1];
1608
+ state.navStack.pop();
1609
+ location.hash = prev;
1610
+ }
1611
+ });
1612
+ $('#ms-fwd').addEventListener('click', () => {
1613
+ const fwd = state.navFwd.pop();
1614
+ if (fwd) location.hash = fwd;
1615
+ });
1616
+
1617
+ $('#ms-sidebar-toggle').addEventListener('click', () => {
1618
+ const isDesktop = window.innerWidth > 1024;
1619
+ if (isDesktop) {
1620
+ $('#music-app').classList.toggle('sb-collapsed');
1621
+ } else {
1622
+ $('#music-sidebar').classList.toggle('open');
1623
+ $('#ms-sidebar-backdrop').classList.toggle('active');
1624
+ }
1625
+ });
1626
+ $('#ms-sidebar-backdrop').addEventListener('click', closeSidebarIfMobile);
1627
+
1628
+ const sidebarEl = $('#music-sidebar');
1629
+ if (sidebarEl) {
1630
+ let downTarget = null;
1631
+ let downX = 0, downY = 0;
1632
+ let pointerHandledAt = 0;
1633
+ const TAP_SLOP = 10;
1634
+
1635
+ sidebarEl.addEventListener('pointerdown', (e) => {
1636
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
1637
+ downTarget = e.target.closest('a, .ms-link, [data-route]');
1638
+ downX = e.clientX; downY = e.clientY;
1639
+ });
1640
+
1641
+ const fireTap = (e) => {
1642
+ const link = e.target.closest('a, .ms-link, [data-route]');
1643
+ if (!link || !sidebarEl.contains(link)) return false;
1644
+
1645
+ if (downTarget && downTarget !== link) return false;
1646
+ if (Math.abs(e.clientX - downX) > TAP_SLOP) return false;
1647
+ if (Math.abs(e.clientY - downY) > TAP_SLOP) return false;
1648
+ const route = link.dataset.route || link.getAttribute('href');
1649
+ if (route && route.startsWith('#')) {
1650
+ navigate(route);
1651
+ } else {
1652
+ closeSidebarIfMobile();
1653
+ }
1654
+ return true;
1655
+ };
1656
+
1657
+ sidebarEl.addEventListener('pointerup', (e) => {
1658
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
1659
+ if (fireTap(e)) pointerHandledAt = performance.now();
1660
+ downTarget = null;
1661
+ });
1662
+
1663
+ sidebarEl.addEventListener('click', (e) => {
1664
+ const link = e.target.closest('a, .ms-link, [data-route]');
1665
+ if (!link || !sidebarEl.contains(link)) return;
1666
+ e.preventDefault();
1667
+ if (performance.now() - pointerHandledAt < 600) return;
1668
+ const route = link.dataset.route || link.getAttribute('href');
1669
+ if (route && route.startsWith('#')) navigate(route);
1670
+ else closeSidebarIfMobile();
1671
+ });
1672
+
1673
+ sidebarEl.addEventListener('pointercancel', () => { downTarget = null; });
1674
+ }
1675
+
1676
+ document.body.classList.remove('header-hidden');
1677
+ const _hdr = document.getElementById('header');
1678
+ if (_hdr) _hdr.classList.remove('is-hidden');
1679
+ const musicMain = document.querySelector('.music-main');
1680
+ if (musicMain && _hdr) {
1681
+ let lastY = 0;
1682
+ let ticking = false;
1683
+ const SCROLL_THRESHOLD = 8;
1684
+ const apply = () => {
1685
+ ticking = false;
1686
+ if (window.innerWidth > 1024) {
1687
+
1688
+ _hdr.classList.remove('is-hidden');
1689
+ document.body.classList.remove('header-hidden');
1690
+ return;
1691
+ }
1692
+ const y = musicMain.scrollTop;
1693
+ const dy = y - lastY;
1694
+ if (Math.abs(dy) < SCROLL_THRESHOLD) return;
1695
+ if (dy > 0 && y > 40) {
1696
+ _hdr.classList.add('is-hidden');
1697
+ document.body.classList.add('header-hidden');
1698
+ } else if (dy < 0) {
1699
+ _hdr.classList.remove('is-hidden');
1700
+ document.body.classList.remove('header-hidden');
1701
+ }
1702
+ lastY = y;
1703
+ };
1704
+ musicMain.addEventListener('scroll', () => {
1705
+ if (ticking) return;
1706
+ ticking = true;
1707
+ requestAnimationFrame(apply);
1708
+ }, { passive: true });
1709
+ window.addEventListener('resize', apply);
1710
+ }
1711
+
1712
+ window.addEventListener('hashchange', handleRoute);
1713
+
1714
+ document.addEventListener('keydown', (e) => {
1715
+ if (e.target.matches('input, textarea')) return;
1716
+ if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
1717
+ else if (e.key === 'ArrowRight' && e.shiftKey) next(false);
1718
+ else if (e.key === 'ArrowLeft' && e.shiftKey) prev();
1719
+ });
1720
+
1721
+ handleRoute();
1722
+ }
1723
+
1724
+ if (document.readyState === 'loading') {
1725
+ document.addEventListener('DOMContentLoaded', init);
1726
+ } else {
1727
+ init();
1728
+ }
1729
+ })();
frontend/index.html ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="anicove - Stream thousands of anime series free in HD. Subbed and dubbed. No ads, no limits." />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=Bebas+Neue&family=Rubik+Dirt&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <title>anicove — Anime, Manga, Movies &amp; Music in One Place</title>
14
+ </head>
15
+ <body>
16
+ <div id="root"></div>
17
+ <script type="module" src="/src/main.jsx"></script>
18
+ </body>
19
+ </html>
frontend/package-lock.json ADDED
@@ -0,0 +1,1747 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "anidoom-frontend",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "anidoom-frontend",
9
+ "version": "0.1.0",
10
+ "dependencies": {
11
+ "hls.js": "^1.5.13",
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1",
14
+ "react-router-dom": "^6.26.2"
15
+ },
16
+ "devDependencies": {
17
+ "@vitejs/plugin-react": "^4.3.1",
18
+ "vite": "^5.4.2"
19
+ }
20
+ },
21
+ "node_modules/@babel/code-frame": {
22
+ "version": "7.29.7",
23
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
24
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
25
+ "dev": true,
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "@babel/helper-validator-identifier": "^7.29.7",
29
+ "js-tokens": "^4.0.0",
30
+ "picocolors": "^1.1.1"
31
+ },
32
+ "engines": {
33
+ "node": ">=6.9.0"
34
+ }
35
+ },
36
+ "node_modules/@babel/compat-data": {
37
+ "version": "7.29.7",
38
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
39
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
40
+ "dev": true,
41
+ "license": "MIT",
42
+ "engines": {
43
+ "node": ">=6.9.0"
44
+ }
45
+ },
46
+ "node_modules/@babel/core": {
47
+ "version": "7.29.7",
48
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
49
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
50
+ "dev": true,
51
+ "license": "MIT",
52
+ "dependencies": {
53
+ "@babel/code-frame": "^7.29.7",
54
+ "@babel/generator": "^7.29.7",
55
+ "@babel/helper-compilation-targets": "^7.29.7",
56
+ "@babel/helper-module-transforms": "^7.29.7",
57
+ "@babel/helpers": "^7.29.7",
58
+ "@babel/parser": "^7.29.7",
59
+ "@babel/template": "^7.29.7",
60
+ "@babel/traverse": "^7.29.7",
61
+ "@babel/types": "^7.29.7",
62
+ "@jridgewell/remapping": "^2.3.5",
63
+ "convert-source-map": "^2.0.0",
64
+ "debug": "^4.1.0",
65
+ "gensync": "^1.0.0-beta.2",
66
+ "json5": "^2.2.3",
67
+ "semver": "^6.3.1"
68
+ },
69
+ "engines": {
70
+ "node": ">=6.9.0"
71
+ },
72
+ "funding": {
73
+ "type": "opencollective",
74
+ "url": "https://opencollective.com/babel"
75
+ }
76
+ },
77
+ "node_modules/@babel/generator": {
78
+ "version": "7.29.8",
79
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
80
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
81
+ "dev": true,
82
+ "license": "MIT",
83
+ "dependencies": {
84
+ "@babel/parser": "^7.29.8",
85
+ "@babel/types": "^7.29.8",
86
+ "@jridgewell/gen-mapping": "^0.3.12",
87
+ "@jridgewell/trace-mapping": "^0.3.28",
88
+ "jsesc": "^3.0.2"
89
+ },
90
+ "engines": {
91
+ "node": ">=6.9.0"
92
+ }
93
+ },
94
+ "node_modules/@babel/helper-compilation-targets": {
95
+ "version": "7.29.7",
96
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
97
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
98
+ "dev": true,
99
+ "license": "MIT",
100
+ "dependencies": {
101
+ "@babel/compat-data": "^7.29.7",
102
+ "@babel/helper-validator-option": "^7.29.7",
103
+ "browserslist": "^4.24.0",
104
+ "lru-cache": "^5.1.1",
105
+ "semver": "^6.3.1"
106
+ },
107
+ "engines": {
108
+ "node": ">=6.9.0"
109
+ }
110
+ },
111
+ "node_modules/@babel/helper-globals": {
112
+ "version": "7.29.7",
113
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
114
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
115
+ "dev": true,
116
+ "license": "MIT",
117
+ "engines": {
118
+ "node": ">=6.9.0"
119
+ }
120
+ },
121
+ "node_modules/@babel/helper-module-imports": {
122
+ "version": "7.29.7",
123
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
124
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
125
+ "dev": true,
126
+ "license": "MIT",
127
+ "dependencies": {
128
+ "@babel/traverse": "^7.29.7",
129
+ "@babel/types": "^7.29.7"
130
+ },
131
+ "engines": {
132
+ "node": ">=6.9.0"
133
+ }
134
+ },
135
+ "node_modules/@babel/helper-module-transforms": {
136
+ "version": "7.29.7",
137
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
138
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
139
+ "dev": true,
140
+ "license": "MIT",
141
+ "dependencies": {
142
+ "@babel/helper-module-imports": "^7.29.7",
143
+ "@babel/helper-validator-identifier": "^7.29.7",
144
+ "@babel/traverse": "^7.29.7"
145
+ },
146
+ "engines": {
147
+ "node": ">=6.9.0"
148
+ },
149
+ "peerDependencies": {
150
+ "@babel/core": "^7.0.0"
151
+ }
152
+ },
153
+ "node_modules/@babel/helper-plugin-utils": {
154
+ "version": "7.29.7",
155
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
156
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
157
+ "dev": true,
158
+ "license": "MIT",
159
+ "engines": {
160
+ "node": ">=6.9.0"
161
+ }
162
+ },
163
+ "node_modules/@babel/helper-string-parser": {
164
+ "version": "7.29.7",
165
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
166
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
167
+ "dev": true,
168
+ "license": "MIT",
169
+ "engines": {
170
+ "node": ">=6.9.0"
171
+ }
172
+ },
173
+ "node_modules/@babel/helper-validator-identifier": {
174
+ "version": "7.29.7",
175
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
176
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
177
+ "dev": true,
178
+ "license": "MIT",
179
+ "engines": {
180
+ "node": ">=6.9.0"
181
+ }
182
+ },
183
+ "node_modules/@babel/helper-validator-option": {
184
+ "version": "7.29.7",
185
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
186
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
187
+ "dev": true,
188
+ "license": "MIT",
189
+ "engines": {
190
+ "node": ">=6.9.0"
191
+ }
192
+ },
193
+ "node_modules/@babel/helpers": {
194
+ "version": "7.29.7",
195
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
196
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
197
+ "dev": true,
198
+ "license": "MIT",
199
+ "dependencies": {
200
+ "@babel/template": "^7.29.7",
201
+ "@babel/types": "^7.29.7"
202
+ },
203
+ "engines": {
204
+ "node": ">=6.9.0"
205
+ }
206
+ },
207
+ "node_modules/@babel/parser": {
208
+ "version": "7.29.8",
209
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
210
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
211
+ "dev": true,
212
+ "license": "MIT",
213
+ "dependencies": {
214
+ "@babel/types": "^7.29.8"
215
+ },
216
+ "bin": {
217
+ "parser": "bin/babel-parser.js"
218
+ },
219
+ "engines": {
220
+ "node": ">=6.0.0"
221
+ }
222
+ },
223
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
224
+ "version": "7.29.7",
225
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
226
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
227
+ "dev": true,
228
+ "license": "MIT",
229
+ "dependencies": {
230
+ "@babel/helper-plugin-utils": "^7.29.7"
231
+ },
232
+ "engines": {
233
+ "node": ">=6.9.0"
234
+ },
235
+ "peerDependencies": {
236
+ "@babel/core": "^7.0.0-0"
237
+ }
238
+ },
239
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
240
+ "version": "7.29.7",
241
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
242
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
243
+ "dev": true,
244
+ "license": "MIT",
245
+ "dependencies": {
246
+ "@babel/helper-plugin-utils": "^7.29.7"
247
+ },
248
+ "engines": {
249
+ "node": ">=6.9.0"
250
+ },
251
+ "peerDependencies": {
252
+ "@babel/core": "^7.0.0-0"
253
+ }
254
+ },
255
+ "node_modules/@babel/template": {
256
+ "version": "7.29.7",
257
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
258
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
259
+ "dev": true,
260
+ "license": "MIT",
261
+ "dependencies": {
262
+ "@babel/code-frame": "^7.29.7",
263
+ "@babel/parser": "^7.29.7",
264
+ "@babel/types": "^7.29.7"
265
+ },
266
+ "engines": {
267
+ "node": ">=6.9.0"
268
+ }
269
+ },
270
+ "node_modules/@babel/traverse": {
271
+ "version": "7.29.8",
272
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
273
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
274
+ "dev": true,
275
+ "license": "MIT",
276
+ "dependencies": {
277
+ "@babel/code-frame": "^7.29.7",
278
+ "@babel/generator": "^7.29.8",
279
+ "@babel/helper-globals": "^7.29.7",
280
+ "@babel/parser": "^7.29.8",
281
+ "@babel/template": "^7.29.7",
282
+ "@babel/types": "^7.29.8",
283
+ "debug": "^4.3.1"
284
+ },
285
+ "engines": {
286
+ "node": ">=6.9.0"
287
+ }
288
+ },
289
+ "node_modules/@babel/types": {
290
+ "version": "7.29.8",
291
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
292
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
293
+ "dev": true,
294
+ "license": "MIT",
295
+ "dependencies": {
296
+ "@babel/helper-string-parser": "^7.29.7",
297
+ "@babel/helper-validator-identifier": "^7.29.7"
298
+ },
299
+ "engines": {
300
+ "node": ">=6.9.0"
301
+ }
302
+ },
303
+ "node_modules/@esbuild/aix-ppc64": {
304
+ "version": "0.21.5",
305
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
306
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
307
+ "cpu": [
308
+ "ppc64"
309
+ ],
310
+ "dev": true,
311
+ "license": "MIT",
312
+ "optional": true,
313
+ "os": [
314
+ "aix"
315
+ ],
316
+ "engines": {
317
+ "node": ">=12"
318
+ }
319
+ },
320
+ "node_modules/@esbuild/android-arm": {
321
+ "version": "0.21.5",
322
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
323
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
324
+ "cpu": [
325
+ "arm"
326
+ ],
327
+ "dev": true,
328
+ "license": "MIT",
329
+ "optional": true,
330
+ "os": [
331
+ "android"
332
+ ],
333
+ "engines": {
334
+ "node": ">=12"
335
+ }
336
+ },
337
+ "node_modules/@esbuild/android-arm64": {
338
+ "version": "0.21.5",
339
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
340
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
341
+ "cpu": [
342
+ "arm64"
343
+ ],
344
+ "dev": true,
345
+ "license": "MIT",
346
+ "optional": true,
347
+ "os": [
348
+ "android"
349
+ ],
350
+ "engines": {
351
+ "node": ">=12"
352
+ }
353
+ },
354
+ "node_modules/@esbuild/android-x64": {
355
+ "version": "0.21.5",
356
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
357
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
358
+ "cpu": [
359
+ "x64"
360
+ ],
361
+ "dev": true,
362
+ "license": "MIT",
363
+ "optional": true,
364
+ "os": [
365
+ "android"
366
+ ],
367
+ "engines": {
368
+ "node": ">=12"
369
+ }
370
+ },
371
+ "node_modules/@esbuild/darwin-arm64": {
372
+ "version": "0.21.5",
373
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
374
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
375
+ "cpu": [
376
+ "arm64"
377
+ ],
378
+ "dev": true,
379
+ "license": "MIT",
380
+ "optional": true,
381
+ "os": [
382
+ "darwin"
383
+ ],
384
+ "engines": {
385
+ "node": ">=12"
386
+ }
387
+ },
388
+ "node_modules/@esbuild/darwin-x64": {
389
+ "version": "0.21.5",
390
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
391
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
392
+ "cpu": [
393
+ "x64"
394
+ ],
395
+ "dev": true,
396
+ "license": "MIT",
397
+ "optional": true,
398
+ "os": [
399
+ "darwin"
400
+ ],
401
+ "engines": {
402
+ "node": ">=12"
403
+ }
404
+ },
405
+ "node_modules/@esbuild/freebsd-arm64": {
406
+ "version": "0.21.5",
407
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
408
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
409
+ "cpu": [
410
+ "arm64"
411
+ ],
412
+ "dev": true,
413
+ "license": "MIT",
414
+ "optional": true,
415
+ "os": [
416
+ "freebsd"
417
+ ],
418
+ "engines": {
419
+ "node": ">=12"
420
+ }
421
+ },
422
+ "node_modules/@esbuild/freebsd-x64": {
423
+ "version": "0.21.5",
424
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
425
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
426
+ "cpu": [
427
+ "x64"
428
+ ],
429
+ "dev": true,
430
+ "license": "MIT",
431
+ "optional": true,
432
+ "os": [
433
+ "freebsd"
434
+ ],
435
+ "engines": {
436
+ "node": ">=12"
437
+ }
438
+ },
439
+ "node_modules/@esbuild/linux-arm": {
440
+ "version": "0.21.5",
441
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
442
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
443
+ "cpu": [
444
+ "arm"
445
+ ],
446
+ "dev": true,
447
+ "license": "MIT",
448
+ "optional": true,
449
+ "os": [
450
+ "linux"
451
+ ],
452
+ "engines": {
453
+ "node": ">=12"
454
+ }
455
+ },
456
+ "node_modules/@esbuild/linux-arm64": {
457
+ "version": "0.21.5",
458
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
459
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
460
+ "cpu": [
461
+ "arm64"
462
+ ],
463
+ "dev": true,
464
+ "license": "MIT",
465
+ "optional": true,
466
+ "os": [
467
+ "linux"
468
+ ],
469
+ "engines": {
470
+ "node": ">=12"
471
+ }
472
+ },
473
+ "node_modules/@esbuild/linux-ia32": {
474
+ "version": "0.21.5",
475
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
476
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
477
+ "cpu": [
478
+ "ia32"
479
+ ],
480
+ "dev": true,
481
+ "license": "MIT",
482
+ "optional": true,
483
+ "os": [
484
+ "linux"
485
+ ],
486
+ "engines": {
487
+ "node": ">=12"
488
+ }
489
+ },
490
+ "node_modules/@esbuild/linux-loong64": {
491
+ "version": "0.21.5",
492
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
493
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
494
+ "cpu": [
495
+ "loong64"
496
+ ],
497
+ "dev": true,
498
+ "license": "MIT",
499
+ "optional": true,
500
+ "os": [
501
+ "linux"
502
+ ],
503
+ "engines": {
504
+ "node": ">=12"
505
+ }
506
+ },
507
+ "node_modules/@esbuild/linux-mips64el": {
508
+ "version": "0.21.5",
509
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
510
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
511
+ "cpu": [
512
+ "mips64el"
513
+ ],
514
+ "dev": true,
515
+ "license": "MIT",
516
+ "optional": true,
517
+ "os": [
518
+ "linux"
519
+ ],
520
+ "engines": {
521
+ "node": ">=12"
522
+ }
523
+ },
524
+ "node_modules/@esbuild/linux-ppc64": {
525
+ "version": "0.21.5",
526
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
527
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
528
+ "cpu": [
529
+ "ppc64"
530
+ ],
531
+ "dev": true,
532
+ "license": "MIT",
533
+ "optional": true,
534
+ "os": [
535
+ "linux"
536
+ ],
537
+ "engines": {
538
+ "node": ">=12"
539
+ }
540
+ },
541
+ "node_modules/@esbuild/linux-riscv64": {
542
+ "version": "0.21.5",
543
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
544
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
545
+ "cpu": [
546
+ "riscv64"
547
+ ],
548
+ "dev": true,
549
+ "license": "MIT",
550
+ "optional": true,
551
+ "os": [
552
+ "linux"
553
+ ],
554
+ "engines": {
555
+ "node": ">=12"
556
+ }
557
+ },
558
+ "node_modules/@esbuild/linux-s390x": {
559
+ "version": "0.21.5",
560
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
561
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
562
+ "cpu": [
563
+ "s390x"
564
+ ],
565
+ "dev": true,
566
+ "license": "MIT",
567
+ "optional": true,
568
+ "os": [
569
+ "linux"
570
+ ],
571
+ "engines": {
572
+ "node": ">=12"
573
+ }
574
+ },
575
+ "node_modules/@esbuild/linux-x64": {
576
+ "version": "0.21.5",
577
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
578
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
579
+ "cpu": [
580
+ "x64"
581
+ ],
582
+ "dev": true,
583
+ "license": "MIT",
584
+ "optional": true,
585
+ "os": [
586
+ "linux"
587
+ ],
588
+ "engines": {
589
+ "node": ">=12"
590
+ }
591
+ },
592
+ "node_modules/@esbuild/netbsd-x64": {
593
+ "version": "0.21.5",
594
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
595
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
596
+ "cpu": [
597
+ "x64"
598
+ ],
599
+ "dev": true,
600
+ "license": "MIT",
601
+ "optional": true,
602
+ "os": [
603
+ "netbsd"
604
+ ],
605
+ "engines": {
606
+ "node": ">=12"
607
+ }
608
+ },
609
+ "node_modules/@esbuild/openbsd-x64": {
610
+ "version": "0.21.5",
611
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
612
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
613
+ "cpu": [
614
+ "x64"
615
+ ],
616
+ "dev": true,
617
+ "license": "MIT",
618
+ "optional": true,
619
+ "os": [
620
+ "openbsd"
621
+ ],
622
+ "engines": {
623
+ "node": ">=12"
624
+ }
625
+ },
626
+ "node_modules/@esbuild/sunos-x64": {
627
+ "version": "0.21.5",
628
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
629
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
630
+ "cpu": [
631
+ "x64"
632
+ ],
633
+ "dev": true,
634
+ "license": "MIT",
635
+ "optional": true,
636
+ "os": [
637
+ "sunos"
638
+ ],
639
+ "engines": {
640
+ "node": ">=12"
641
+ }
642
+ },
643
+ "node_modules/@esbuild/win32-arm64": {
644
+ "version": "0.21.5",
645
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
646
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
647
+ "cpu": [
648
+ "arm64"
649
+ ],
650
+ "dev": true,
651
+ "license": "MIT",
652
+ "optional": true,
653
+ "os": [
654
+ "win32"
655
+ ],
656
+ "engines": {
657
+ "node": ">=12"
658
+ }
659
+ },
660
+ "node_modules/@esbuild/win32-ia32": {
661
+ "version": "0.21.5",
662
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
663
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
664
+ "cpu": [
665
+ "ia32"
666
+ ],
667
+ "dev": true,
668
+ "license": "MIT",
669
+ "optional": true,
670
+ "os": [
671
+ "win32"
672
+ ],
673
+ "engines": {
674
+ "node": ">=12"
675
+ }
676
+ },
677
+ "node_modules/@esbuild/win32-x64": {
678
+ "version": "0.21.5",
679
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
680
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
681
+ "cpu": [
682
+ "x64"
683
+ ],
684
+ "dev": true,
685
+ "license": "MIT",
686
+ "optional": true,
687
+ "os": [
688
+ "win32"
689
+ ],
690
+ "engines": {
691
+ "node": ">=12"
692
+ }
693
+ },
694
+ "node_modules/@jridgewell/gen-mapping": {
695
+ "version": "0.3.13",
696
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
697
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
698
+ "dev": true,
699
+ "license": "MIT",
700
+ "dependencies": {
701
+ "@jridgewell/sourcemap-codec": "^1.5.0",
702
+ "@jridgewell/trace-mapping": "^0.3.24"
703
+ }
704
+ },
705
+ "node_modules/@jridgewell/remapping": {
706
+ "version": "2.3.5",
707
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
708
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
709
+ "dev": true,
710
+ "license": "MIT",
711
+ "dependencies": {
712
+ "@jridgewell/gen-mapping": "^0.3.5",
713
+ "@jridgewell/trace-mapping": "^0.3.24"
714
+ }
715
+ },
716
+ "node_modules/@jridgewell/resolve-uri": {
717
+ "version": "3.1.2",
718
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
719
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
720
+ "dev": true,
721
+ "license": "MIT",
722
+ "engines": {
723
+ "node": ">=6.0.0"
724
+ }
725
+ },
726
+ "node_modules/@jridgewell/sourcemap-codec": {
727
+ "version": "1.5.5",
728
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
729
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
730
+ "dev": true,
731
+ "license": "MIT"
732
+ },
733
+ "node_modules/@jridgewell/trace-mapping": {
734
+ "version": "0.3.31",
735
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
736
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
737
+ "dev": true,
738
+ "license": "MIT",
739
+ "dependencies": {
740
+ "@jridgewell/resolve-uri": "^3.1.0",
741
+ "@jridgewell/sourcemap-codec": "^1.4.14"
742
+ }
743
+ },
744
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
745
+ "version": "1.5.1",
746
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
747
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
748
+ "cpu": [
749
+ "x64"
750
+ ],
751
+ "dev": true,
752
+ "license": "MIT",
753
+ "optional": true,
754
+ "os": [
755
+ "linux"
756
+ ],
757
+ "engines": {
758
+ "node": "^22.20 || ^24.12 || >=25"
759
+ }
760
+ },
761
+ "node_modules/@remix-run/router": {
762
+ "version": "1.23.3",
763
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
764
+ "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
765
+ "license": "MIT",
766
+ "engines": {
767
+ "node": ">=14.0.0"
768
+ }
769
+ },
770
+ "node_modules/@rolldown/pluginutils": {
771
+ "version": "1.0.0-beta.27",
772
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
773
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
774
+ "dev": true,
775
+ "license": "MIT"
776
+ },
777
+ "node_modules/@rollup/rollup-android-arm-eabi": {
778
+ "version": "4.62.4",
779
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
780
+ "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
781
+ "cpu": [
782
+ "arm"
783
+ ],
784
+ "dev": true,
785
+ "license": "MIT",
786
+ "optional": true,
787
+ "os": [
788
+ "android"
789
+ ]
790
+ },
791
+ "node_modules/@rollup/rollup-android-arm64": {
792
+ "version": "4.62.4",
793
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
794
+ "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
795
+ "cpu": [
796
+ "arm64"
797
+ ],
798
+ "dev": true,
799
+ "license": "MIT",
800
+ "optional": true,
801
+ "os": [
802
+ "android"
803
+ ]
804
+ },
805
+ "node_modules/@rollup/rollup-darwin-arm64": {
806
+ "version": "4.62.4",
807
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
808
+ "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
809
+ "cpu": [
810
+ "arm64"
811
+ ],
812
+ "dev": true,
813
+ "license": "MIT",
814
+ "optional": true,
815
+ "os": [
816
+ "darwin"
817
+ ]
818
+ },
819
+ "node_modules/@rollup/rollup-darwin-x64": {
820
+ "version": "4.62.4",
821
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
822
+ "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
823
+ "cpu": [
824
+ "x64"
825
+ ],
826
+ "dev": true,
827
+ "license": "MIT",
828
+ "optional": true,
829
+ "os": [
830
+ "darwin"
831
+ ]
832
+ },
833
+ "node_modules/@rollup/rollup-freebsd-arm64": {
834
+ "version": "4.62.4",
835
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
836
+ "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
837
+ "cpu": [
838
+ "arm64"
839
+ ],
840
+ "dev": true,
841
+ "license": "MIT",
842
+ "optional": true,
843
+ "os": [
844
+ "freebsd"
845
+ ]
846
+ },
847
+ "node_modules/@rollup/rollup-freebsd-x64": {
848
+ "version": "4.62.4",
849
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
850
+ "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
851
+ "cpu": [
852
+ "x64"
853
+ ],
854
+ "dev": true,
855
+ "license": "MIT",
856
+ "optional": true,
857
+ "os": [
858
+ "freebsd"
859
+ ]
860
+ },
861
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
862
+ "version": "4.62.4",
863
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
864
+ "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
865
+ "cpu": [
866
+ "arm"
867
+ ],
868
+ "dev": true,
869
+ "license": "MIT",
870
+ "optional": true,
871
+ "os": [
872
+ "linux"
873
+ ]
874
+ },
875
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
876
+ "version": "4.62.4",
877
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
878
+ "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
879
+ "cpu": [
880
+ "arm"
881
+ ],
882
+ "dev": true,
883
+ "license": "MIT",
884
+ "optional": true,
885
+ "os": [
886
+ "linux"
887
+ ]
888
+ },
889
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
890
+ "version": "4.62.4",
891
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
892
+ "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
893
+ "cpu": [
894
+ "arm64"
895
+ ],
896
+ "dev": true,
897
+ "license": "MIT",
898
+ "optional": true,
899
+ "os": [
900
+ "linux"
901
+ ]
902
+ },
903
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
904
+ "version": "4.62.4",
905
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
906
+ "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
907
+ "cpu": [
908
+ "arm64"
909
+ ],
910
+ "dev": true,
911
+ "license": "MIT",
912
+ "optional": true,
913
+ "os": [
914
+ "linux"
915
+ ]
916
+ },
917
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
918
+ "version": "4.62.4",
919
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
920
+ "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
921
+ "cpu": [
922
+ "loong64"
923
+ ],
924
+ "dev": true,
925
+ "license": "MIT",
926
+ "optional": true,
927
+ "os": [
928
+ "linux"
929
+ ]
930
+ },
931
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
932
+ "version": "4.62.4",
933
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
934
+ "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
935
+ "cpu": [
936
+ "loong64"
937
+ ],
938
+ "dev": true,
939
+ "license": "MIT",
940
+ "optional": true,
941
+ "os": [
942
+ "linux"
943
+ ]
944
+ },
945
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
946
+ "version": "4.62.4",
947
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
948
+ "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
949
+ "cpu": [
950
+ "ppc64"
951
+ ],
952
+ "dev": true,
953
+ "license": "MIT",
954
+ "optional": true,
955
+ "os": [
956
+ "linux"
957
+ ]
958
+ },
959
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
960
+ "version": "4.62.4",
961
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
962
+ "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
963
+ "cpu": [
964
+ "ppc64"
965
+ ],
966
+ "dev": true,
967
+ "license": "MIT",
968
+ "optional": true,
969
+ "os": [
970
+ "linux"
971
+ ]
972
+ },
973
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
974
+ "version": "4.62.4",
975
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
976
+ "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
977
+ "cpu": [
978
+ "riscv64"
979
+ ],
980
+ "dev": true,
981
+ "license": "MIT",
982
+ "optional": true,
983
+ "os": [
984
+ "linux"
985
+ ]
986
+ },
987
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
988
+ "version": "4.62.4",
989
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
990
+ "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
991
+ "cpu": [
992
+ "riscv64"
993
+ ],
994
+ "dev": true,
995
+ "license": "MIT",
996
+ "optional": true,
997
+ "os": [
998
+ "linux"
999
+ ]
1000
+ },
1001
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
1002
+ "version": "4.62.4",
1003
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1004
+ "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1005
+ "cpu": [
1006
+ "s390x"
1007
+ ],
1008
+ "dev": true,
1009
+ "license": "MIT",
1010
+ "optional": true,
1011
+ "os": [
1012
+ "linux"
1013
+ ]
1014
+ },
1015
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
1016
+ "version": "4.62.4",
1017
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1018
+ "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1019
+ "cpu": [
1020
+ "x64"
1021
+ ],
1022
+ "dev": true,
1023
+ "license": "MIT",
1024
+ "optional": true,
1025
+ "os": [
1026
+ "linux"
1027
+ ]
1028
+ },
1029
+ "node_modules/@rollup/rollup-linux-x64-musl": {
1030
+ "version": "4.62.4",
1031
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1032
+ "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1033
+ "cpu": [
1034
+ "x64"
1035
+ ],
1036
+ "dev": true,
1037
+ "license": "MIT",
1038
+ "optional": true,
1039
+ "os": [
1040
+ "linux"
1041
+ ]
1042
+ },
1043
+ "node_modules/@rollup/rollup-openbsd-x64": {
1044
+ "version": "4.62.4",
1045
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1046
+ "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1047
+ "cpu": [
1048
+ "x64"
1049
+ ],
1050
+ "dev": true,
1051
+ "license": "MIT",
1052
+ "optional": true,
1053
+ "os": [
1054
+ "openbsd"
1055
+ ]
1056
+ },
1057
+ "node_modules/@rollup/rollup-openharmony-arm64": {
1058
+ "version": "4.62.4",
1059
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1060
+ "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1061
+ "cpu": [
1062
+ "arm64"
1063
+ ],
1064
+ "dev": true,
1065
+ "license": "MIT",
1066
+ "optional": true,
1067
+ "os": [
1068
+ "openharmony"
1069
+ ]
1070
+ },
1071
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1072
+ "version": "4.62.4",
1073
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1074
+ "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1075
+ "cpu": [
1076
+ "arm64"
1077
+ ],
1078
+ "dev": true,
1079
+ "license": "MIT",
1080
+ "optional": true,
1081
+ "os": [
1082
+ "win32"
1083
+ ]
1084
+ },
1085
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1086
+ "version": "4.62.4",
1087
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1088
+ "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1089
+ "cpu": [
1090
+ "ia32"
1091
+ ],
1092
+ "dev": true,
1093
+ "license": "MIT",
1094
+ "optional": true,
1095
+ "os": [
1096
+ "win32"
1097
+ ]
1098
+ },
1099
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1100
+ "version": "4.62.4",
1101
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1102
+ "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1103
+ "cpu": [
1104
+ "x64"
1105
+ ],
1106
+ "dev": true,
1107
+ "license": "MIT",
1108
+ "optional": true,
1109
+ "os": [
1110
+ "win32"
1111
+ ]
1112
+ },
1113
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1114
+ "version": "4.62.4",
1115
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1116
+ "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1117
+ "cpu": [
1118
+ "x64"
1119
+ ],
1120
+ "dev": true,
1121
+ "license": "MIT",
1122
+ "optional": true,
1123
+ "os": [
1124
+ "win32"
1125
+ ]
1126
+ },
1127
+ "node_modules/@types/babel__core": {
1128
+ "version": "7.20.5",
1129
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1130
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1131
+ "dev": true,
1132
+ "license": "MIT",
1133
+ "dependencies": {
1134
+ "@babel/parser": "^7.20.7",
1135
+ "@babel/types": "^7.20.7",
1136
+ "@types/babel__generator": "*",
1137
+ "@types/babel__template": "*",
1138
+ "@types/babel__traverse": "*"
1139
+ }
1140
+ },
1141
+ "node_modules/@types/babel__generator": {
1142
+ "version": "7.27.0",
1143
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1144
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1145
+ "dev": true,
1146
+ "license": "MIT",
1147
+ "dependencies": {
1148
+ "@babel/types": "^7.0.0"
1149
+ }
1150
+ },
1151
+ "node_modules/@types/babel__template": {
1152
+ "version": "7.4.4",
1153
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1154
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1155
+ "dev": true,
1156
+ "license": "MIT",
1157
+ "dependencies": {
1158
+ "@babel/parser": "^7.1.0",
1159
+ "@babel/types": "^7.0.0"
1160
+ }
1161
+ },
1162
+ "node_modules/@types/babel__traverse": {
1163
+ "version": "7.28.0",
1164
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1165
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1166
+ "dev": true,
1167
+ "license": "MIT",
1168
+ "dependencies": {
1169
+ "@babel/types": "^7.28.2"
1170
+ }
1171
+ },
1172
+ "node_modules/@types/estree": {
1173
+ "version": "1.0.9",
1174
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1175
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1176
+ "dev": true,
1177
+ "license": "MIT"
1178
+ },
1179
+ "node_modules/@vitejs/plugin-react": {
1180
+ "version": "4.7.0",
1181
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1182
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1183
+ "dev": true,
1184
+ "license": "MIT",
1185
+ "dependencies": {
1186
+ "@babel/core": "^7.28.0",
1187
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1188
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1189
+ "@rolldown/pluginutils": "1.0.0-beta.27",
1190
+ "@types/babel__core": "^7.20.5",
1191
+ "react-refresh": "^0.17.0"
1192
+ },
1193
+ "engines": {
1194
+ "node": "^14.18.0 || >=16.0.0"
1195
+ },
1196
+ "peerDependencies": {
1197
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1198
+ }
1199
+ },
1200
+ "node_modules/baseline-browser-mapping": {
1201
+ "version": "2.11.12",
1202
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
1203
+ "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==",
1204
+ "dev": true,
1205
+ "license": "Apache-2.0",
1206
+ "bin": {
1207
+ "baseline-browser-mapping": "dist/cli.cjs"
1208
+ },
1209
+ "engines": {
1210
+ "node": ">=6.0.0"
1211
+ }
1212
+ },
1213
+ "node_modules/browserslist": {
1214
+ "version": "4.28.7",
1215
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
1216
+ "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
1217
+ "dev": true,
1218
+ "funding": [
1219
+ {
1220
+ "type": "opencollective",
1221
+ "url": "https://opencollective.com/browserslist"
1222
+ },
1223
+ {
1224
+ "type": "tidelift",
1225
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1226
+ },
1227
+ {
1228
+ "type": "github",
1229
+ "url": "https://github.com/sponsors/ai"
1230
+ }
1231
+ ],
1232
+ "license": "MIT",
1233
+ "dependencies": {
1234
+ "baseline-browser-mapping": "^2.10.44",
1235
+ "caniuse-lite": "^1.0.30001806",
1236
+ "electron-to-chromium": "^1.5.393",
1237
+ "node-releases": "^2.0.51",
1238
+ "update-browserslist-db": "^1.2.3"
1239
+ },
1240
+ "bin": {
1241
+ "browserslist": "cli.js"
1242
+ },
1243
+ "engines": {
1244
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1245
+ }
1246
+ },
1247
+ "node_modules/caniuse-lite": {
1248
+ "version": "1.0.30001809",
1249
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
1250
+ "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
1251
+ "dev": true,
1252
+ "funding": [
1253
+ {
1254
+ "type": "opencollective",
1255
+ "url": "https://opencollective.com/browserslist"
1256
+ },
1257
+ {
1258
+ "type": "tidelift",
1259
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1260
+ },
1261
+ {
1262
+ "type": "github",
1263
+ "url": "https://github.com/sponsors/ai"
1264
+ }
1265
+ ],
1266
+ "license": "CC-BY-4.0"
1267
+ },
1268
+ "node_modules/convert-source-map": {
1269
+ "version": "2.0.0",
1270
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1271
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1272
+ "dev": true,
1273
+ "license": "MIT"
1274
+ },
1275
+ "node_modules/debug": {
1276
+ "version": "4.4.3",
1277
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1278
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1279
+ "dev": true,
1280
+ "license": "MIT",
1281
+ "dependencies": {
1282
+ "ms": "^2.1.3"
1283
+ },
1284
+ "engines": {
1285
+ "node": ">=6.0"
1286
+ },
1287
+ "peerDependenciesMeta": {
1288
+ "supports-color": {
1289
+ "optional": true
1290
+ }
1291
+ }
1292
+ },
1293
+ "node_modules/electron-to-chromium": {
1294
+ "version": "1.5.402",
1295
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz",
1296
+ "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==",
1297
+ "dev": true,
1298
+ "license": "ISC"
1299
+ },
1300
+ "node_modules/esbuild": {
1301
+ "version": "0.21.5",
1302
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1303
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1304
+ "dev": true,
1305
+ "hasInstallScript": true,
1306
+ "license": "MIT",
1307
+ "bin": {
1308
+ "esbuild": "bin/esbuild"
1309
+ },
1310
+ "engines": {
1311
+ "node": ">=12"
1312
+ },
1313
+ "optionalDependencies": {
1314
+ "@esbuild/aix-ppc64": "0.21.5",
1315
+ "@esbuild/android-arm": "0.21.5",
1316
+ "@esbuild/android-arm64": "0.21.5",
1317
+ "@esbuild/android-x64": "0.21.5",
1318
+ "@esbuild/darwin-arm64": "0.21.5",
1319
+ "@esbuild/darwin-x64": "0.21.5",
1320
+ "@esbuild/freebsd-arm64": "0.21.5",
1321
+ "@esbuild/freebsd-x64": "0.21.5",
1322
+ "@esbuild/linux-arm": "0.21.5",
1323
+ "@esbuild/linux-arm64": "0.21.5",
1324
+ "@esbuild/linux-ia32": "0.21.5",
1325
+ "@esbuild/linux-loong64": "0.21.5",
1326
+ "@esbuild/linux-mips64el": "0.21.5",
1327
+ "@esbuild/linux-ppc64": "0.21.5",
1328
+ "@esbuild/linux-riscv64": "0.21.5",
1329
+ "@esbuild/linux-s390x": "0.21.5",
1330
+ "@esbuild/linux-x64": "0.21.5",
1331
+ "@esbuild/netbsd-x64": "0.21.5",
1332
+ "@esbuild/openbsd-x64": "0.21.5",
1333
+ "@esbuild/sunos-x64": "0.21.5",
1334
+ "@esbuild/win32-arm64": "0.21.5",
1335
+ "@esbuild/win32-ia32": "0.21.5",
1336
+ "@esbuild/win32-x64": "0.21.5"
1337
+ }
1338
+ },
1339
+ "node_modules/escalade": {
1340
+ "version": "3.2.0",
1341
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1342
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1343
+ "dev": true,
1344
+ "license": "MIT",
1345
+ "engines": {
1346
+ "node": ">=6"
1347
+ }
1348
+ },
1349
+ "node_modules/fsevents": {
1350
+ "version": "2.3.3",
1351
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1352
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1353
+ "dev": true,
1354
+ "hasInstallScript": true,
1355
+ "license": "MIT",
1356
+ "optional": true,
1357
+ "os": [
1358
+ "darwin"
1359
+ ],
1360
+ "engines": {
1361
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1362
+ }
1363
+ },
1364
+ "node_modules/gensync": {
1365
+ "version": "1.0.0-beta.2",
1366
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1367
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1368
+ "dev": true,
1369
+ "license": "MIT",
1370
+ "engines": {
1371
+ "node": ">=6.9.0"
1372
+ }
1373
+ },
1374
+ "node_modules/hls.js": {
1375
+ "version": "1.6.17",
1376
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.17.tgz",
1377
+ "integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==",
1378
+ "license": "Apache-2.0"
1379
+ },
1380
+ "node_modules/js-tokens": {
1381
+ "version": "4.0.0",
1382
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1383
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1384
+ "license": "MIT"
1385
+ },
1386
+ "node_modules/jsesc": {
1387
+ "version": "3.1.0",
1388
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1389
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1390
+ "dev": true,
1391
+ "license": "MIT",
1392
+ "bin": {
1393
+ "jsesc": "bin/jsesc"
1394
+ },
1395
+ "engines": {
1396
+ "node": ">=6"
1397
+ }
1398
+ },
1399
+ "node_modules/json5": {
1400
+ "version": "2.2.3",
1401
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1402
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1403
+ "dev": true,
1404
+ "license": "MIT",
1405
+ "bin": {
1406
+ "json5": "lib/cli.js"
1407
+ },
1408
+ "engines": {
1409
+ "node": ">=6"
1410
+ }
1411
+ },
1412
+ "node_modules/loose-envify": {
1413
+ "version": "1.4.0",
1414
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1415
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1416
+ "license": "MIT",
1417
+ "dependencies": {
1418
+ "js-tokens": "^3.0.0 || ^4.0.0"
1419
+ },
1420
+ "bin": {
1421
+ "loose-envify": "cli.js"
1422
+ }
1423
+ },
1424
+ "node_modules/lru-cache": {
1425
+ "version": "5.1.1",
1426
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1427
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1428
+ "dev": true,
1429
+ "license": "ISC",
1430
+ "dependencies": {
1431
+ "yallist": "^3.0.2"
1432
+ }
1433
+ },
1434
+ "node_modules/ms": {
1435
+ "version": "2.1.3",
1436
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1437
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1438
+ "dev": true,
1439
+ "license": "MIT"
1440
+ },
1441
+ "node_modules/nanoid": {
1442
+ "version": "3.3.18",
1443
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1444
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1445
+ "dev": true,
1446
+ "funding": [
1447
+ {
1448
+ "type": "github",
1449
+ "url": "https://github.com/sponsors/ai"
1450
+ }
1451
+ ],
1452
+ "license": "MIT",
1453
+ "bin": {
1454
+ "nanoid": "bin/nanoid.cjs"
1455
+ },
1456
+ "engines": {
1457
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1458
+ }
1459
+ },
1460
+ "node_modules/node-releases": {
1461
+ "version": "2.0.53",
1462
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1463
+ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1464
+ "dev": true,
1465
+ "license": "MIT",
1466
+ "engines": {
1467
+ "node": ">=18"
1468
+ }
1469
+ },
1470
+ "node_modules/picocolors": {
1471
+ "version": "1.1.1",
1472
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1473
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1474
+ "dev": true,
1475
+ "license": "ISC"
1476
+ },
1477
+ "node_modules/postcss": {
1478
+ "version": "8.5.26",
1479
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1480
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1481
+ "dev": true,
1482
+ "funding": [
1483
+ {
1484
+ "type": "opencollective",
1485
+ "url": "https://opencollective.com/postcss/"
1486
+ },
1487
+ {
1488
+ "type": "tidelift",
1489
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1490
+ },
1491
+ {
1492
+ "type": "github",
1493
+ "url": "https://github.com/sponsors/ai"
1494
+ }
1495
+ ],
1496
+ "license": "MIT",
1497
+ "dependencies": {
1498
+ "nanoid": "^3.3.17",
1499
+ "picocolors": "^1.1.1",
1500
+ "source-map-js": "^1.2.1"
1501
+ },
1502
+ "engines": {
1503
+ "node": "^10 || ^12 || >=14"
1504
+ }
1505
+ },
1506
+ "node_modules/react": {
1507
+ "version": "18.3.1",
1508
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1509
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1510
+ "license": "MIT",
1511
+ "dependencies": {
1512
+ "loose-envify": "^1.1.0"
1513
+ },
1514
+ "engines": {
1515
+ "node": ">=0.10.0"
1516
+ }
1517
+ },
1518
+ "node_modules/react-dom": {
1519
+ "version": "18.3.1",
1520
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1521
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1522
+ "license": "MIT",
1523
+ "dependencies": {
1524
+ "loose-envify": "^1.1.0",
1525
+ "scheduler": "^0.23.2"
1526
+ },
1527
+ "peerDependencies": {
1528
+ "react": "^18.3.1"
1529
+ }
1530
+ },
1531
+ "node_modules/react-refresh": {
1532
+ "version": "0.17.0",
1533
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1534
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1535
+ "dev": true,
1536
+ "license": "MIT",
1537
+ "engines": {
1538
+ "node": ">=0.10.0"
1539
+ }
1540
+ },
1541
+ "node_modules/react-router": {
1542
+ "version": "6.30.4",
1543
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1544
+ "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1545
+ "license": "MIT",
1546
+ "dependencies": {
1547
+ "@remix-run/router": "1.23.3"
1548
+ },
1549
+ "engines": {
1550
+ "node": ">=14.0.0"
1551
+ },
1552
+ "peerDependencies": {
1553
+ "react": ">=16.8"
1554
+ }
1555
+ },
1556
+ "node_modules/react-router-dom": {
1557
+ "version": "6.30.4",
1558
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1559
+ "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1560
+ "license": "MIT",
1561
+ "dependencies": {
1562
+ "@remix-run/router": "1.23.3",
1563
+ "react-router": "6.30.4"
1564
+ },
1565
+ "engines": {
1566
+ "node": ">=14.0.0"
1567
+ },
1568
+ "peerDependencies": {
1569
+ "react": ">=16.8",
1570
+ "react-dom": ">=16.8"
1571
+ }
1572
+ },
1573
+ "node_modules/rollup": {
1574
+ "version": "4.62.4",
1575
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1576
+ "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1577
+ "dev": true,
1578
+ "license": "MIT",
1579
+ "dependencies": {
1580
+ "@types/estree": "1.0.9"
1581
+ },
1582
+ "bin": {
1583
+ "rollup": "dist/bin/rollup"
1584
+ },
1585
+ "engines": {
1586
+ "node": ">=18.0.0",
1587
+ "npm": ">=8.0.0"
1588
+ },
1589
+ "optionalDependencies": {
1590
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1591
+ "@rollup/rollup-android-arm-eabi": "4.62.4",
1592
+ "@rollup/rollup-android-arm64": "4.62.4",
1593
+ "@rollup/rollup-darwin-arm64": "4.62.4",
1594
+ "@rollup/rollup-darwin-x64": "4.62.4",
1595
+ "@rollup/rollup-freebsd-arm64": "4.62.4",
1596
+ "@rollup/rollup-freebsd-x64": "4.62.4",
1597
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1598
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1599
+ "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1600
+ "@rollup/rollup-linux-arm64-musl": "4.62.4",
1601
+ "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1602
+ "@rollup/rollup-linux-loong64-musl": "4.62.4",
1603
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1604
+ "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1605
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1606
+ "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1607
+ "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1608
+ "@rollup/rollup-linux-x64-gnu": "4.62.4",
1609
+ "@rollup/rollup-linux-x64-musl": "4.62.4",
1610
+ "@rollup/rollup-openbsd-x64": "4.62.4",
1611
+ "@rollup/rollup-openharmony-arm64": "4.62.4",
1612
+ "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1613
+ "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1614
+ "@rollup/rollup-win32-x64-gnu": "4.62.4",
1615
+ "@rollup/rollup-win32-x64-msvc": "4.62.4",
1616
+ "fsevents": "~2.3.2"
1617
+ }
1618
+ },
1619
+ "node_modules/scheduler": {
1620
+ "version": "0.23.2",
1621
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1622
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1623
+ "license": "MIT",
1624
+ "dependencies": {
1625
+ "loose-envify": "^1.1.0"
1626
+ }
1627
+ },
1628
+ "node_modules/semver": {
1629
+ "version": "6.3.1",
1630
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1631
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1632
+ "dev": true,
1633
+ "license": "ISC",
1634
+ "bin": {
1635
+ "semver": "bin/semver.js"
1636
+ }
1637
+ },
1638
+ "node_modules/source-map-js": {
1639
+ "version": "1.2.1",
1640
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1641
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1642
+ "dev": true,
1643
+ "license": "BSD-3-Clause",
1644
+ "engines": {
1645
+ "node": ">=0.10.0"
1646
+ }
1647
+ },
1648
+ "node_modules/update-browserslist-db": {
1649
+ "version": "1.3.0",
1650
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz",
1651
+ "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==",
1652
+ "dev": true,
1653
+ "funding": [
1654
+ {
1655
+ "type": "opencollective",
1656
+ "url": "https://opencollective.com/browserslist"
1657
+ },
1658
+ {
1659
+ "type": "tidelift",
1660
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1661
+ },
1662
+ {
1663
+ "type": "github",
1664
+ "url": "https://github.com/sponsors/ai"
1665
+ }
1666
+ ],
1667
+ "license": "MIT",
1668
+ "dependencies": {
1669
+ "escalade": "^3.2.0",
1670
+ "picocolors": "^1.1.1"
1671
+ },
1672
+ "bin": {
1673
+ "update-browserslist-db": "cli.js"
1674
+ },
1675
+ "peerDependencies": {
1676
+ "browserslist": ">= 4.21.0"
1677
+ }
1678
+ },
1679
+ "node_modules/vite": {
1680
+ "version": "5.4.21",
1681
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1682
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1683
+ "dev": true,
1684
+ "license": "MIT",
1685
+ "dependencies": {
1686
+ "esbuild": "^0.21.3",
1687
+ "postcss": "^8.4.43",
1688
+ "rollup": "^4.20.0"
1689
+ },
1690
+ "bin": {
1691
+ "vite": "bin/vite.js"
1692
+ },
1693
+ "engines": {
1694
+ "node": "^18.0.0 || >=20.0.0"
1695
+ },
1696
+ "funding": {
1697
+ "url": "https://github.com/vitejs/vite?sponsor=1"
1698
+ },
1699
+ "optionalDependencies": {
1700
+ "fsevents": "~2.3.3"
1701
+ },
1702
+ "peerDependencies": {
1703
+ "@types/node": "^18.0.0 || >=20.0.0",
1704
+ "less": "*",
1705
+ "lightningcss": "^1.21.0",
1706
+ "sass": "*",
1707
+ "sass-embedded": "*",
1708
+ "stylus": "*",
1709
+ "sugarss": "*",
1710
+ "terser": "^5.4.0"
1711
+ },
1712
+ "peerDependenciesMeta": {
1713
+ "@types/node": {
1714
+ "optional": true
1715
+ },
1716
+ "less": {
1717
+ "optional": true
1718
+ },
1719
+ "lightningcss": {
1720
+ "optional": true
1721
+ },
1722
+ "sass": {
1723
+ "optional": true
1724
+ },
1725
+ "sass-embedded": {
1726
+ "optional": true
1727
+ },
1728
+ "stylus": {
1729
+ "optional": true
1730
+ },
1731
+ "sugarss": {
1732
+ "optional": true
1733
+ },
1734
+ "terser": {
1735
+ "optional": true
1736
+ }
1737
+ }
1738
+ },
1739
+ "node_modules/yallist": {
1740
+ "version": "3.1.1",
1741
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1742
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1743
+ "dev": true,
1744
+ "license": "ISC"
1745
+ }
1746
+ }
1747
+ }
frontend/package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "anicove-frontend",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "hls.js": "^1.5.13",
13
+ "react": "^18.3.1",
14
+ "react-dom": "^18.3.1",
15
+ "react-router-dom": "^6.26.2"
16
+ },
17
+ "devDependencies": {
18
+ "@vitejs/plugin-react": "^4.3.1",
19
+ "vite": "^5.4.2"
20
+ }
21
+ }
frontend/public/music-api.js ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // music-api.js — fetchAPI bridge for music.js
2
+ // Connects music.js directly to AnimeThemes API v2.
3
+
4
+ (function () {
5
+ const AT = 'https://api.animethemes.moe';
6
+
7
+ async function _get(url) {
8
+ const r = await fetch(url, { headers: { Accept: 'application/json' } });
9
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
10
+ return r.json();
11
+ }
12
+
13
+ function normalizeImages(images) {
14
+ if (!images || !Array.isArray(images)) return [];
15
+ return images.map(img => {
16
+ let link = img.link || img.url;
17
+ if (!link && img.path) link = `https://static.animethemes.moe/${img.path}`;
18
+ if (!link && img.basename) link = `https://static.animethemes.moe/${img.basename}`;
19
+ return {
20
+ facet: img.facet || 'Large Cover',
21
+ link: link || ''
22
+ };
23
+ });
24
+ }
25
+
26
+ function normalizeTheme(t, animeRef) {
27
+ if (!t) return null;
28
+ const entries = t.animethemeentries || t.entries || [];
29
+ const normEntries = entries.map(e => {
30
+ const videos = e.videos || [];
31
+ const normVideos = videos.map(v => {
32
+ let audioLink = v.audio?.link || v.link || v.url;
33
+ if (!audioLink && v.basename) audioLink = `https://v.animethemes.moe/${v.basename}`;
34
+ if (!audioLink && v.filename) audioLink = `https://v.animethemes.moe/${v.filename}`;
35
+ return {
36
+ ...v,
37
+ audio: { id: v.audio?.id || v.id || t.id || '1', link: audioLink }
38
+ };
39
+ });
40
+ return { ...e, videos: normVideos };
41
+ });
42
+ return {
43
+ ...t,
44
+ id: t.id || Math.random().toString(36).substring(7),
45
+ slug: t.slug || `${t.type || 'OP'}${t.sequence || ''}`,
46
+ type: t.type || 'OP',
47
+ animethemeentries: normEntries,
48
+ anime: animeRef || t.anime
49
+ };
50
+ }
51
+
52
+ function normalizeAnime(a) {
53
+ if (!a) return null;
54
+ const rawThemes = a.animethemes || a.themes || [];
55
+ const animeObj = {
56
+ id: a.slug || String(a.id),
57
+ rawId: a.id,
58
+ name: a.name || a.title || a.name_jp || 'Anime',
59
+ slug: a.slug || String(a.id),
60
+ year: a.year || (a.season ? a.season : ''),
61
+ media_format: a.media_format || a.format || 'TV',
62
+ images: normalizeImages(a.images)
63
+ };
64
+ animeObj.animethemes = rawThemes.map(t => normalizeTheme(t, animeObj));
65
+ return animeObj;
66
+ }
67
+
68
+ function normalizeArtist(art) {
69
+ if (!art) return null;
70
+ return {
71
+ id: art.slug || String(art.id),
72
+ name: art.name || 'Artist',
73
+ slug: art.slug || String(art.id),
74
+ images: normalizeImages(art.images),
75
+ songs: art.songs || []
76
+ };
77
+ }
78
+
79
+ async function fetchAPI(path) {
80
+ try {
81
+ // /themes/search?q=<q>
82
+ if (path.startsWith('/themes/search')) {
83
+ const q = new URL(path, 'https://x').searchParams.get('q') || '';
84
+ const data = await _get(`${AT}/search?q=${encodeURIComponent(q)}&include[anime]=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`);
85
+ const list = (data.search?.anime || data.anime || []).map(normalizeAnime);
86
+ return { anime: list };
87
+ }
88
+
89
+ // /themes/anime/<id_or_slug>
90
+ if (path.startsWith('/themes/anime/')) {
91
+ const param = decodeURIComponent(path.replace('/themes/anime/', '').split('?')[0]);
92
+ let rawAnime = null;
93
+
94
+ // 1. Direct slug endpoint fetch with full relations
95
+ const data = await _get(`${AT}/anime/${encodeURIComponent(param)}?include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`).catch(() => null);
96
+ if (data?.anime) rawAnime = data.anime;
97
+ else if (data?.data) rawAnime = Array.isArray(data.data) ? data.data[0] : data.data;
98
+
99
+ // 2. Filter search if direct fetch failed
100
+ if (!rawAnime) {
101
+ const filterData = await _get(`${AT}/anime?filter[slug]=${encodeURIComponent(param)}&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`).catch(() => null)
102
+ || await _get(`${AT}/anime?filter[anilist_id]=${encodeURIComponent(param)}&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`).catch(() => null);
103
+ const list = filterData?.anime || filterData?.data || [];
104
+ if (list.length) rawAnime = list[0];
105
+ }
106
+
107
+ return { anime: rawAnime ? normalizeAnime(rawAnime) : null };
108
+ }
109
+
110
+ // /themes/artist/<slug>
111
+ if (path.startsWith('/themes/artist/')) {
112
+ const slug = decodeURIComponent(path.replace('/themes/artist/', '').split('?')[0]);
113
+ const data = await _get(`${AT}/artist/${encodeURIComponent(slug)}?include=animethemes.anime.images,animethemes.song`).catch(() => null);
114
+ const raw = data?.artist || (Array.isArray(data?.data) ? data.data[0] : data?.data);
115
+ return { artist: raw ? normalizeArtist(raw) : null };
116
+ }
117
+
118
+ // /themes/artists or /themes/top-artists
119
+ if (path.startsWith('/themes/artists') || path.startsWith('/themes/top-artists')) {
120
+ const data = await _get(`${AT}/artist?page[size]=24&include=images`);
121
+ const list = data?.artist || data?.data || [];
122
+ return { artists: list.map(normalizeArtist) };
123
+ }
124
+
125
+ // /themes/recent
126
+ if (path.startsWith('/themes/recent')) {
127
+ const data = await _get(`${AT}/anime?sort=-created_at&page[size]=24&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`);
128
+ const list = data?.anime || data?.data || [];
129
+ return { anime: list.map(normalizeAnime) };
130
+ }
131
+
132
+ // /themes/year/<year>
133
+ if (path.startsWith('/themes/year/')) {
134
+ const year = decodeURIComponent(path.replace('/themes/year/', '').split('?')[0]);
135
+ const data = await _get(`${AT}/anime?filter[year]=${year}&sort=name&page[size]=24&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`);
136
+ const list = data?.anime || data?.data || [];
137
+ return { anime: list.map(normalizeAnime) };
138
+ }
139
+
140
+ // Default home discover feed
141
+ const defaultData = await _get(`${AT}/anime?page[size]=24&include=animethemes.animethemeentries.videos.audio,animethemes.song.artists,images`);
142
+ const defaultList = defaultData?.anime || defaultData?.data || [];
143
+ return { anime: defaultList.map(normalizeAnime) };
144
+ } catch (e) {
145
+ console.error('[music-api] fetch error:', e);
146
+ return { anime: null, artists: [] };
147
+ }
148
+ }
149
+
150
+ window.fetchAPI = fetchAPI;
151
+ })();
frontend/public/music.css ADDED
@@ -0,0 +1,2197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ :root {
3
+ --ms-bg: #0a0a0f;
4
+ --ms-sidebar: #000000;
5
+ --ms-surface: #121218;
6
+ --ms-surface-2: #1a1a22;
7
+ --ms-surface-3: #232330;
8
+ --ms-text: #ffffff;
9
+ --ms-text-dim: #a0a0b8;
10
+ --ms-text-mute: #6a6a82;
11
+ --ms-divider: rgba(255, 255, 255, 0.07);
12
+ --ms-accent: #7c3aed;
13
+ --ms-accent-hot: #9d6ef8;
14
+ --ms-accent-soft: rgba(124, 58, 237, 0.18);
15
+ }
16
+
17
+ .music-body {
18
+ overflow: hidden;
19
+ background:
20
+ radial-gradient(circle at top, rgba(124,58,237,0.22), transparent 22%),
21
+ linear-gradient(180deg, #07070d 0%, #090910 100%);
22
+ }
23
+
24
+ .music-body .float-nav { display: none !important; }
25
+
26
+ .music-body #mobile-nav { display: none !important; }
27
+
28
+ .music-body #header {
29
+ background: rgba(0, 0, 0, 0.92);
30
+ border-bottom: 1px solid var(--ms-divider);
31
+ backdrop-filter: blur(20px);
32
+ -webkit-backdrop-filter: blur(20px);
33
+ }
34
+
35
+ .music-app {
36
+ position: fixed;
37
+ top: var(--header-h);
38
+ left: 0;
39
+ right: 0;
40
+ bottom: 92px;
41
+ display: grid;
42
+ grid-template-columns: 280px 1fr;
43
+ gap: 8px;
44
+ padding: 8px;
45
+ background: #000;
46
+ transition: top 0.32s cubic-bezier(0.22, 1, 0.36, 1),
47
+ bottom 0.28s cubic-bezier(0.22, 1, 0.36, 1),
48
+ grid-template-columns 0.32s cubic-bezier(0.22, 1, 0.36, 1);
49
+ }
50
+
51
+ .music-body #header {
52
+ transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
53
+ will-change: transform;
54
+ }
55
+ .music-body #header.is-hidden {
56
+ transform: translateY(-100%);
57
+ }
58
+ .music-body.header-hidden .music-app {
59
+ top: 0;
60
+ }
61
+
62
+ @media (max-width: 1024px) {
63
+ .music-body .music-sidebar {
64
+ transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1),
65
+ top 0.32s cubic-bezier(0.22, 1, 0.36, 1);
66
+ }
67
+ .music-body.header-hidden .music-sidebar { top: 0; }
68
+ }
69
+
70
+ @media (min-width: 1025px) {
71
+ .music-app.sb-collapsed {
72
+ grid-template-columns: 0 1fr;
73
+ gap: 0;
74
+ }
75
+ .music-app.sb-collapsed .music-sidebar {
76
+ transform: translateX(-110%);
77
+ opacity: 0;
78
+ pointer-events: none;
79
+ }
80
+ .music-sidebar {
81
+ transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1),
82
+ opacity 0.22s ease;
83
+ will-change: transform;
84
+ }
85
+ .ms-sidebar-toggle { display: flex; }
86
+ }
87
+
88
+ .music-body.pb-hidden .music-app { bottom: 0; }
89
+ .music-body.pb-hidden .player-bar { display: none; }
90
+
91
+ .music-sidebar {
92
+ background: var(--ms-sidebar);
93
+ border-radius: 10px;
94
+ padding: 16px 12px;
95
+ overflow-y: auto;
96
+ overflow-x: hidden;
97
+ scrollbar-width: thin;
98
+ scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
99
+ }
100
+
101
+ .music-sidebar::-webkit-scrollbar { width: 8px; }
102
+ .music-sidebar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.16); border-radius: 4px; }
103
+
104
+ .ms-block { margin-bottom: 22px; }
105
+
106
+ .ms-block-title {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 10px;
110
+ color: var(--ms-text-dim);
111
+ font-size: 0.75rem;
112
+ font-weight: 700;
113
+ text-transform: uppercase;
114
+ letter-spacing: 0.08em;
115
+ padding: 6px 10px 10px;
116
+ }
117
+ .ms-block-title svg { width: 16px; height: 16px; }
118
+
119
+ .ms-link {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 14px;
123
+ padding: 10px 12px;
124
+ border-radius: 6px;
125
+ color: var(--ms-text-dim);
126
+ font-size: 0.92rem;
127
+ font-weight: 600;
128
+ transition: color 0.15s ease, background 0.15s ease;
129
+ cursor: pointer;
130
+ }
131
+ .ms-link svg { width: 20px; height: 20px; flex-shrink: 0; }
132
+ .ms-link:hover { color: #fff; background: transparent; }
133
+ .ms-link.active { color: #fff; background: var(--ms-surface-2); }
134
+ .ms-link.active svg { color: var(--ms-accent-hot); }
135
+
136
+ .ms-year-grid {
137
+ display: grid;
138
+ grid-template-columns: repeat(2, 1fr);
139
+ gap: 6px;
140
+ padding: 4px 6px;
141
+ }
142
+ .ms-year-grid a {
143
+ background: var(--ms-surface-2);
144
+ border-radius: 6px;
145
+ padding: 9px 10px;
146
+ text-align: center;
147
+ font-size: 0.85rem;
148
+ font-weight: 600;
149
+ color: var(--ms-text-dim);
150
+ transition: color 0.15s, background 0.15s;
151
+ }
152
+ .ms-year-grid a:hover {
153
+ background: var(--ms-surface-3);
154
+ color: #fff;
155
+ }
156
+
157
+ .music-main {
158
+ background: linear-gradient(180deg, #1a1228 0, #0e0e14 320px, var(--ms-surface) 100%);
159
+ border-radius: 10px;
160
+ overflow-y: auto;
161
+ overflow-x: hidden;
162
+ position: relative;
163
+ scrollbar-width: thin;
164
+ scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
165
+ }
166
+ .music-main::-webkit-scrollbar { width: 12px; }
167
+ .music-main::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.16); border-radius: 6px; }
168
+ .music-main::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.28); }
169
+
170
+ .ms-topbar {
171
+ position: sticky;
172
+ top: 0;
173
+ z-index: 5;
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 14px;
177
+ padding: 14px 24px;
178
+ background: rgba(10, 10, 16, 0.65);
179
+ backdrop-filter: blur(18px);
180
+ -webkit-backdrop-filter: blur(18px);
181
+ }
182
+
183
+ .ms-nav-btn {
184
+ width: 32px; height: 32px;
185
+ border-radius: 50%;
186
+ background: rgba(0, 0, 0, 0.7);
187
+ color: #fff;
188
+ display: flex; align-items: center; justify-content: center;
189
+ transition: background 0.15s, transform 0.15s;
190
+ flex-shrink: 0;
191
+ }
192
+ .ms-nav-btn svg { width: 14px; height: 14px; }
193
+ .ms-nav-btn:hover:not([disabled]) { background: #000; transform: scale(1.06); }
194
+ .ms-nav-btn[disabled] { opacity: 0.45; cursor: not-allowed; }
195
+
196
+ .ms-search {
197
+ position: relative;
198
+ flex: 1;
199
+ max-width: 360px;
200
+ display: flex;
201
+ align-items: center;
202
+ }
203
+ .ms-search-icon {
204
+ position: absolute;
205
+ left: 14px;
206
+ top: 50%;
207
+ transform: translateY(-50%);
208
+ color: #1a1a22;
209
+ display: flex;
210
+ pointer-events: none;
211
+ }
212
+ .ms-search-icon svg { width: 18px; height: 18px; }
213
+
214
+ .ms-search input {
215
+ width: 100%;
216
+ padding: 11px 40px 11px 42px;
217
+ background: #fff;
218
+ border: 2px solid transparent;
219
+ border-radius: 999px;
220
+ color: #1a1a22;
221
+ font-family: 'Outfit', sans-serif;
222
+ font-size: 0.92rem;
223
+ font-weight: 500;
224
+ outline: none;
225
+ transition: box-shadow 0.15s;
226
+ }
227
+ .ms-search input::placeholder { color: #6a6a82; font-weight: 500; }
228
+ .ms-search input:focus {
229
+ box-shadow: 0 0 0 2px var(--ms-accent);
230
+ }
231
+
232
+ .ms-search-clear {
233
+ position: absolute;
234
+ right: 8px;
235
+ top: 50%;
236
+ transform: translateY(-50%);
237
+ width: 26px; height: 26px;
238
+ border-radius: 50%;
239
+ color: #1a1a22;
240
+ display: flex; align-items: center; justify-content: center;
241
+ transition: background 0.15s;
242
+ }
243
+ .ms-search-clear svg { width: 14px; height: 14px; }
244
+ .ms-search-clear:hover { background: rgba(0, 0, 0, 0.08); }
245
+
246
+ .ms-sidebar-toggle {
247
+ display: flex;
248
+ width: 38px; height: 38px;
249
+ border-radius: 10px;
250
+ color: #fff;
251
+ background: rgba(255, 255, 255, 0.06);
252
+ border: 1px solid rgba(255, 255, 255, 0.08);
253
+ align-items: center; justify-content: center;
254
+ margin: 0;
255
+
256
+ order: -1;
257
+ flex-shrink: 0;
258
+ transition: background 0.16s ease, transform 0.16s ease, border-color 0.16s ease;
259
+ cursor: pointer;
260
+ }
261
+ .ms-sidebar-toggle:hover {
262
+ background: rgba(167, 139, 250, 0.18);
263
+ border-color: rgba(167, 139, 250, 0.35);
264
+ transform: scale(1.04);
265
+ }
266
+ .ms-sidebar-toggle:active { transform: scale(0.94); }
267
+ .ms-sidebar-toggle svg { width: 20px; height: 20px; }
268
+
269
+ .music-app.sb-collapsed .ms-sidebar-toggle svg { transform: rotate(180deg); transition: transform 0.22s ease; }
270
+
271
+ .ms-view {
272
+ padding: 0 24px 40px;
273
+ min-height: calc(100% - 70px);
274
+ }
275
+
276
+ .ms-section-title {
277
+ display: flex;
278
+ align-items: baseline;
279
+ justify-content: space-between;
280
+ gap: 14px;
281
+ margin: 30px 0 16px;
282
+ }
283
+ .ms-section-title h2 {
284
+ font-size: 1.5rem;
285
+ font-weight: 800;
286
+ letter-spacing: -0.01em;
287
+ color: #fff;
288
+ }
289
+ .ms-section-title a {
290
+ color: var(--ms-text-dim);
291
+ font-size: 0.78rem;
292
+ font-weight: 700;
293
+ text-transform: uppercase;
294
+ letter-spacing: 0.08em;
295
+ transition: color 0.15s;
296
+ }
297
+ .ms-section-title a:hover { color: #fff; text-decoration: underline; }
298
+
299
+ .ms-card-grid {
300
+ display: grid;
301
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
302
+ gap: 24px;
303
+ }
304
+
305
+ .ms-card {
306
+ background: var(--ms-surface);
307
+ border-radius: 8px;
308
+ padding: 16px;
309
+ transition: background 0.2s ease;
310
+ cursor: pointer;
311
+ position: relative;
312
+ text-align: left;
313
+ isolation: isolate;
314
+ }
315
+ .ms-card:hover { background: var(--ms-surface-2); }
316
+ .ms-card:hover .ms-card-play {
317
+ opacity: 1;
318
+ transform: translateY(0);
319
+ }
320
+
321
+ .ms-card-cover-wrap {
322
+ position: relative;
323
+ width: 100%;
324
+ aspect-ratio: 1;
325
+ border-radius: 6px;
326
+ overflow: hidden;
327
+ background: var(--ms-surface-2);
328
+ margin-bottom: 14px;
329
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
330
+
331
+ contain: layout paint;
332
+ flex-shrink: 0;
333
+ }
334
+ .ms-card.is-artist .ms-card-cover-wrap {
335
+ border-radius: 50%;
336
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
337
+ }
338
+
339
+ .ms-card-cover {
340
+ width: 100%;
341
+ height: 100%;
342
+ object-fit: cover;
343
+ transition: transform 0.4s ease;
344
+ }
345
+
346
+ .ms-card-cover-fallback {
347
+ width: 100%;
348
+ height: 100%;
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: center;
352
+ background: linear-gradient(135deg, var(--ms-accent), #2a1656);
353
+ color: #fff;
354
+ }
355
+ .ms-card-cover-fallback svg { width: 48px; height: 48px; opacity: 0.7; }
356
+
357
+ .ms-card-play {
358
+ position: absolute;
359
+ right: 8px;
360
+ bottom: 8px;
361
+ width: 48px; height: 48px;
362
+ border-radius: 50%;
363
+ background: var(--ms-accent);
364
+ color: #fff;
365
+ display: flex;
366
+ align-items: center;
367
+ justify-content: center;
368
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.6);
369
+ opacity: 0;
370
+ transform: translateY(8px);
371
+ transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease, scale 0.12s;
372
+ z-index: 2;
373
+ }
374
+ .ms-card-play svg { width: 22px; height: 22px; transform: translateX(1px); }
375
+ .ms-card-play:hover { background: var(--ms-accent-hot); transform: translateY(0) scale(1.06); }
376
+ .ms-card-play:active { transform: translateY(0) scale(0.96); }
377
+
378
+ .ms-card-title {
379
+ font-size: 1rem;
380
+ font-weight: 700;
381
+ color: #fff;
382
+ margin-bottom: 6px;
383
+ overflow: hidden;
384
+ text-overflow: ellipsis;
385
+ display: -webkit-box;
386
+ -webkit-line-clamp: 1;
387
+ -webkit-box-orient: vertical;
388
+ line-clamp: 1;
389
+ }
390
+ .ms-card-sub {
391
+ font-size: 0.85rem;
392
+ color: var(--ms-text-dim);
393
+ overflow: hidden;
394
+ text-overflow: ellipsis;
395
+ display: -webkit-box;
396
+ -webkit-line-clamp: 2;
397
+ -webkit-box-orient: vertical;
398
+ line-clamp: 2;
399
+ line-height: 1.4;
400
+ }
401
+
402
+ .ms-empty {
403
+ padding: 60px 20px;
404
+ text-align: center;
405
+ color: var(--ms-text-dim);
406
+ }
407
+ .ms-empty svg { width: 56px; height: 56px; margin-bottom: 14px; opacity: 0.4; }
408
+ .ms-empty h3 { font-size: 1.4rem; color: #fff; margin-bottom: 8px; font-weight: 800; }
409
+ .ms-empty p { font-size: 0.95rem; }
410
+
411
+ .ms-loading {
412
+ padding: 80px 20px;
413
+ text-align: center;
414
+ color: var(--ms-text-dim);
415
+ }
416
+ .ms-spinner {
417
+ width: 36px; height: 36px;
418
+ border: 3px solid rgba(124, 58, 237, 0.18);
419
+ border-top-color: var(--ms-accent);
420
+ border-radius: 50%;
421
+ margin: 0 auto 16px;
422
+ animation: msSpin 0.8s linear infinite;
423
+ }
424
+ @keyframes msSpin { to { transform: rotate(360deg); } }
425
+
426
+ .ms-album-hero {
427
+ display: flex;
428
+ gap: 28px;
429
+ align-items: flex-end;
430
+ padding: 36px 0 24px;
431
+ }
432
+
433
+ .ms-album-cover {
434
+ width: 232px; height: 232px;
435
+ border-radius: 6px;
436
+ overflow: hidden;
437
+ flex-shrink: 0;
438
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.85);
439
+ background: linear-gradient(135deg, var(--ms-accent), #2a1656);
440
+ }
441
+ .ms-album-cover img { width: 100%; height: 100%; object-fit: cover; }
442
+
443
+ .ms-album-info { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
444
+ .ms-album-eyebrow {
445
+ font-size: 0.78rem;
446
+ font-weight: 700;
447
+ text-transform: uppercase;
448
+ letter-spacing: 0.08em;
449
+ color: #fff;
450
+ }
451
+ .ms-album-title {
452
+ font-size: clamp(2rem, 6vw, 4rem);
453
+ font-weight: 900;
454
+ color: #fff;
455
+ line-height: 1.05;
456
+ letter-spacing: -0.03em;
457
+ margin: 4px 0 8px;
458
+ word-break: break-word;
459
+ }
460
+ .ms-album-meta { color: var(--ms-text-dim); font-size: 0.9rem; }
461
+ .ms-album-meta strong { color: #fff; font-weight: 700; }
462
+
463
+ .ms-album-actions {
464
+ display: flex;
465
+ gap: 16px;
466
+ align-items: center;
467
+ padding: 24px 0 10px;
468
+ }
469
+ .ms-play-big {
470
+ width: 60px; height: 60px;
471
+ border-radius: 50%;
472
+ background: linear-gradient(135deg, var(--ms-accent) 0%, var(--ms-accent-hot) 100%);
473
+ color: #fff;
474
+ display: flex; align-items: center; justify-content: center;
475
+ transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
476
+ box-shadow 0.18s ease,
477
+ filter 0.18s ease;
478
+ box-shadow: 0 10px 28px rgba(167, 139, 250, 0.45),
479
+ 0 4px 12px rgba(0, 0, 0, 0.5),
480
+ inset 0 1px 0 rgba(255, 255, 255, 0.25);
481
+ position: relative;
482
+ overflow: hidden;
483
+ }
484
+ .ms-play-big::before {
485
+ content: "";
486
+ position: absolute;
487
+ inset: 0;
488
+ border-radius: inherit;
489
+ background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.35), transparent 55%);
490
+ pointer-events: none;
491
+ }
492
+ .ms-play-big svg { width: 28px; height: 28px; transform: translateX(2px); position: relative; z-index: 1; }
493
+ .ms-play-big:hover {
494
+ transform: scale(1.08) translateY(-1px);
495
+ filter: brightness(1.08);
496
+ box-shadow: 0 14px 36px rgba(167, 139, 250, 0.6),
497
+ 0 6px 14px rgba(0, 0, 0, 0.55),
498
+ inset 0 1px 0 rgba(255, 255, 255, 0.3);
499
+ }
500
+ .ms-play-big:active { transform: scale(0.95); transition-duration: 0.08s; }
501
+ .ms-play-big:focus-visible {
502
+ outline: none;
503
+ box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.5),
504
+ 0 10px 28px rgba(167, 139, 250, 0.45),
505
+ 0 4px 12px rgba(0, 0, 0, 0.5);
506
+ }
507
+
508
+ .ms-icon-btn {
509
+ width: 38px; height: 38px;
510
+ border-radius: 50%;
511
+ color: var(--ms-text-dim);
512
+ display: flex; align-items: center; justify-content: center;
513
+ transition: color 0.12s, transform 0.12s;
514
+ }
515
+ .ms-icon-btn svg { width: 24px; height: 24px; }
516
+ .ms-icon-btn:hover { color: #fff; transform: scale(1.05); }
517
+ .ms-icon-btn.is-on { color: var(--ms-accent-hot); }
518
+ .ms-icon-btn.is-on svg { fill: currentColor; }
519
+
520
+ .ms-tracks {
521
+ margin-top: 8px;
522
+ padding-bottom: 16px;
523
+ }
524
+ .ms-tracks-head {
525
+ display: grid;
526
+ grid-template-columns: 36px 1fr 0.8fr 100px 60px;
527
+ gap: 14px;
528
+ padding: 8px 16px;
529
+ border-bottom: 1px solid var(--ms-divider);
530
+ margin-bottom: 8px;
531
+ color: var(--ms-text-dim);
532
+ font-size: 0.74rem;
533
+ text-transform: uppercase;
534
+ letter-spacing: 0.1em;
535
+ font-weight: 600;
536
+ }
537
+
538
+ .ms-track {
539
+ display: grid;
540
+ grid-template-columns: 36px 1fr 0.8fr 100px 60px;
541
+ gap: 14px;
542
+ align-items: center;
543
+ padding: 8px 16px;
544
+ border-radius: 4px;
545
+ cursor: pointer;
546
+ transition: background 0.12s ease;
547
+ color: var(--ms-text-dim);
548
+ position: relative;
549
+ }
550
+ .ms-track:hover { background: rgba(255, 255, 255, 0.07); color: #fff; }
551
+ .ms-track:hover .ms-track-num .ms-num { display: none; }
552
+ .ms-track:hover .ms-track-num .ms-play-mini { display: flex; }
553
+ .ms-track.is-playing { color: var(--ms-accent-hot); }
554
+ .ms-track.is-playing .ms-track-title { color: var(--ms-accent-hot); }
555
+
556
+ .ms-track-num {
557
+ text-align: center;
558
+ font-size: 0.95rem;
559
+ color: var(--ms-text-dim);
560
+ width: 36px; height: 36px;
561
+ display: flex; align-items: center; justify-content: center;
562
+ }
563
+ .ms-track-num .ms-play-mini { display: none; color: #fff; }
564
+ .ms-track-num .ms-play-mini svg { width: 16px; height: 16px; fill: currentColor; }
565
+ .ms-track.is-playing .ms-num { display: none; }
566
+ .ms-track.is-playing .ms-now-bars { display: flex; }
567
+ .ms-now-bars { display: none; gap: 2px; align-items: flex-end; height: 14px; }
568
+ .ms-now-bars span {
569
+ display: block; width: 3px; background: var(--ms-accent-hot); border-radius: 1px;
570
+ animation: msBar 0.8s ease-in-out infinite alternate;
571
+ }
572
+ .ms-now-bars span:nth-child(1) { animation-delay: -0.5s; }
573
+ .ms-now-bars span:nth-child(2) { animation-delay: -0.2s; }
574
+ .ms-now-bars span:nth-child(3) { animation-delay: -0.8s; }
575
+ @keyframes msBar { from { height: 4px; } to { height: 14px; } }
576
+
577
+ .ms-track-meta { display: flex; gap: 12px; align-items: center; min-width: 0; }
578
+ .ms-track-cover {
579
+ width: 40px; height: 40px;
580
+ border-radius: 4px;
581
+ object-fit: cover;
582
+ background: var(--ms-surface-2);
583
+ flex-shrink: 0;
584
+ }
585
+ .ms-track-text { min-width: 0; }
586
+ .ms-track-title {
587
+ color: #fff;
588
+ font-size: 0.95rem;
589
+ font-weight: 500;
590
+ white-space: nowrap;
591
+ overflow: hidden;
592
+ text-overflow: ellipsis;
593
+ }
594
+ .ms-track-sub {
595
+ color: var(--ms-text-dim);
596
+ font-size: 0.82rem;
597
+ white-space: nowrap;
598
+ overflow: hidden;
599
+ text-overflow: ellipsis;
600
+ }
601
+ .ms-track-sub a { transition: color 0.12s; }
602
+ .ms-track-sub a:hover { color: #fff; text-decoration: underline; }
603
+
604
+ .ms-track-anime {
605
+ color: var(--ms-text-dim);
606
+ font-size: 0.85rem;
607
+ white-space: nowrap;
608
+ overflow: hidden;
609
+ text-overflow: ellipsis;
610
+ }
611
+ .ms-track-anime:hover { color: #fff; text-decoration: underline; }
612
+
613
+ .ms-track-tag {
614
+ display: inline-flex;
615
+ align-items: center;
616
+ padding: 3px 10px;
617
+ border-radius: 999px;
618
+ background: rgba(124, 58, 237, 0.18);
619
+ border: 1px solid rgba(124, 58, 237, 0.32);
620
+ color: #c4b5fd;
621
+ font-size: 0.7rem;
622
+ font-weight: 700;
623
+ letter-spacing: 0.04em;
624
+ width: fit-content;
625
+ }
626
+ .ms-track-tag.ed { background: rgba(190, 24, 93, 0.16); border-color: rgba(190, 24, 93, 0.4); color: #f9a8d4; }
627
+ .ms-track-tag.in { background: rgba(34, 197, 94, 0.13); border-color: rgba(34, 197, 94, 0.35); color: #86efac; }
628
+
629
+ .ms-track-like {
630
+ width: 28px; height: 28px;
631
+ color: var(--ms-text-dim);
632
+ display: flex; align-items: center; justify-content: center;
633
+ border-radius: 50%;
634
+ transition: color 0.12s, transform 0.12s, opacity 0.12s;
635
+ opacity: 0;
636
+ }
637
+ .ms-track-like svg { width: 16px; height: 16px; }
638
+ .ms-track:hover .ms-track-like { opacity: 1; }
639
+ .ms-track-like.is-liked { color: var(--ms-accent-hot); opacity: 1; }
640
+ .ms-track-like.is-liked svg { fill: currentColor; }
641
+ .ms-track-like:hover { color: #fff; transform: scale(1.1); }
642
+
643
+ .ms-search-tabs {
644
+ display: flex;
645
+ gap: 8px;
646
+ padding: 18px 0 6px;
647
+ flex-wrap: wrap;
648
+ }
649
+ .ms-tab {
650
+ padding: 8px 18px;
651
+ border-radius: 999px;
652
+ background: var(--ms-surface-2);
653
+ color: #fff;
654
+ font-size: 0.86rem;
655
+ font-weight: 600;
656
+ transition: background 0.15s, color 0.15s;
657
+ cursor: pointer;
658
+ }
659
+ .ms-tab:hover { background: var(--ms-surface-3); }
660
+ .ms-tab.active { background: #fff; color: #000; }
661
+
662
+ .ms-search-block { margin-bottom: 36px; }
663
+
664
+ .ms-top-result {
665
+ display: grid;
666
+ grid-template-columns: 1.05fr 1.6fr;
667
+ gap: 24px;
668
+ align-items: stretch;
669
+ margin: 6px 0 30px;
670
+ }
671
+ @media (max-width: 900px) {
672
+ .ms-top-result { grid-template-columns: 1fr; }
673
+ }
674
+
675
+ .ms-top-card {
676
+ display: grid;
677
+ grid-template-columns: 120px 1fr;
678
+ grid-template-rows: auto auto auto 1fr;
679
+ grid-template-areas:
680
+ "cover name"
681
+ "cover tag"
682
+ "cover ."
683
+ "play .";
684
+ column-gap: 22px;
685
+ row-gap: 8px;
686
+ background: var(--ms-surface);
687
+ border-radius: 10px;
688
+ padding: 22px;
689
+ transition: background 0.2s;
690
+ cursor: pointer;
691
+ position: relative;
692
+ min-height: 220px;
693
+ overflow: hidden;
694
+ }
695
+ .ms-top-card:hover { background: var(--ms-surface-2); }
696
+ .ms-top-card:hover .ms-top-play { opacity: 1; transform: translateY(0); }
697
+
698
+ .ms-top-cover {
699
+ grid-area: cover;
700
+ width: 120px; height: 120px;
701
+ border-radius: 6px;
702
+ object-fit: cover;
703
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.6);
704
+ align-self: start;
705
+ }
706
+ .ms-top-card.is-artist .ms-top-cover { border-radius: 50%; }
707
+ .ms-top-card .ms-top-name { grid-area: name; align-self: end; margin: 0; }
708
+ .ms-top-card .ms-top-tag { grid-area: tag; justify-self: start; }
709
+
710
+ .ms-top-play {
711
+ grid-area: play;
712
+ justify-self: end;
713
+ align-self: end;
714
+ width: 56px; height: 56px;
715
+ border-radius: 50%;
716
+ background: var(--ms-accent);
717
+ color: #fff;
718
+ display: flex; align-items: center; justify-content: center;
719
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.55);
720
+ opacity: 0;
721
+ transform: translateY(8px);
722
+ transition: opacity 0.18s, transform 0.18s, background 0.18s;
723
+ z-index: 2;
724
+ }
725
+ .ms-top-play:hover { background: var(--ms-accent-hot); transform: translateY(0) scale(1.06); }
726
+ .ms-top-play svg { width: 26px; height: 26px; transform: translateX(1px); }
727
+
728
+ @media (max-width: 720px) {
729
+ .ms-top-card {
730
+ grid-template-columns: 84px 1fr;
731
+ grid-template-areas:
732
+ "cover name"
733
+ "cover tag"
734
+ "play play";
735
+ column-gap: 16px;
736
+ padding: 16px;
737
+ min-height: auto;
738
+ }
739
+ .ms-top-cover { width: 84px; height: 84px; }
740
+ .ms-top-card .ms-top-name { font-size: 1.4rem; }
741
+ .ms-top-play { opacity: 1; transform: none; width: 48px; height: 48px; margin-top: 6px; }
742
+ .ms-top-play svg { width: 22px; height: 22px; }
743
+ }
744
+ .ms-top-name {
745
+ font-size: 2rem;
746
+ font-weight: 900;
747
+ color: #fff;
748
+ margin-bottom: 10px;
749
+ line-height: 1.05;
750
+ letter-spacing: -0.02em;
751
+ overflow: hidden;
752
+ display: -webkit-box;
753
+ -webkit-line-clamp: 2;
754
+ -webkit-box-orient: vertical;
755
+ line-clamp: 2;
756
+ }
757
+ .ms-top-tag {
758
+ display: inline-flex;
759
+ align-items: center;
760
+ padding: 5px 12px;
761
+ background: rgba(0, 0, 0, 0.55);
762
+ border-radius: 4px;
763
+ color: #fff;
764
+ font-size: 0.72rem;
765
+ font-weight: 700;
766
+ text-transform: uppercase;
767
+ letter-spacing: 0.08em;
768
+ }
769
+
770
+ .ms-top-songs {
771
+ background: transparent;
772
+ border-radius: 8px;
773
+ padding: 0;
774
+ }
775
+ .ms-top-songs h3 {
776
+ font-size: 1.5rem;
777
+ font-weight: 800;
778
+ color: #fff;
779
+ margin: 0 0 12px;
780
+ letter-spacing: -0.01em;
781
+ }
782
+
783
+ .ms-songlist { display: flex; flex-direction: column; }
784
+ .ms-song-row {
785
+ display: grid;
786
+ grid-template-columns: 40px 1fr 28px;
787
+ gap: 14px;
788
+ align-items: center;
789
+ padding: 8px 12px;
790
+ border-radius: 6px;
791
+ cursor: pointer;
792
+ transition: background 0.12s;
793
+ }
794
+ .ms-song-row:hover { background: var(--ms-surface-2); }
795
+ .ms-song-row.is-playing .ms-song-title { color: var(--ms-accent-hot); }
796
+
797
+ .ms-song-cover {
798
+ width: 40px; height: 40px;
799
+ border-radius: 4px;
800
+ object-fit: cover;
801
+ background: var(--ms-surface-2);
802
+ flex-shrink: 0;
803
+ position: relative;
804
+ }
805
+ .ms-song-cover-wrap {
806
+ position: relative;
807
+ width: 40px; height: 40px;
808
+ flex-shrink: 0;
809
+ }
810
+ .ms-song-cover-wrap .ms-song-play {
811
+ position: absolute;
812
+ inset: 0;
813
+ display: none;
814
+ align-items: center;
815
+ justify-content: center;
816
+ background: rgba(0, 0, 0, 0.6);
817
+ color: #fff;
818
+ border-radius: 4px;
819
+ }
820
+ .ms-song-cover-wrap .ms-song-play svg { width: 16px; height: 16px; }
821
+ .ms-song-row:hover .ms-song-play { display: flex; }
822
+
823
+ .ms-song-text { min-width: 0; }
824
+ .ms-song-title {
825
+ color: #fff;
826
+ font-size: 0.92rem;
827
+ font-weight: 500;
828
+ white-space: nowrap;
829
+ overflow: hidden;
830
+ text-overflow: ellipsis;
831
+ }
832
+ .ms-song-sub {
833
+ color: var(--ms-text-dim);
834
+ font-size: 0.8rem;
835
+ white-space: nowrap;
836
+ overflow: hidden;
837
+ text-overflow: ellipsis;
838
+ }
839
+ .ms-song-sub a:hover { color: #fff; text-decoration: underline; }
840
+
841
+ .ms-song-like {
842
+ width: 28px; height: 28px;
843
+ color: var(--ms-text-dim);
844
+ display: flex; align-items: center; justify-content: center;
845
+ border-radius: 50%;
846
+ transition: color 0.12s, opacity 0.12s, transform 0.12s;
847
+ opacity: 0;
848
+ }
849
+ .ms-song-like svg { width: 16px; height: 16px; }
850
+ .ms-song-row:hover .ms-song-like { opacity: 1; }
851
+ .ms-song-like.is-liked { color: var(--ms-accent-hot); opacity: 1; }
852
+ .ms-song-like.is-liked svg { fill: currentColor; }
853
+
854
+ .player-bar {
855
+ position: fixed;
856
+ left: 0; right: 0; bottom: 0;
857
+ height: 90px;
858
+ background: #000;
859
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
860
+ display: grid;
861
+ grid-template-columns: minmax(180px, 30%) 1fr minmax(180px, 30%);
862
+ align-items: center;
863
+ padding: 0 16px;
864
+ gap: 16px;
865
+ z-index: 100;
866
+ box-shadow: 0 -10px 32px rgba(0, 0, 0, 0.6);
867
+ }
868
+ .player-bar[data-empty="1"] .pb-controls,
869
+ .player-bar[data-empty="1"] .pb-progress,
870
+ .player-bar[data-empty="1"] .pb-like { opacity: 0.5; pointer-events: none; }
871
+
872
+ .pb-track {
873
+ display: flex;
874
+ align-items: center;
875
+ gap: 14px;
876
+ min-width: 0;
877
+ }
878
+ .pb-cover {
879
+ width: 56px; height: 56px;
880
+ border-radius: 4px;
881
+ background: var(--ms-surface-2);
882
+ object-fit: cover;
883
+ flex-shrink: 0;
884
+ }
885
+
886
+ .pb-cover:not([src]),
887
+ .pb-cover[src=""],
888
+ .pb-cover.is-failed {
889
+ visibility: hidden;
890
+ }
891
+ .player-bar[data-empty="1"] .pb-cover {
892
+ visibility: hidden;
893
+ }
894
+
895
+ .pb-cover-fallback {
896
+ position: absolute;
897
+ inset: 0;
898
+ display: none;
899
+ align-items: center;
900
+ justify-content: center;
901
+ background: linear-gradient(135deg, #432470 0%, #1a0f33 100%);
902
+ color: rgba(255, 255, 255, 0.7);
903
+ border-radius: 4px;
904
+ }
905
+ .pb-cover-fallback svg { width: 22px; height: 22px; }
906
+ .pb-cover-btn:not(.has-cover) .pb-cover-fallback,
907
+ .player-bar[data-empty="1"] .pb-cover-fallback { display: flex; }
908
+
909
+ .pb-dismiss {
910
+ width: 28px; height: 28px;
911
+ color: var(--ms-text-mute);
912
+ display: flex; align-items: center; justify-content: center;
913
+ border-radius: 50%;
914
+ transition: color 0.15s, background 0.15s;
915
+ flex-shrink: 0;
916
+ }
917
+ .pb-dismiss svg { width: 14px; height: 14px; }
918
+ .pb-dismiss:hover { color: #fff; background: rgba(255, 255, 255, 0.08); }
919
+ .pb-meta { min-width: 0; flex: 1; }
920
+ .pb-title {
921
+ color: #fff;
922
+ font-size: 0.9rem;
923
+ font-weight: 500;
924
+ white-space: nowrap;
925
+ overflow: hidden;
926
+ text-overflow: ellipsis;
927
+ display: block;
928
+ transition: color 0.12s;
929
+ background: transparent;
930
+ border: 0;
931
+ padding: 0;
932
+ cursor: pointer;
933
+ text-align: left;
934
+ width: 100%;
935
+ font-family: inherit;
936
+ }
937
+ .pb-title:hover { text-decoration: underline; }
938
+ .pb-artist {
939
+ color: var(--ms-text-dim);
940
+ font-size: 0.74rem;
941
+ white-space: nowrap;
942
+ overflow: hidden;
943
+ text-overflow: ellipsis;
944
+ display: block;
945
+ transition: color 0.12s;
946
+ }
947
+ .pb-artist:hover { color: #fff; text-decoration: underline; }
948
+ .pb-like {
949
+ width: 32px; height: 32px;
950
+ color: var(--ms-text-dim);
951
+ display: flex; align-items: center; justify-content: center;
952
+ border-radius: 50%;
953
+ transition: color 0.12s, transform 0.12s;
954
+ flex-shrink: 0;
955
+ }
956
+ .pb-like svg { width: 18px; height: 18px; }
957
+ .pb-like:hover { color: #fff; transform: scale(1.1); }
958
+ .pb-like.is-liked { color: var(--ms-accent-hot); }
959
+ .pb-like.is-liked svg { fill: currentColor; }
960
+
961
+ .pb-center {
962
+ display: flex;
963
+ flex-direction: column;
964
+ align-items: center;
965
+ gap: 6px;
966
+ width: 100%;
967
+ max-width: 720px;
968
+ margin: 0 auto;
969
+ }
970
+
971
+ .pb-controls {
972
+ display: flex;
973
+ align-items: center;
974
+ justify-content: center;
975
+ gap: 18px;
976
+ }
977
+
978
+ .pb-btn {
979
+ width: 32px; height: 32px;
980
+ color: var(--ms-text-dim);
981
+ display: flex; align-items: center; justify-content: center;
982
+ border-radius: 50%;
983
+ transition: color 0.12s, transform 0.12s;
984
+ }
985
+ .pb-btn svg { width: 20px; height: 20px; }
986
+ .pb-btn:hover { color: #fff; transform: scale(1.08); }
987
+ .pb-btn.is-on { color: var(--ms-accent-hot); }
988
+ .pb-btn.is-on::after {
989
+ content: "";
990
+ width: 4px; height: 4px;
991
+ background: var(--ms-accent-hot);
992
+ border-radius: 50%;
993
+ position: absolute;
994
+ margin-top: 30px;
995
+ }
996
+
997
+ .pb-play {
998
+ width: 38px; height: 38px;
999
+ border-radius: 50%;
1000
+ background: #fff;
1001
+ color: #000;
1002
+ display: flex; align-items: center; justify-content: center;
1003
+ transition: transform 0.12s, background 0.15s;
1004
+ }
1005
+ .pb-play svg { width: 22px; height: 22px; }
1006
+ .pb-play:hover { transform: scale(1.07); background: #f1eaff; }
1007
+ .pb-play:active { transform: scale(0.96); }
1008
+ .player-bar[data-empty="1"] .pb-play { opacity: 0.6; }
1009
+
1010
+ .pb-progress {
1011
+ display: flex;
1012
+ align-items: center;
1013
+ gap: 12px;
1014
+ width: 100%;
1015
+ padding: 0 12px;
1016
+ }
1017
+ .pb-time {
1018
+ font-size: 0.7rem;
1019
+ color: var(--ms-text-dim);
1020
+ font-variant-numeric: tabular-nums;
1021
+ width: 38px;
1022
+ text-align: center;
1023
+ flex-shrink: 0;
1024
+ }
1025
+ .pb-bar {
1026
+ flex: 1;
1027
+ height: 4px;
1028
+ background: rgba(255, 255, 255, 0.18);
1029
+ border-radius: 2px;
1030
+ position: relative;
1031
+ cursor: pointer;
1032
+ transition: height 0.12s;
1033
+ }
1034
+ .pb-bar:hover { height: 6px; }
1035
+ .pb-bar:hover .pb-bar-fill { background: var(--ms-accent-hot); }
1036
+ .pb-bar:hover .pb-bar-knob { opacity: 1; }
1037
+ .pb-bar-fill {
1038
+ position: absolute;
1039
+ left: 0; top: 0; bottom: 0;
1040
+ width: 0%;
1041
+ background: #fff;
1042
+ border-radius: 2px;
1043
+ transition: background 0.12s;
1044
+ }
1045
+ .pb-bar-knob {
1046
+ position: absolute;
1047
+ top: 50%;
1048
+ width: 12px; height: 12px;
1049
+ background: #fff;
1050
+ border-radius: 50%;
1051
+ transform: translate(-50%, -50%);
1052
+ opacity: 0;
1053
+ transition: opacity 0.12s;
1054
+ pointer-events: none;
1055
+ }
1056
+
1057
+ .pb-right {
1058
+ display: flex;
1059
+ align-items: center;
1060
+ justify-content: flex-end;
1061
+ gap: 8px;
1062
+ }
1063
+ .pb-mobile-play { display: none; }
1064
+ .pb-vol {
1065
+ width: 100px;
1066
+ height: 4px;
1067
+ background: rgba(255, 255, 255, 0.18);
1068
+ border-radius: 2px;
1069
+ position: relative;
1070
+ cursor: pointer;
1071
+ }
1072
+ .pb-vol:hover .pb-bar-fill { background: var(--ms-accent-hot); }
1073
+ .pb-vol:hover .pb-bar-knob { opacity: 1; }
1074
+
1075
+ .ms-sidebar-backdrop {
1076
+ display: none;
1077
+ position: fixed;
1078
+ inset: 0;
1079
+ background: rgba(0, 0, 0, 0.6);
1080
+ z-index: 60;
1081
+ opacity: 0;
1082
+ transition: opacity 0.22s ease;
1083
+ }
1084
+ .ms-sidebar-backdrop.active {
1085
+ display: block;
1086
+ opacity: 1;
1087
+ }
1088
+
1089
+ @media (hover: none), (max-width: 800px) {
1090
+ .ms-card-play {
1091
+ opacity: 1;
1092
+ transform: translateY(0);
1093
+ width: 44px; height: 44px;
1094
+ right: 8px; bottom: 8px;
1095
+ }
1096
+ .ms-card-play svg { width: 18px; height: 18px; }
1097
+ .ms-track-like { opacity: 1; }
1098
+ .ms-song-like { opacity: 1; }
1099
+ .ms-song-cover-wrap .ms-song-play { display: none; }
1100
+ }
1101
+
1102
+ @media (max-width: 1024px) {
1103
+ .music-app { grid-template-columns: 1fr; }
1104
+ .music-sidebar {
1105
+ position: fixed;
1106
+ top: var(--header-h);
1107
+ left: 0;
1108
+ bottom: 92px;
1109
+ width: 280px;
1110
+ z-index: 80;
1111
+ margin: 8px;
1112
+ transform: translateX(-110%);
1113
+ transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
1114
+ pointer-events: none;
1115
+ }
1116
+ .music-sidebar.open {
1117
+ transform: translateX(0);
1118
+ pointer-events: auto;
1119
+ }
1120
+
1121
+ .music-sidebar.open .ms-link,
1122
+ .music-sidebar.open .ms-year-grid a,
1123
+ .music-sidebar.open .ms-block-title { pointer-events: auto; }
1124
+
1125
+ .music-sidebar .ms-link { padding: 12px 14px; font-size: 0.98rem; }
1126
+
1127
+ .music-sidebar .ms-link,
1128
+ .music-sidebar .ms-year-grid a,
1129
+ .ms-sidebar-toggle {
1130
+ touch-action: manipulation;
1131
+ -webkit-tap-highlight-color: rgba(167, 139, 250, 0.18);
1132
+ }
1133
+ .ms-sidebar-toggle { display: flex; }
1134
+ .ms-sidebar-backdrop { z-index: 70; }
1135
+ }
1136
+
1137
+ @media (max-width: 800px) {
1138
+ .ms-album-hero {
1139
+ flex-direction: column;
1140
+ align-items: flex-start;
1141
+ gap: 18px;
1142
+ padding: 16px 0;
1143
+ }
1144
+ .ms-album-cover { width: 180px; height: 180px; align-self: center; }
1145
+ .ms-album-info { align-items: flex-start; text-align: left; width: 100%; }
1146
+ .ms-tracks-head, .ms-track {
1147
+ grid-template-columns: 36px 1fr 56px;
1148
+ }
1149
+ .ms-tracks-head .ms-th-anime, .ms-tracks-head .ms-th-tag,
1150
+ .ms-track .ms-track-anime, .ms-track .ms-track-tag-cell { display: none; }
1151
+ .ms-view { padding: 0 14px 24px; }
1152
+ .ms-topbar { padding: 12px 14px; gap: 8px; }
1153
+ .ms-search { max-width: none; }
1154
+ .ms-card-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
1155
+ .ms-top-name { font-size: 1.5rem; }
1156
+ }
1157
+
1158
+ @media (max-width: 720px) {
1159
+ .player-bar {
1160
+ height: 64px;
1161
+ grid-template-columns: 1fr auto;
1162
+ padding: 0 10px;
1163
+ gap: 10px;
1164
+ bottom: 0;
1165
+ border-radius: 0;
1166
+ }
1167
+ .pb-cover-btn { width: 44px; height: 44px; }
1168
+ .pb-cover { width: 44px; height: 44px; }
1169
+ .pb-title { font-size: 0.86rem; }
1170
+ .pb-artist { font-size: 0.72rem; }
1171
+ .pb-like, .pb-center { display: none; }
1172
+ .pb-mobile-play {
1173
+ display: flex;
1174
+ width: 40px; height: 40px;
1175
+ color: #fff;
1176
+ background: transparent;
1177
+ border-radius: 50%;
1178
+ }
1179
+ .pb-mobile-play svg { width: 24px; height: 24px; }
1180
+ .pb-right .pb-btn:not(#pb-play-mobile), .pb-right .pb-vol { display: none; }
1181
+ .music-app {
1182
+ bottom: 64px;
1183
+ padding: 6px;
1184
+ gap: 6px;
1185
+ top: 10px;
1186
+ }
1187
+ .music-sidebar { bottom: 64px; }
1188
+ .player-bar[data-empty="1"] { display: none; }
1189
+ body.music-body:has(.player-bar[data-empty="1"]) .music-app,
1190
+ body.music-body:has(.player-bar[data-empty="1"]) .music-sidebar {
1191
+ bottom: 0;
1192
+ }
1193
+ .ms-topbar {
1194
+ padding: 12px 12px;
1195
+ gap: 8px;
1196
+ }
1197
+ .ms-search {
1198
+ max-width: none;
1199
+ }
1200
+ .ms-search input {
1201
+ padding: 10px 38px 10px 38px;
1202
+ font-size: 0.84rem;
1203
+ }
1204
+ .ms-sidebar-toggle {
1205
+ width: 34px;
1206
+ height: 34px;
1207
+ }
1208
+ }
1209
+
1210
+ @media (max-width: 480px) {
1211
+ .ms-card-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
1212
+ .ms-section-title h2 { font-size: 1.2rem; }
1213
+ .ms-top-name { font-size: 1.25rem; }
1214
+ .ms-years-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
1215
+ .ms-year-num { font-size: 1.8rem; }
1216
+ }
1217
+
1218
+ .ms-home-hero {
1219
+ margin: 18px 0 28px;
1220
+ }
1221
+ .ms-home-greet {
1222
+ font-size: 2rem;
1223
+ font-weight: 900;
1224
+ letter-spacing: -0.02em;
1225
+ color: #fff;
1226
+ margin: 0 0 18px;
1227
+ }
1228
+ .ms-quick-grid {
1229
+ display: grid;
1230
+ grid-template-columns: repeat(3, 1fr);
1231
+ gap: 10px;
1232
+ }
1233
+ @media (max-width: 900px) { .ms-quick-grid { grid-template-columns: repeat(2, 1fr); } }
1234
+ @media (max-width: 540px) { .ms-quick-grid { grid-template-columns: 1fr; } }
1235
+
1236
+ .ms-quick-tile {
1237
+ display: flex;
1238
+ align-items: center;
1239
+ gap: 12px;
1240
+ background: rgba(255, 255, 255, 0.06);
1241
+ border-radius: 6px;
1242
+ padding: 0 14px 0 0;
1243
+ height: 64px;
1244
+ overflow: hidden;
1245
+ position: relative;
1246
+ transition: background 0.15s;
1247
+ }
1248
+ .ms-quick-tile:hover { background: rgba(255, 255, 255, 0.12); }
1249
+ .ms-quick-cover {
1250
+ width: 64px; height: 64px;
1251
+ object-fit: cover;
1252
+ flex-shrink: 0;
1253
+ background: var(--ms-surface-2);
1254
+ }
1255
+ .ms-quick-name {
1256
+ flex: 1;
1257
+ font-weight: 700;
1258
+ color: #fff;
1259
+ font-size: 0.9rem;
1260
+ overflow: hidden;
1261
+ text-overflow: ellipsis;
1262
+ white-space: nowrap;
1263
+ min-width: 0;
1264
+ }
1265
+ .ms-quick-play {
1266
+ width: 44px; height: 44px;
1267
+ border-radius: 50%;
1268
+ background: var(--ms-accent);
1269
+ color: #fff;
1270
+ display: flex; align-items: center; justify-content: center;
1271
+ flex-shrink: 0;
1272
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45);
1273
+ opacity: 0;
1274
+ transform: translateX(8px);
1275
+ transition: opacity 0.15s, transform 0.15s, background 0.15s;
1276
+ }
1277
+ .ms-quick-tile:hover .ms-quick-play { opacity: 1; transform: translateX(0); }
1278
+ .ms-quick-play:hover { background: var(--ms-accent-hot); transform: scale(1.06); }
1279
+ .ms-quick-play svg { width: 18px; height: 18px; transform: translateX(1px); }
1280
+ .ms-quick-empty {
1281
+ grid-column: 1 / -1;
1282
+ color: var(--ms-text-dim);
1283
+ background: var(--ms-surface);
1284
+ border-radius: 6px;
1285
+ padding: 18px;
1286
+ font-size: 0.92rem;
1287
+ }
1288
+
1289
+ .ms-years-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 18px; }
1290
+ .ms-year-card {
1291
+ display: block;
1292
+ background: var(--ms-surface);
1293
+ padding: 14px;
1294
+ border-radius: 8px;
1295
+ transition: background 0.18s;
1296
+ text-align: left;
1297
+ }
1298
+ .ms-year-card:hover { background: var(--ms-surface-2); }
1299
+ .ms-year-cover {
1300
+ width: 100%;
1301
+ aspect-ratio: 1 / 1;
1302
+ border-radius: 6px;
1303
+ display: flex;
1304
+ align-items: center;
1305
+ justify-content: center;
1306
+ margin-bottom: 12px;
1307
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.55);
1308
+ overflow: hidden;
1309
+ position: relative;
1310
+ }
1311
+ .ms-year-cover::after {
1312
+ content: "";
1313
+ position: absolute;
1314
+ inset: 0;
1315
+ background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.12), transparent 55%);
1316
+ pointer-events: none;
1317
+ }
1318
+ .ms-year-num {
1319
+ font-size: 2.4rem;
1320
+ font-weight: 900;
1321
+ letter-spacing: -0.04em;
1322
+ color: #fff;
1323
+ text-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
1324
+ }
1325
+
1326
+ .pb-cover-btn {
1327
+ position: relative;
1328
+ width: 56px; height: 56px;
1329
+ border-radius: 4px;
1330
+ overflow: hidden;
1331
+ flex-shrink: 0;
1332
+ background: transparent;
1333
+ cursor: pointer;
1334
+ display: block;
1335
+ }
1336
+ .pb-cover-btn .pb-cover { width: 100%; height: 100%; display: block; }
1337
+ .pb-cover-overlay {
1338
+ position: absolute;
1339
+ inset: 0;
1340
+ display: flex;
1341
+ align-items: center;
1342
+ justify-content: center;
1343
+ background: rgba(0, 0, 0, 0.55);
1344
+ color: #fff;
1345
+ opacity: 0;
1346
+ transition: opacity 0.15s;
1347
+ }
1348
+ .pb-cover-overlay svg { width: 18px; height: 18px; }
1349
+ .pb-cover-btn:hover .pb-cover-overlay { opacity: 1; }
1350
+ .player-bar[data-empty="1"] .pb-cover-btn { pointer-events: none; }
1351
+
1352
+ .np-modal {
1353
+ position: fixed;
1354
+ inset: 0;
1355
+ z-index: 10001;
1356
+ color: #fff;
1357
+ overflow: hidden;
1358
+ animation: npFadeIn 0.22s ease-out;
1359
+ }
1360
+ .np-modal[hidden] { display: none; }
1361
+ body.np-open { overflow: hidden; }
1362
+ body.np-open .float-nav { display: none !important; }
1363
+
1364
+ body.np-open #header { display: none !important; }
1365
+ body.np-open .np-modal { top: 0; }
1366
+ @keyframes npFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
1367
+
1368
+ .np-bg {
1369
+ position: absolute;
1370
+ inset: 0;
1371
+ background-size: cover;
1372
+ background-position: center;
1373
+ filter: blur(38px) saturate(150%) brightness(0.6);
1374
+ transform: scale(1.2);
1375
+ z-index: 0;
1376
+ transition: filter 0.35s ease;
1377
+ }
1378
+ .np-bg::after {
1379
+ content: "";
1380
+ position: absolute;
1381
+ inset: 0;
1382
+ background: linear-gradient(180deg, rgba(10,5,25,0.55) 0%, rgba(10,5,25,0.85) 60%, #07050d 100%);
1383
+ transition: opacity 0.35s ease;
1384
+ }
1385
+
1386
+ body.np-lyrics-open .np-bg {
1387
+ filter: blur(46px) saturate(140%) brightness(0.45);
1388
+ }
1389
+ body.np-lyrics-open .np-bg::after {
1390
+ background: linear-gradient(180deg, rgba(8,4,20,0.72) 0%, rgba(8,4,20,0.88) 60%, #050208 100%);
1391
+ opacity: 1;
1392
+ }
1393
+
1394
+ body.np-lyrics-open .np-stage { box-shadow: none; background: transparent; }
1395
+ body.np-lyrics-open .np-cover,
1396
+ body.np-lyrics-open .np-cover-fallback { display: none !important; }
1397
+
1398
+ .np-inner {
1399
+ position: relative;
1400
+ z-index: 1;
1401
+ height: 100vh;
1402
+ height: 100dvh;
1403
+ max-width: 520px;
1404
+ margin: 0 auto;
1405
+ padding: 14px 22px 22px;
1406
+ display: grid;
1407
+ grid-template-rows: auto minmax(0, 1fr) auto auto auto auto;
1408
+ gap: 14px;
1409
+ }
1410
+ @media (min-width: 900px) {
1411
+ .np-inner { max-width: 560px; padding: 18px 28px 26px; gap: 16px; }
1412
+ }
1413
+ @media (max-width: 480px) {
1414
+ .np-inner { padding: 10px 16px 18px; gap: 10px; }
1415
+ }
1416
+
1417
+ .np-header {
1418
+ display: flex;
1419
+ align-items: center;
1420
+ justify-content: space-between;
1421
+ gap: 12px;
1422
+ }
1423
+ .np-icon-btn {
1424
+ min-width: 36px; height: 36px;
1425
+ display: flex; align-items: center; justify-content: center;
1426
+ gap: 6px;
1427
+ padding: 0 6px;
1428
+ color: #fff;
1429
+ border-radius: 18px;
1430
+ transition: background 0.15s;
1431
+ }
1432
+ .np-icon-btn:hover { background: rgba(255, 255, 255, 0.12); }
1433
+ .np-icon-btn svg { width: 22px; height: 22px; }
1434
+
1435
+ .np-back-btn span {
1436
+ font-size: 0.85rem;
1437
+ font-weight: 600;
1438
+ padding-right: 6px;
1439
+ }
1440
+ @media (max-width: 600px) {
1441
+ .np-back-btn span { display: none; }
1442
+ .np-back-btn { padding: 0; min-width: 36px; border-radius: 50%; }
1443
+ }
1444
+ .np-source { text-align: center; min-width: 0; flex: 1; }
1445
+ .np-eyebrow {
1446
+ font-size: 0.66rem;
1447
+ text-transform: uppercase;
1448
+ letter-spacing: 0.18em;
1449
+ color: rgba(255, 255, 255, 0.7);
1450
+ font-weight: 700;
1451
+ margin-bottom: 2px;
1452
+ }
1453
+ .np-source-name {
1454
+ font-size: 0.86rem;
1455
+ font-weight: 600;
1456
+ color: #fff;
1457
+ text-decoration: none;
1458
+ overflow: hidden;
1459
+ text-overflow: ellipsis;
1460
+ white-space: nowrap;
1461
+ display: block;
1462
+ }
1463
+ .np-source-name:hover { text-decoration: underline; }
1464
+
1465
+ .np-stage {
1466
+ position: relative;
1467
+
1468
+ width: min(100%, 70vh, 560px);
1469
+ max-width: 100%;
1470
+ aspect-ratio: 1 / 1;
1471
+ max-height: min(70vh, 560px);
1472
+ margin: 0 auto;
1473
+ border-radius: 12px;
1474
+ overflow: hidden;
1475
+ background: linear-gradient(135deg, #432470 0%, #1a0f33 100%);
1476
+ box-shadow: 0 26px 60px rgba(0, 0, 0, 0.6);
1477
+ align-self: center;
1478
+ flex-shrink: 0;
1479
+ }
1480
+ @media (max-width: 600px) {
1481
+ .np-stage {
1482
+
1483
+ width: min(100%, 54vh, 460px);
1484
+ max-height: 54vh;
1485
+ }
1486
+ }
1487
+ @media (max-width: 380px) {
1488
+ .np-stage {
1489
+ width: min(100%, 48vh, 380px);
1490
+ max-height: 48vh;
1491
+ }
1492
+ }
1493
+
1494
+ @media (max-height: 520px) and (orientation: landscape) {
1495
+ .np-stage {
1496
+ width: min(82vh, 400px);
1497
+ max-height: 82vh;
1498
+ }
1499
+ }
1500
+ .np-cover, .np-video {
1501
+ position: absolute;
1502
+ inset: 0;
1503
+ width: 100%;
1504
+ height: 100%;
1505
+ object-fit: cover;
1506
+ display: block;
1507
+ }
1508
+ .np-video { background: #000; object-fit: contain; }
1509
+
1510
+ .np-cover[hidden], .np-video[hidden] { display: none !important; }
1511
+
1512
+ .np-cover:not([src]),
1513
+ .np-cover[src=""],
1514
+ .np-cover.is-failed { visibility: hidden; }
1515
+
1516
+ .np-cover-fallback {
1517
+ position: absolute;
1518
+ inset: 0;
1519
+ display: flex;
1520
+ align-items: center;
1521
+ justify-content: center;
1522
+ color: rgba(255, 255, 255, 0.55);
1523
+ pointer-events: none;
1524
+ }
1525
+ .np-cover-fallback svg { width: 72px; height: 72px; }
1526
+
1527
+ .np-loading {
1528
+ position: absolute;
1529
+ inset: 0;
1530
+ display: flex;
1531
+ align-items: center;
1532
+ justify-content: center;
1533
+ background: rgba(0, 0, 0, 0.45);
1534
+ backdrop-filter: blur(2px);
1535
+ -webkit-backdrop-filter: blur(2px);
1536
+ z-index: 3;
1537
+ animation: npFadeIn 0.18s ease-out;
1538
+ }
1539
+ .np-loading[hidden] { display: none; }
1540
+ .np-spinner {
1541
+ width: 56px;
1542
+ height: 56px;
1543
+ border-radius: 50%;
1544
+ border: 3px solid rgba(255, 255, 255, 0.18);
1545
+ border-top-color: var(--ms-accent-hot);
1546
+ animation: npSpin 0.85s linear infinite;
1547
+ }
1548
+ @keyframes npSpin { to { transform: rotate(360deg); } }
1549
+
1550
+ .np-panel-close {
1551
+ width: 32px; height: 32px;
1552
+ color: rgba(255, 255, 255, 0.7);
1553
+ display: flex; align-items: center; justify-content: center;
1554
+ border-radius: 50%;
1555
+ transition: background 0.15s, color 0.15s;
1556
+ flex-shrink: 0;
1557
+ }
1558
+ .np-panel-close svg { width: 16px; height: 16px; }
1559
+ .np-panel-close:hover { color: #fff; background: rgba(255, 255, 255, 0.12); }
1560
+ .np-lyrics-head {
1561
+ display: flex;
1562
+ align-items: center;
1563
+ justify-content: space-between;
1564
+ margin-bottom: 12px;
1565
+ }
1566
+ .np-lyrics-head h3 { margin: 0; }
1567
+
1568
+ .np-lyrics {
1569
+ position: absolute;
1570
+ inset: 0;
1571
+
1572
+ background: radial-gradient(120% 80% at 50% 0%, #1a0d2e 0%, #0a0612 55%, #050208 100%);
1573
+ overflow: hidden;
1574
+ padding: 18px 22px;
1575
+ display: flex;
1576
+ align-items: center;
1577
+ justify-content: center;
1578
+ }
1579
+ .np-lyrics[hidden] { display: none; }
1580
+ .np-lyrics-inner { width: 100%; max-width: 420px; }
1581
+ .np-lyrics-inner h3 {
1582
+ margin: 0 0 12px;
1583
+ font-size: 1.1rem;
1584
+ font-weight: 800;
1585
+ color: #fff;
1586
+ }
1587
+ .np-lyrics-body {
1588
+ color: rgba(255, 255, 255, 0.92);
1589
+ line-height: 1.55;
1590
+ font-size: 0.96rem;
1591
+ margin: 0 0 18px;
1592
+ }
1593
+ .np-lyrics-meta {
1594
+ display: grid;
1595
+ gap: 10px;
1596
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
1597
+ padding-top: 14px;
1598
+ }
1599
+ .np-lyrics-meta div {
1600
+ display: flex;
1601
+ justify-content: space-between;
1602
+ gap: 16px;
1603
+ font-size: 0.85rem;
1604
+ }
1605
+ .np-lyrics-meta span {
1606
+ color: rgba(255, 255, 255, 0.55);
1607
+ text-transform: uppercase;
1608
+ letter-spacing: 0.08em;
1609
+ font-weight: 700;
1610
+ font-size: 0.7rem;
1611
+ }
1612
+ .np-lyrics-meta strong {
1613
+ color: #fff;
1614
+ font-weight: 600;
1615
+ text-align: right;
1616
+ overflow: hidden;
1617
+ text-overflow: ellipsis;
1618
+ white-space: nowrap;
1619
+ max-width: 60%;
1620
+ }
1621
+
1622
+ .np-info {
1623
+ display: flex;
1624
+ align-items: center;
1625
+ gap: 16px;
1626
+ }
1627
+ .np-info-text { flex: 1; min-width: 0; }
1628
+ .np-title {
1629
+ display: block;
1630
+ color: #fff;
1631
+ font-size: 1.4rem;
1632
+ font-weight: 800;
1633
+ letter-spacing: -0.01em;
1634
+ text-decoration: none;
1635
+ overflow: hidden;
1636
+ text-overflow: ellipsis;
1637
+ white-space: nowrap;
1638
+ margin-bottom: 4px;
1639
+ }
1640
+ .np-title:hover { text-decoration: underline; }
1641
+ .np-artist {
1642
+ display: block;
1643
+ color: rgba(255, 255, 255, 0.75);
1644
+ font-size: 0.95rem;
1645
+ text-decoration: none;
1646
+ overflow: hidden;
1647
+ text-overflow: ellipsis;
1648
+ white-space: nowrap;
1649
+ }
1650
+ .np-artist:hover { color: #fff; text-decoration: underline; }
1651
+ .np-like {
1652
+ width: 40px; height: 40px;
1653
+ color: rgba(255, 255, 255, 0.85);
1654
+ display: flex; align-items: center; justify-content: center;
1655
+ border-radius: 50%;
1656
+ transition: color 0.15s, transform 0.15s;
1657
+ flex-shrink: 0;
1658
+ }
1659
+ .np-like svg { width: 24px; height: 24px; }
1660
+ .np-like:hover { color: #fff; transform: scale(1.08); }
1661
+ .np-like.is-liked { color: var(--ms-accent-hot); }
1662
+ .np-like.is-liked svg { fill: currentColor; }
1663
+
1664
+ .np-progress { display: grid; gap: 6px; }
1665
+ .np-bar {
1666
+ position: relative;
1667
+ height: 4px;
1668
+ background: rgba(255, 255, 255, 0.18);
1669
+ border-radius: 2px;
1670
+ cursor: pointer;
1671
+ transition: height 0.12s;
1672
+ }
1673
+ .np-bar:hover { height: 6px; }
1674
+ .np-bar-fill {
1675
+ position: absolute;
1676
+ left: 0; top: 0; bottom: 0;
1677
+ width: 0;
1678
+ background: #fff;
1679
+ border-radius: 2px;
1680
+ }
1681
+ .np-bar:hover .np-bar-fill { background: var(--ms-accent-hot); }
1682
+ .np-bar-knob {
1683
+ position: absolute;
1684
+ top: 50%;
1685
+ width: 12px; height: 12px;
1686
+ background: #fff;
1687
+ border-radius: 50%;
1688
+ transform: translate(-50%, -50%);
1689
+ opacity: 0;
1690
+ transition: opacity 0.12s;
1691
+ }
1692
+ .np-bar:hover .np-bar-knob { opacity: 1; }
1693
+ .np-times {
1694
+ display: flex;
1695
+ justify-content: space-between;
1696
+ color: rgba(255, 255, 255, 0.7);
1697
+ font-size: 0.72rem;
1698
+ font-variant-numeric: tabular-nums;
1699
+ }
1700
+
1701
+ .np-controls {
1702
+ display: flex;
1703
+ align-items: center;
1704
+ justify-content: space-between;
1705
+ padding: 0 4px;
1706
+ }
1707
+ .np-ctl {
1708
+ width: 44px; height: 44px;
1709
+ color: rgba(255, 255, 255, 0.85);
1710
+ display: flex; align-items: center; justify-content: center;
1711
+ border-radius: 50%;
1712
+ transition: color 0.15s, transform 0.15s;
1713
+ position: relative;
1714
+ }
1715
+ .np-ctl svg { width: 26px; height: 26px; }
1716
+ .np-ctl:hover { color: #fff; transform: scale(1.08); }
1717
+ .np-ctl.is-on { color: var(--ms-accent-hot); }
1718
+ .np-ctl.is-on::after {
1719
+ content: "";
1720
+ position: absolute;
1721
+ bottom: 6px;
1722
+ left: 50%;
1723
+ transform: translateX(-50%);
1724
+ width: 4px; height: 4px;
1725
+ background: var(--ms-accent-hot);
1726
+ border-radius: 50%;
1727
+ }
1728
+ .np-play-big {
1729
+ width: 64px; height: 64px;
1730
+ border-radius: 50%;
1731
+ background: #fff;
1732
+ color: #000;
1733
+ display: flex; align-items: center; justify-content: center;
1734
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
1735
+ transition: transform 0.12s;
1736
+ }
1737
+ .np-play-big svg { width: 30px; height: 30px; transform: translateX(1px); }
1738
+ .np-play-big:hover { transform: scale(1.06); }
1739
+ .np-play-big:active { transform: scale(0.96); }
1740
+
1741
+ .np-tools {
1742
+ display: flex;
1743
+ align-items: center;
1744
+ justify-content: center;
1745
+ gap: 12px;
1746
+ padding-top: 4px;
1747
+ }
1748
+ .np-tool {
1749
+ display: inline-flex;
1750
+ align-items: center;
1751
+ gap: 8px;
1752
+ padding: 9px 18px;
1753
+ border-radius: 999px;
1754
+ background: rgba(255, 255, 255, 0.10);
1755
+ color: rgba(255, 255, 255, 0.92);
1756
+ font-weight: 600;
1757
+ font-size: 0.84rem;
1758
+ letter-spacing: 0.01em;
1759
+ transition: background 0.15s, color 0.15s, transform 0.12s;
1760
+ }
1761
+ .np-tool svg { width: 18px; height: 18px; }
1762
+ .np-tool:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
1763
+ .np-tool.is-on {
1764
+ background: var(--ms-accent);
1765
+ color: #fff;
1766
+ }
1767
+ .np-tool.is-on:hover { background: var(--ms-accent-hot); }
1768
+ .np-tool.is-disabled { opacity: 0.45; pointer-events: none; }
1769
+
1770
+ @media (min-width: 720px) {
1771
+ .np-inner { max-width: 620px; padding: 28px 32px 36px; gap: 22px; }
1772
+ .np-title { font-size: 1.7rem; }
1773
+ }
1774
+
1775
+ #toast-container {
1776
+ position: fixed;
1777
+ bottom: 110px;
1778
+ left: 50%;
1779
+ transform: translateX(-50%);
1780
+ z-index: 300;
1781
+ display: flex;
1782
+ flex-direction: column;
1783
+ align-items: center;
1784
+ gap: 8px;
1785
+ pointer-events: none;
1786
+ }
1787
+ .kv-toast {
1788
+ background: rgba(20, 14, 36, 0.96);
1789
+ color: #fff;
1790
+ border: 1px solid rgba(255, 255, 255, 0.10);
1791
+ padding: 10px 18px;
1792
+ border-radius: 999px;
1793
+ font-size: 0.85rem;
1794
+ font-weight: 600;
1795
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
1796
+ opacity: 0;
1797
+ transform: translateY(8px);
1798
+ transition: opacity 0.2s, transform 0.2s;
1799
+ }
1800
+ .kv-toast.show { opacity: 1; transform: none; }
1801
+
1802
+ .np-queue {
1803
+ position: absolute;
1804
+ inset: 0;
1805
+ background: rgba(8, 4, 18, 0.92);
1806
+ backdrop-filter: blur(28px);
1807
+ -webkit-backdrop-filter: blur(28px);
1808
+ border-radius: inherit;
1809
+ display: flex;
1810
+ flex-direction: column;
1811
+ overflow: hidden;
1812
+ animation: npFadeIn 0.18s ease-out;
1813
+ }
1814
+ .np-queue[hidden] { display: none; }
1815
+ .np-queue-head {
1816
+ flex-shrink: 0;
1817
+ padding: 16px 18px 10px;
1818
+ display: flex;
1819
+ align-items: baseline;
1820
+ justify-content: space-between;
1821
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
1822
+ }
1823
+ .np-queue-head h3 {
1824
+ font-size: 1rem;
1825
+ font-weight: 800;
1826
+ letter-spacing: 0.04em;
1827
+ text-transform: uppercase;
1828
+ }
1829
+ .np-queue-head span {
1830
+ font-size: 0.78rem;
1831
+ color: var(--ms-text-dim);
1832
+ font-weight: 600;
1833
+ }
1834
+ .np-queue-list {
1835
+ flex: 1;
1836
+ overflow-y: auto;
1837
+ padding: 6px 8px 12px;
1838
+ scrollbar-width: thin;
1839
+ scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
1840
+ }
1841
+ .np-queue-empty {
1842
+ color: var(--ms-text-dim);
1843
+ text-align: center;
1844
+ padding: 30px 16px;
1845
+ font-size: 0.92rem;
1846
+ }
1847
+ .np-queue-row {
1848
+ width: 100%;
1849
+ display: grid;
1850
+ grid-template-columns: 44px 1fr auto;
1851
+ gap: 12px;
1852
+ align-items: center;
1853
+ padding: 8px 10px;
1854
+ border-radius: 8px;
1855
+ background: transparent;
1856
+ color: #fff;
1857
+ text-align: left;
1858
+ cursor: pointer;
1859
+ transition: background 0.15s;
1860
+ }
1861
+ .np-queue-row:hover { background: rgba(255, 255, 255, 0.07); }
1862
+ .np-queue-row.is-current { background: rgba(124, 58, 237, 0.18); }
1863
+ .np-queue-cover {
1864
+ position: relative;
1865
+ width: 44px; height: 44px;
1866
+ border-radius: 6px;
1867
+ overflow: hidden;
1868
+ background: var(--ms-surface-2);
1869
+ flex-shrink: 0;
1870
+ }
1871
+ .np-queue-cover img { width: 100%; height: 100%; object-fit: cover; }
1872
+ .np-queue-eq {
1873
+ position: absolute;
1874
+ inset: 0;
1875
+ background: rgba(0, 0, 0, 0.55);
1876
+ display: flex;
1877
+ align-items: flex-end;
1878
+ justify-content: center;
1879
+ gap: 2px;
1880
+ padding-bottom: 6px;
1881
+ }
1882
+ .np-queue-eq i {
1883
+ width: 3px; height: 12px;
1884
+ background: var(--ms-accent-hot);
1885
+ border-radius: 1px;
1886
+ animation: eqBars 1s ease-in-out infinite;
1887
+ animation-play-state: paused;
1888
+ transform: scaleY(0.4);
1889
+ transform-origin: bottom center;
1890
+ }
1891
+ .np-queue-eq i:nth-child(2) { animation-delay: 0.15s; height: 16px; }
1892
+ .np-queue-eq i:nth-child(3) { animation-delay: 0.3s; height: 10px; }
1893
+
1894
+ body.is-playing .np-queue-eq i { animation-play-state: running; }
1895
+ @keyframes eqBars {
1896
+ 0%, 100% { transform: scaleY(0.4); }
1897
+ 50% { transform: scaleY(1); }
1898
+ }
1899
+
1900
+ .np-lyric-line {
1901
+ display: block;
1902
+ padding: 6px 4px;
1903
+ line-height: 1.55;
1904
+ color: rgba(255, 255, 255, 0.55);
1905
+ transition: color 0.25s ease, transform 0.25s ease, font-weight 0.2s ease;
1906
+ font-size: 1rem;
1907
+ }
1908
+ .np-lyric-line.is-active {
1909
+ color: #fff;
1910
+ font-weight: 700;
1911
+ transform: scale(1.04);
1912
+ transform-origin: left center;
1913
+ text-shadow: 0 0 18px rgba(157, 110, 248, 0.55);
1914
+ }
1915
+ .np-lyric-line.is-loading,
1916
+ .np-lyric-line.is-empty {
1917
+ color: rgba(255, 255, 255, 0.5);
1918
+ font-style: italic;
1919
+ }
1920
+ .np-queue-text {
1921
+ display: flex;
1922
+ flex-direction: column;
1923
+ gap: 2px;
1924
+ min-width: 0;
1925
+ }
1926
+ .np-queue-title {
1927
+ font-size: 0.95rem;
1928
+ font-weight: 600;
1929
+ color: #fff;
1930
+ overflow: hidden;
1931
+ text-overflow: ellipsis;
1932
+ white-space: nowrap;
1933
+ }
1934
+ .np-queue-row.is-current .np-queue-title { color: var(--ms-accent-hot); }
1935
+ .np-queue-sub {
1936
+ font-size: 0.78rem;
1937
+ color: var(--ms-text-dim);
1938
+ overflow: hidden;
1939
+ text-overflow: ellipsis;
1940
+ white-space: nowrap;
1941
+ }
1942
+ .np-queue-tag {
1943
+ font-size: 0.7rem;
1944
+ color: var(--ms-text-dim);
1945
+ font-weight: 700;
1946
+ text-transform: uppercase;
1947
+ letter-spacing: 0.06em;
1948
+ flex-shrink: 0;
1949
+ }
1950
+
1951
+ .ms-link:hover {
1952
+ color: #fff;
1953
+ background: rgba(255, 255, 255, 0.06);
1954
+ }
1955
+ .ms-link:active { background: rgba(255, 255, 255, 0.12); }
1956
+
1957
+ .pb-cover-btn { cursor: pointer; }
1958
+
1959
+ @media (max-width: 720px) {
1960
+ .np-inner {
1961
+ padding: 14px 16px 22px;
1962
+ gap: 14px;
1963
+ }
1964
+ .np-stage { aspect-ratio: 1 / 1; }
1965
+ .np-tools { gap: 8px; }
1966
+ .np-tool { padding: 8px 12px; font-size: 0.78rem; }
1967
+ .np-tool span { display: inline; }
1968
+ .np-title { font-size: 1.1rem; }
1969
+ .np-artist { font-size: 0.88rem; }
1970
+ .np-controls { gap: 18px; }
1971
+ .np-play-big { width: 64px; height: 64px; }
1972
+ .np-play-big svg { width: 26px; height: 26px; }
1973
+ .np-queue-row { grid-template-columns: 40px 1fr auto; }
1974
+ .np-queue-cover { width: 40px; height: 40px; }
1975
+ }
1976
+
1977
+ @media (max-width: 380px) {
1978
+ .np-tool span { display: none; }
1979
+ .np-tool { padding: 8px 10px; }
1980
+ .np-controls { gap: 12px; }
1981
+ .np-controls .pb-icon-btn { width: 36px; height: 36px; }
1982
+ .np-play-big { width: 58px; height: 58px; }
1983
+ }
1984
+
1985
+ @media (min-width: 721px) and (max-width: 1024px) {
1986
+ .np-inner { max-width: 460px; padding: 22px 28px 30px; }
1987
+ }
1988
+
1989
+ @media (min-width: 1200px) {
1990
+ .np-inner { max-width: 560px; }
1991
+ .np-stage { max-height: 420px; }
1992
+ }
1993
+
1994
+ .np-inner {
1995
+ grid-template-rows: auto minmax(0, 1fr) auto auto auto auto;
1996
+ align-content: stretch;
1997
+ gap: 14px;
1998
+ padding: 14px 22px max(20px, env(safe-area-inset-bottom));
1999
+ overflow: hidden;
2000
+ overscroll-behavior: contain;
2001
+ box-sizing: border-box;
2002
+ }
2003
+ .np-stage {
2004
+ max-height: min(60vh, 480px);
2005
+ min-height: 0;
2006
+ }
2007
+ @media (max-height: 780px) {
2008
+ .np-inner { gap: 10px; padding: 10px 18px max(14px, env(safe-area-inset-bottom)); }
2009
+ .np-stage { max-height: min(52vh, 400px); }
2010
+ .np-controls { gap: 14px; }
2011
+ .np-tools { padding-top: 0; gap: 8px; }
2012
+ .np-tool { padding: 7px 12px; font-size: 0.78rem; }
2013
+ }
2014
+ @media (max-height: 640px) {
2015
+ .np-inner { gap: 6px; padding: 8px 14px max(10px, env(safe-area-inset-bottom)); }
2016
+ .np-stage { max-height: min(42vh, 300px); }
2017
+ .np-info { padding: 6px 10px; }
2018
+ .np-title { font-size: 1rem; margin-bottom: 2px; }
2019
+ .np-artist { font-size: 0.82rem; }
2020
+ .np-play-big { width: 54px; height: 54px; }
2021
+ .np-play-big svg { width: 22px; height: 22px; }
2022
+ .np-ctl { width: 38px; height: 38px; }
2023
+ .np-ctl svg { width: 22px; height: 22px; }
2024
+ }
2025
+ @media (max-height: 520px) {
2026
+ .np-stage { max-height: 30vh; }
2027
+ .np-inner { gap: 4px; padding: 6px 12px max(8px, env(safe-area-inset-bottom)); }
2028
+ .np-tool span { display: none; }
2029
+ .np-tool { padding: 6px 10px; }
2030
+ }
2031
+
2032
+ body.np-lyrics-open .np-inner {
2033
+ grid-template-rows: auto minmax(140px, 1fr) auto auto auto auto;
2034
+ align-content: stretch;
2035
+ overflow: hidden;
2036
+ }
2037
+ body.np-lyrics-open .np-stage {
2038
+ width: 100%;
2039
+ max-width: 100%;
2040
+ aspect-ratio: auto;
2041
+ max-height: none;
2042
+ height: 100%;
2043
+ border-radius: 8px;
2044
+ }
2045
+
2046
+ .np-back-btn {
2047
+ background: rgba(255, 255, 255, 0.08);
2048
+ border: 1px solid rgba(255, 255, 255, 0.10);
2049
+ padding: 0 12px 0 8px !important;
2050
+ min-width: auto !important;
2051
+ border-radius: 18px !important;
2052
+ height: 36px;
2053
+ }
2054
+ .np-back-btn span { display: inline !important; padding-right: 0 !important; }
2055
+ .np-back-btn:hover { background: rgba(255, 255, 255, 0.16); }
2056
+
2057
+ .np-lyrics {
2058
+ align-items: stretch;
2059
+ justify-content: center;
2060
+ padding: 28px 18px;
2061
+ overscroll-behavior: contain;
2062
+ }
2063
+ .np-lyrics-inner {
2064
+ width: 100%;
2065
+ max-width: 640px;
2066
+ height: 100%;
2067
+ margin: 0 auto;
2068
+ text-align: center;
2069
+ display: flex;
2070
+ flex-direction: column;
2071
+ min-height: 0;
2072
+ }
2073
+ .np-lyrics-inner h3 { text-align: center; }
2074
+ .np-lyrics-head { flex: 0 0 auto; justify-content: center; gap: 14px; position: relative; }
2075
+ .np-lyrics-head .np-panel-close { position: absolute; right: 0; top: 50%; transform: translateY(-50%); }
2076
+ .np-lyrics-body {
2077
+ flex: 1 1 0;
2078
+ min-height: 0;
2079
+ text-align: center;
2080
+ font-size: 1.45rem;
2081
+ line-height: 1.5;
2082
+ margin: 0;
2083
+ color: #fff;
2084
+
2085
+ padding: 45% 6px;
2086
+ overflow-y: auto;
2087
+ overflow-x: hidden;
2088
+
2089
+ -webkit-overflow-scrolling: touch;
2090
+ scrollbar-width: none;
2091
+
2092
+ -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
2093
+ mask-image: linear-gradient(180deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
2094
+ }
2095
+ .np-lyrics-body::-webkit-scrollbar { width: 0; height: 0; display: none; }
2096
+ .np-lyrics-meta { flex: 0 0 auto; padding-top: 8px; }
2097
+
2098
+ .np-lyric-line {
2099
+ display: block;
2100
+ padding: 10px 6px;
2101
+ line-height: 1.35;
2102
+ font-size: 1.4rem;
2103
+
2104
+ font-weight: 500;
2105
+ letter-spacing: -0.005em;
2106
+ color: rgba(255, 255, 255, 0.55);
2107
+ transform: scale(0.96);
2108
+ transform-origin: center;
2109
+ transition: opacity 0.45s cubic-bezier(0.22, 1, 0.36, 1),
2110
+ transform 0.55s cubic-bezier(0.22, 1, 0.36, 1),
2111
+ color 0.4s cubic-bezier(0.22, 1, 0.36, 1),
2112
+ font-weight 0.25s ease,
2113
+ text-shadow 0.5s ease;
2114
+ text-align: center;
2115
+ will-change: transform, color;
2116
+
2117
+ max-width: 100%;
2118
+ word-break: break-word;
2119
+ overflow-wrap: anywhere;
2120
+ hyphens: auto;
2121
+ white-space: normal;
2122
+ }
2123
+
2124
+ .np-lyric-line.is-past {
2125
+ color: rgba(255, 255, 255, 0.32);
2126
+ font-weight: 500;
2127
+ }
2128
+
2129
+ .np-lyric-line.is-active {
2130
+ transform: scale(1.12);
2131
+ color: #ffffff;
2132
+ font-weight: 900;
2133
+ letter-spacing: 0;
2134
+ text-shadow: 0 0 22px rgba(167, 139, 250, 0.55),
2135
+ 0 2px 14px rgba(0, 0, 0, 0.55);
2136
+ }
2137
+
2138
+ .np-lyric-line.is-active ~ .np-lyric-line {
2139
+ color: rgba(255, 255, 255, 0.55);
2140
+ font-weight: 500;
2141
+ transform: scale(0.96);
2142
+ }
2143
+ .np-lyric-line.is-loading,
2144
+ .np-lyric-line.is-empty {
2145
+ transform: none;
2146
+ font-style: italic;
2147
+ font-weight: 500;
2148
+ font-size: 1rem;
2149
+ color: rgba(255, 255, 255, 0.7);
2150
+ }
2151
+
2152
+ @media (max-width: 600px) {
2153
+ .np-lyrics { padding: 18px 10px; }
2154
+ .np-lyrics-body { font-size: 1.15rem; }
2155
+ .np-lyric-line { font-size: 1.15rem; padding: 8px 4px; }
2156
+ .np-lyric-line.is-active { transform: scale(1.08); }
2157
+ }
2158
+
2159
+ .np-info {
2160
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.10), rgba(255, 255, 255, 0.02));
2161
+ border: 1px solid rgba(255, 255, 255, 0.06);
2162
+ border-radius: 14px;
2163
+ padding: 10px 14px;
2164
+ }
2165
+ .np-like {
2166
+ width: 44px !important;
2167
+ height: 44px !important;
2168
+ background: rgba(255, 255, 255, 0.06);
2169
+ border: 1px solid rgba(255, 255, 255, 0.10);
2170
+ color: rgba(255, 255, 255, 0.85);
2171
+ transition: color 0.18s ease, background 0.18s ease, transform 0.18s ease, border-color 0.18s ease, box-shadow 0.2s ease;
2172
+ }
2173
+ .np-like svg { width: 22px !important; height: 22px !important; transition: transform 0.18s ease; }
2174
+ .np-like:hover {
2175
+ background: rgba(244, 63, 94, 0.12);
2176
+ border-color: rgba(244, 63, 94, 0.45);
2177
+ color: #fff;
2178
+ transform: scale(1.06);
2179
+ }
2180
+ .np-like.is-liked {
2181
+ color: #fff;
2182
+ background: linear-gradient(135deg, #f43f5e, #ec4899);
2183
+ border-color: rgba(244, 63, 94, 0.65);
2184
+ box-shadow: 0 8px 22px rgba(244, 63, 94, 0.45);
2185
+ }
2186
+ .np-like.is-liked svg { fill: currentColor; transform: scale(1.05); }
2187
+ .np-like.just-liked { animation: npLikePop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) 1; }
2188
+ @keyframes npLikePop {
2189
+ 0% { transform: scale(1); }
2190
+ 40% { transform: scale(1.25); }
2191
+ 100% { transform: scale(1.06); }
2192
+ }
2193
+ @media (max-width: 600px) {
2194
+ .np-info { padding: 8px 12px; gap: 12px; }
2195
+ .np-like { width: 40px !important; height: 40px !important; }
2196
+ .np-like svg { width: 20px !important; height: 20px !important; }
2197
+ }
frontend/public/music.html ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
6
+ <title>Music · anicove</title>
7
+ <meta name="description" content="Listen to anime openings, endings and OSTs. Powered by AnimeThemes.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/music.css">
12
+ <style>
13
+ :root {
14
+ --header-h: 0px;
15
+ }
16
+ body {
17
+ font-family: 'Outfit', sans-serif;
18
+ margin: 0;
19
+ padding: 0;
20
+ }
21
+ </style>
22
+ </head>
23
+
24
+ <body class="music-body">
25
+ <main class="music-app" id="music-app">
26
+ <aside class="music-sidebar" id="music-sidebar">
27
+ <div class="ms-block">
28
+ <div class="ms-block-title">
29
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2h-4v-7h-6v7H5a2 2 0 0 1-2-2z"/></svg>
30
+ <span>Browse</span>
31
+ </div>
32
+ <a class="ms-link" data-route="#/" href="#/">
33
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20"/><path d="M12 2a14.5 14.5 0 0 1 0 20"/><line x1="2" y1="12" x2="22" y2="12"/></svg>
34
+ <span>Discover</span>
35
+ </a>
36
+ <a class="ms-link" data-route="#/recent" href="#/recent">
37
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
38
+ <span>Newest Themes</span>
39
+ </a>
40
+ <a class="ms-link" data-route="#/artists" href="#/artists">
41
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
42
+ <span>Artists</span>
43
+ </a>
44
+ </div>
45
+
46
+ <div class="ms-block">
47
+ <div class="ms-block-title">
48
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
49
+ <span>Library</span>
50
+ </div>
51
+ <a class="ms-link" data-route="#/liked" href="#/liked">
52
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
53
+ <span>Liked Songs</span>
54
+ </a>
55
+ <a class="ms-link" data-route="#/history" href="#/history">
56
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
57
+ <span>Recently Played</span>
58
+ </a>
59
+ </div>
60
+
61
+ <div class="ms-block">
62
+ <div class="ms-block-title">
63
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
64
+ <span>By Year</span>
65
+ </div>
66
+ <div class="ms-year-grid" id="ms-year-grid"></div>
67
+ </div>
68
+ </aside>
69
+
70
+ <section class="music-main" id="music-main">
71
+ <div class="ms-topbar">
72
+ <button class="ms-nav-btn" id="ms-back" aria-label="Back" disabled>
73
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
74
+ </button>
75
+ <button class="ms-nav-btn" id="ms-fwd" aria-label="Forward" disabled>
76
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
77
+ </button>
78
+
79
+ <div class="ms-search">
80
+ <span class="ms-search-icon">
81
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
82
+ </span>
83
+ <input id="ms-search-input" type="text" placeholder="What do you want to listen to?" autocomplete="off">
84
+ <button class="ms-search-clear" id="ms-search-clear" aria-label="Clear" hidden>
85
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
86
+ </button>
87
+ </div>
88
+
89
+ <button class="ms-sidebar-toggle" id="ms-sidebar-toggle" aria-label="Library">
90
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
91
+ </button>
92
+ </div>
93
+
94
+ <div class="ms-view" id="ms-view"></div>
95
+ </section>
96
+ </main>
97
+
98
+ <div class="ms-sidebar-backdrop" id="ms-sidebar-backdrop"></div>
99
+
100
+ <div class="player-bar" id="player-bar" data-empty="1">
101
+ <div class="pb-track">
102
+ <button class="pb-cover-btn" id="pb-expand" aria-label="Expand player">
103
+ <img class="pb-cover" id="pb-cover" alt="" referrerpolicy="no-referrer">
104
+ <span class="pb-cover-fallback" id="pb-cover-fallback" aria-hidden="true">
105
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
106
+ </span>
107
+ <span class="pb-cover-overlay">
108
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
109
+ </span>
110
+ </button>
111
+ <div class="pb-meta">
112
+ <button class="pb-title" id="pb-title" type="button">Pick a song to start</button>
113
+ <a class="pb-artist" id="pb-artist" href="#">—</a>
114
+ </div>
115
+ <button class="pb-like" id="pb-like" aria-label="Like">
116
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
117
+ </button>
118
+ <button class="pb-dismiss" id="pb-dismiss" aria-label="Hide player">
119
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
120
+ </button>
121
+ </div>
122
+
123
+ <div class="pb-center">
124
+ <div class="pb-controls">
125
+ <button class="pb-btn" id="pb-shuffle" aria-label="Shuffle">
126
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
127
+ </button>
128
+ <button class="pb-btn" id="pb-prev" aria-label="Previous">
129
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
130
+ </button>
131
+ <button class="pb-play" id="pb-play" aria-label="Play">
132
+ <svg id="pb-play-svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
133
+ </button>
134
+ <button class="pb-btn" id="pb-next" aria-label="Next">
135
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6z"/></svg>
136
+ </button>
137
+ <button class="pb-btn" id="pb-loop" aria-label="Loop">
138
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
139
+ </button>
140
+ </div>
141
+ <div class="pb-progress">
142
+ <span class="pb-time" id="pb-cur">0:00</span>
143
+ <div class="pb-bar" id="pb-seek">
144
+ <div class="pb-bar-fill" id="pb-seek-fill"></div>
145
+ <div class="pb-bar-knob" id="pb-seek-knob"></div>
146
+ </div>
147
+ <span class="pb-time" id="pb-dur">0:00</span>
148
+ </div>
149
+ </div>
150
+
151
+ <div class="pb-right">
152
+ <button class="pb-btn pb-mobile-play" id="pb-play-mobile" aria-label="Play">
153
+ <svg id="pb-play-mobile-svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
154
+ </button>
155
+ <button class="pb-btn" id="pb-mute" aria-label="Mute">
156
+ <svg id="pb-vol-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
157
+ </button>
158
+ <div class="pb-vol" id="pb-vol">
159
+ <div class="pb-bar-fill" id="pb-vol-fill"></div>
160
+ <div class="pb-bar-knob" id="pb-vol-knob"></div>
161
+ </div>
162
+ </div>
163
+
164
+ <audio id="pb-audio" preload="metadata"></audio>
165
+ </div>
166
+
167
+ <div class="np-modal" id="np-modal" hidden>
168
+ <div class="np-bg" id="np-bg"></div>
169
+ <div class="np-inner">
170
+ <div class="np-header">
171
+ <button class="np-icon-btn np-back-btn" id="np-close" aria-label="Back">
172
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
173
+ <span>Back</span>
174
+ </button>
175
+ <div class="np-source">
176
+ <div class="np-eyebrow">Playing from</div>
177
+ <a class="np-source-name" id="np-source-name" href="#">—</a>
178
+ </div>
179
+ <div style="width:36px"></div>
180
+ </div>
181
+
182
+ <div class="np-stage" id="np-stage">
183
+ <span class="np-cover-fallback" id="np-cover-fallback" aria-hidden="true">
184
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
185
+ </span>
186
+ <img class="np-cover" id="np-cover" alt="" referrerpolicy="no-referrer">
187
+ <video class="np-video" id="np-video" playsinline preload="metadata" hidden></video>
188
+ <div class="np-loading" id="np-loading" hidden><div class="np-spinner"></div></div>
189
+ <div class="np-lyrics" id="np-lyrics" hidden>
190
+ <div class="np-lyrics-inner">
191
+ <div class="np-lyrics-head">
192
+ <h3>Lyrics</h3>
193
+ <button class="np-panel-close" id="np-lyrics-close" aria-label="Close lyrics">
194
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
195
+ </button>
196
+ </div>
197
+ <p class="np-lyrics-body" id="np-lyrics-body">Pick a song to load lyrics.</p>
198
+ <div class="np-lyrics-meta" id="np-lyrics-meta"></div>
199
+ </div>
200
+ </div>
201
+ <div class="np-queue" id="np-queue" hidden>
202
+ <div class="np-queue-head">
203
+ <h3>Queue</h3>
204
+ <span id="np-queue-count">0 tracks</span>
205
+ <button class="np-panel-close" id="np-queue-close" aria-label="Close queue">
206
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
207
+ </button>
208
+ </div>
209
+ <div class="np-queue-list" id="np-queue-list"></div>
210
+ </div>
211
+ </div>
212
+
213
+ <div class="np-info">
214
+ <div class="np-info-text">
215
+ <a class="np-title" id="np-title" href="#">—</a>
216
+ <a class="np-artist" id="np-artist" href="#">—</a>
217
+ </div>
218
+ <button class="np-like" id="np-like" aria-label="Like">
219
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
220
+ </button>
221
+ </div>
222
+
223
+ <div class="np-progress">
224
+ <div class="np-bar" id="np-seek">
225
+ <div class="np-bar-fill" id="np-seek-fill"></div>
226
+ <div class="np-bar-knob" id="np-seek-knob"></div>
227
+ </div>
228
+ <div class="np-times">
229
+ <span id="np-cur">0:00</span>
230
+ <span id="np-dur">0:00</span>
231
+ </div>
232
+ </div>
233
+
234
+ <div class="np-controls">
235
+ <button class="np-ctl" id="np-shuffle" aria-label="Shuffle">
236
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
237
+ </button>
238
+ <button class="np-ctl" id="np-prev" aria-label="Previous">
239
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
240
+ </button>
241
+ <button class="np-play-big" id="np-play" aria-label="Play">
242
+ <svg id="np-play-svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
243
+ </button>
244
+ <button class="np-ctl" id="np-next" aria-label="Next">
245
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6z"/></svg>
246
+ </button>
247
+ <button class="np-ctl" id="np-loop" aria-label="Loop">
248
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
249
+ </button>
250
+ </div>
251
+
252
+ <div class="np-tools">
253
+ <button class="np-tool" id="np-video-btn" aria-label="Toggle video">
254
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
255
+ <span>Video</span>
256
+ </button>
257
+ <button class="np-tool" id="np-lyrics-btn" aria-label="Toggle lyrics">
258
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 10h12M4 14h16M4 18h8"/></svg>
259
+ <span>Lyrics</span>
260
+ </button>
261
+ <button class="np-tool" id="np-queue-btn" aria-label="Toggle queue">
262
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
263
+ <span>Queue</span>
264
+ </button>
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ <div id="toast-container"></div>
270
+
271
+ <script src="/music-api.js"></script>
272
+ <script src="/music.js"></script>
273
+ </body>
274
+ </html>
frontend/public/music.js ADDED
@@ -0,0 +1,1729 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ const $ = (s, r) => (r || document).querySelector(s);
3
+ const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
4
+
5
+ const audio = $('#pb-audio');
6
+ const video = $('#np-video');
7
+ const view = $('#ms-view');
8
+ const playerBar = $('#player-bar');
9
+ const npModal = $('#np-modal');
10
+
11
+ let mediaMode = 'audio';
12
+ function activeMedia() { return mediaMode === 'video' ? video : audio; }
13
+
14
+ const STORE = {
15
+ liked: 'kv_music_liked',
16
+ history: 'kv_music_history',
17
+ vol: 'kv_music_vol',
18
+ };
19
+
20
+ const state = {
21
+ queue: [],
22
+ index: -1,
23
+ liked: new Set(JSON.parse(localStorage.getItem(STORE.liked) || '[]')),
24
+ history: JSON.parse(localStorage.getItem(STORE.history) || '[]'),
25
+ volume: parseFloat(localStorage.getItem(STORE.vol) || '0.8'),
26
+ muted: false,
27
+ loop: false,
28
+ shuffle: false,
29
+ navStack: [],
30
+ navFwd: [],
31
+ scrubbing: false,
32
+ };
33
+
34
+ const api = {
35
+ search: (q) => fetchAPI(`/themes/search?q=${encodeURIComponent(q)}`),
36
+ anime: (id) => fetchAPI(`/themes/anime/${encodeURIComponent(id)}`),
37
+ artist: (slug) => fetchAPI(`/themes/artist/${encodeURIComponent(slug)}`),
38
+ recent: () => fetchAPI('/themes/recent'),
39
+ year: (y) => fetchAPI(`/themes/year/${encodeURIComponent(y)}`),
40
+ artists: () => fetchAPI('/themes/top-artists'),
41
+ };
42
+
43
+ const esc = (s) => String(s == null ? '' : s).replace(/[<>&"']/g, c => ({
44
+ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;',
45
+ }[c]));
46
+
47
+ const fmtTime = (sec) => {
48
+ if (!isFinite(sec) || sec < 0) sec = 0;
49
+ const m = Math.floor(sec / 60);
50
+ const s = Math.floor(sec % 60);
51
+ return `${m}:${s < 10 ? '0' : ''}${s}`;
52
+ };
53
+
54
+ const placeholderCover = (label) => {
55
+ const t = (label || '?').slice(0, 1).toUpperCase();
56
+ return `<div class="ms-card-cover-fallback"><span style="font-size:2.4rem;font-weight:900;letter-spacing:-0.04em;">${esc(t)}</span></div>`;
57
+ };
58
+
59
+ function coverImg(url, label, opts) {
60
+ opts = opts || {};
61
+ const cls = opts.cls || 'ms-card-cover';
62
+ if (!url) return placeholderCover(label || '');
63
+ return `<img class="${cls}" data-cover-fallback="${esc(label || '')}"`
64
+ + ` src="${esc(url)}" alt="" loading="lazy" referrerpolicy="no-referrer">`;
65
+ }
66
+
67
+ document.addEventListener('error', (e) => {
68
+ const img = e.target;
69
+ if (!(img instanceof HTMLImageElement)) return;
70
+ if (!img.dataset || img.dataset.coverFallback == null) return;
71
+ const label = img.dataset.coverFallback;
72
+ const wrap = img.parentNode;
73
+ if (!wrap) return;
74
+
75
+ const tmp = document.createElement('div');
76
+ tmp.innerHTML = placeholderCover(label);
77
+ const node = tmp.firstChild;
78
+ if (node) wrap.replaceChild(node, img);
79
+ }, true);
80
+
81
+ const coverUrl = (anime) => {
82
+ if (!anime) return '';
83
+ const imgs = anime.images || [];
84
+ const large = imgs.find(i => i.facet === 'Large Cover');
85
+ const small = imgs.find(i => i.facet === 'Small Cover');
86
+ return (large || small || imgs[0])?.link || '';
87
+ };
88
+
89
+ const artistImageUrl = (artist) => {
90
+ if (!artist) return '';
91
+ const imgs = artist.images || [];
92
+ const large = imgs.find(i => i.facet === 'Large Cover');
93
+ const small = imgs.find(i => i.facet === 'Small Cover');
94
+ return (large || small || imgs[0])?.link || '';
95
+ };
96
+
97
+ function trackFromTheme(anime, theme, animeOverride) {
98
+ const animeRef = animeOverride || theme.anime || anime;
99
+ if (!animeRef) return null;
100
+ const entries = theme.animethemeentries || [];
101
+ let pickedAudio = null, pickedVideo = null, pickedEntry = null;
102
+ for (const e of entries) {
103
+ for (const v of (e.videos || [])) {
104
+ if (v.audio && v.audio.link) {
105
+ pickedAudio = v.audio;
106
+ pickedVideo = v;
107
+ pickedEntry = e;
108
+ break;
109
+ }
110
+ }
111
+ if (pickedAudio) break;
112
+ }
113
+ if (!pickedAudio) return null;
114
+ const cover = coverUrl(animeRef) || (theme.song?.artists?.[0] && artistImageUrl(theme.song.artists[0])) || '';
115
+ const slug = theme.slug || `${theme.type || ''}${theme.sequence || ''}`;
116
+ return {
117
+ id: `${theme.id}-${pickedAudio.id}`,
118
+ themeId: theme.id,
119
+ title: theme.song?.title || slug || 'Untitled',
120
+ type: slug,
121
+ typeKind: theme.type || '',
122
+ artists: theme.song?.artists || [],
123
+ animeId: animeRef.id,
124
+ animeName: animeRef.name,
125
+ animeSlug: animeRef.slug,
126
+ cover,
127
+ audio: pickedAudio.link,
128
+ videoLink: pickedVideo?.link,
129
+ episodes: pickedEntry?.episodes || '',
130
+ };
131
+ }
132
+
133
+ function tracksFromAnime(anime) {
134
+ return (anime.animethemes || [])
135
+ .map(t => trackFromTheme(anime, t))
136
+ .filter(Boolean);
137
+ }
138
+
139
+ function persistLiked() { localStorage.setItem(STORE.liked, JSON.stringify(Array.from(state.liked))); }
140
+ function persistHistory() { localStorage.setItem(STORE.history, JSON.stringify(state.history.slice(0, 60))); }
141
+ function persistVol() { localStorage.setItem(STORE.vol, String(state.volume)); }
142
+
143
+ function isLiked(track) { return state.liked.has(track.id); }
144
+ function toggleLike(track) {
145
+ if (state.liked.has(track.id)) {
146
+ state.liked.delete(track.id);
147
+ try { localStorage.removeItem(`kv_music_track_${track.id}`); } catch (_) { }
148
+ } else {
149
+ state.liked.add(track.id);
150
+ try { localStorage.setItem(`kv_music_track_${track.id}`, JSON.stringify(track)); } catch (_) { }
151
+ }
152
+ persistLiked();
153
+ updatePlayerLike();
154
+ $$(`.ms-track-like[data-tid="${CSS.escape(track.id)}"]`).forEach(b => {
155
+ b.classList.toggle('is-liked', state.liked.has(track.id));
156
+ });
157
+ }
158
+
159
+ function pushHistory(track) {
160
+ try { localStorage.setItem(`kv_music_track_${track.id}`, JSON.stringify(track)); } catch (_) { }
161
+ state.history = [track.id, ...state.history.filter(id => id !== track.id)].slice(0, 60);
162
+ persistHistory();
163
+ }
164
+
165
+ function loadStoredTracks(ids) {
166
+ const out = [];
167
+ for (const id of ids) {
168
+ try {
169
+ const raw = localStorage.getItem(`kv_music_track_${id}`);
170
+ if (raw) out.push(JSON.parse(raw));
171
+ } catch (_) { }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ function play(queue, idx) {
177
+ if (!queue || !queue.length) return;
178
+ state.queue = queue.slice();
179
+ state.index = Math.max(0, Math.min(idx || 0, state.queue.length - 1));
180
+ loadCurrent(true);
181
+ renderQueue();
182
+
183
+ if (window.innerWidth <= 720) openNP();
184
+ }
185
+
186
+ function jumpTo(idx) {
187
+ if (idx < 0 || idx >= state.queue.length) return;
188
+ state.index = idx;
189
+ loadCurrent(true);
190
+ renderQueue();
191
+ }
192
+
193
+ function renderQueue() {
194
+ const list = $('#np-queue-list');
195
+ const count = $('#np-queue-count');
196
+ if (!list || !count) return;
197
+ const q = state.queue;
198
+ count.textContent = q.length ? `${q.length} track${q.length > 1 ? 's' : ''}` : 'Empty';
199
+ if (!q.length) {
200
+ list.innerHTML = `<div class="np-queue-empty">Pick a song to start playing.</div>`;
201
+ return;
202
+ }
203
+ list.innerHTML = q.map((t, i) => `
204
+ <button class="np-queue-row${i === state.index ? ' is-current' : ''}" data-qjump="${i}">
205
+ <span class="np-queue-cover">
206
+ ${t.cover ? `<img src="${esc(t.cover)}" alt="" referrerpolicy="no-referrer">` : ''}
207
+ ${i === state.index ? '<span class="np-queue-eq"><i></i><i></i><i></i></span>' : ''}
208
+ </span>
209
+ <span class="np-queue-text">
210
+ <span class="np-queue-title">${esc(t.title)}</span>
211
+ <span class="np-queue-sub">${esc((t.artists || []).map(a => a.name).join(', ') || t.animeName || '')}</span>
212
+ </span>
213
+ <span class="np-queue-tag">${i < state.index ? 'Played' : (i === state.index ? 'Now' : 'Next')}</span>
214
+ </button>
215
+ `).join('');
216
+ $$('[data-qjump]', list).forEach(btn => {
217
+ btn.addEventListener('click', () => jumpTo(parseInt(btn.dataset.qjump, 10)));
218
+ });
219
+
220
+ const cur = list.querySelector('.is-current');
221
+ if (cur) cur.scrollIntoView({ block: 'nearest' });
222
+ }
223
+
224
+ function loadCurrent(autoplay) {
225
+ const t = state.queue[state.index];
226
+ if (!t) return;
227
+ playerBar.dataset.empty = '0';
228
+
229
+ document.body.classList.remove('pb-hidden');
230
+
231
+ if (mediaMode === 'video' && !t.videoLink) {
232
+ setVideoMode(false, false);
233
+ }
234
+
235
+ if (mediaMode === 'video') {
236
+ video.src = t.videoLink;
237
+ try { audio.removeAttribute('src'); audio.load(); } catch (_) { }
238
+ video.muted = state.muted;
239
+ video.volume = state.volume;
240
+ } else {
241
+ audio.src = t.audio;
242
+ try { video.removeAttribute('src'); video.load(); } catch (_) { }
243
+ audio.muted = state.muted;
244
+ audio.volume = state.volume;
245
+ }
246
+
247
+ if (autoplay) {
248
+ const p = activeMedia().play();
249
+ if (p && p.catch) p.catch(() => { });
250
+ }
251
+ renderPlayerMeta(t);
252
+ pushHistory(t);
253
+ updateNowPlayingMarkers();
254
+ }
255
+
256
+ function setVideoMode(on, reload = true) {
257
+ const t = state.queue[state.index];
258
+ if (on && (!t || !t.videoLink)) {
259
+ toast && toast('No video available for this track');
260
+ return;
261
+ }
262
+ const wasPaused = activeMedia().paused;
263
+ const ct = activeMedia().currentTime || 0;
264
+ activeMedia().pause();
265
+
266
+ mediaMode = on ? 'video' : 'audio';
267
+ document.body.classList.toggle('np-video-on', mediaMode === 'video');
268
+ $('#np-video-btn').classList.toggle('is-on', mediaMode === 'video');
269
+ const npVid = $('#np-video');
270
+ const npCov = $('#np-cover');
271
+ if (npVid) npVid.hidden = mediaMode !== 'video';
272
+ if (npCov) npCov.hidden = mediaMode === 'video';
273
+
274
+ if (!t) return;
275
+
276
+ if (reload) {
277
+ if (mediaMode === 'video') {
278
+ video.src = t.videoLink;
279
+ try { audio.removeAttribute('src'); audio.load(); } catch (_) { }
280
+ } else {
281
+ audio.src = t.audio;
282
+ try { video.removeAttribute('src'); video.load(); } catch (_) { }
283
+ }
284
+ const m = activeMedia();
285
+ m.muted = state.muted;
286
+ m.volume = state.volume;
287
+ const onMeta = () => {
288
+ try { m.currentTime = ct; } catch (_) { }
289
+ if (!wasPaused) m.play().catch(() => { });
290
+ m.removeEventListener('loadedmetadata', onMeta);
291
+ };
292
+ m.addEventListener('loadedmetadata', onMeta);
293
+ }
294
+ }
295
+
296
+ function toast(msg) {
297
+ const c = $('#toast-container');
298
+ if (!c) return;
299
+ const el = document.createElement('div');
300
+ el.className = 'kv-toast';
301
+ el.textContent = msg;
302
+ c.appendChild(el);
303
+ setTimeout(() => el.classList.add('show'), 10);
304
+ setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 300); }, 2200);
305
+ }
306
+
307
+ function next(auto) {
308
+ if (!state.queue.length) return;
309
+ if (state.shuffle && state.queue.length > 1) {
310
+ let n;
311
+ do { n = Math.floor(Math.random() * state.queue.length); } while (n === state.index);
312
+ state.index = n;
313
+ } else {
314
+ state.index = state.index + 1;
315
+ if (state.index >= state.queue.length) {
316
+ if (state.loop || !auto) state.index = 0;
317
+ else { state.index = state.queue.length - 1; activeMedia().pause(); return; }
318
+ }
319
+ }
320
+ loadCurrent(true);
321
+ renderQueue();
322
+ }
323
+
324
+ function prev() {
325
+ if (!state.queue.length) return;
326
+ if (activeMedia().currentTime > 3) { activeMedia().currentTime = 0; return; }
327
+ state.index = (state.index - 1 + state.queue.length) % state.queue.length;
328
+ loadCurrent(true);
329
+ renderQueue();
330
+ }
331
+
332
+ function togglePlay() {
333
+ if (playerBar.dataset.empty === '1') return;
334
+ const m = activeMedia();
335
+ if (m.paused) m.play().catch(() => { });
336
+ else m.pause();
337
+ }
338
+
339
+ function setCoverImg(imgEl, btnEl, url) {
340
+ if (!imgEl) return;
341
+ if (url) {
342
+ imgEl.classList.remove('is-failed');
343
+ if (imgEl.getAttribute('src') !== url) imgEl.src = url;
344
+ if (btnEl) btnEl.classList.add('has-cover');
345
+ } else {
346
+ imgEl.removeAttribute('src');
347
+ imgEl.classList.add('is-failed');
348
+ if (btnEl) btnEl.classList.remove('has-cover');
349
+ }
350
+ }
351
+
352
+ function renderPlayerMeta(t) {
353
+ const pbCover = $('#pb-cover');
354
+ const pbCoverBtn = $('#pb-expand');
355
+ setCoverImg(pbCover, pbCoverBtn, t.cover || '');
356
+ const titleEl = $('#pb-title');
357
+ titleEl.textContent = t.title;
358
+ titleEl.dataset.animeId = t.animeId || '';
359
+ const artistEl = $('#pb-artist');
360
+ const artistsLabel = (t.artists && t.artists.length)
361
+ ? t.artists.map(a => a.name).join(', ')
362
+ : (t.animeName || '—');
363
+ if (t.artists && t.artists.length) {
364
+ const first = t.artists[0];
365
+ artistEl.textContent = artistsLabel;
366
+ artistEl.href = `#/artist/${encodeURIComponent(first.slug || first.id)}`;
367
+ } else {
368
+ artistEl.textContent = artistsLabel;
369
+ artistEl.href = `#/anime/${encodeURIComponent(t.animeId)}`;
370
+ }
371
+
372
+ const npCover = $('#np-cover');
373
+ const npTitle = $('#np-title');
374
+ const npArtist = $('#np-artist');
375
+ const npSource = $('#np-source-name');
376
+ const npBg = $('#np-bg');
377
+ setCoverImg(npCover, null, t.cover || '');
378
+ if (npTitle) { npTitle.textContent = t.title; npTitle.href = `#/anime/${encodeURIComponent(t.animeId)}`; }
379
+ if (npArtist) {
380
+ npArtist.textContent = artistsLabel;
381
+ const first = (t.artists || [])[0];
382
+ npArtist.href = first ? `#/artist/${encodeURIComponent(first.slug || first.id)}` : `#/anime/${encodeURIComponent(t.animeId)}`;
383
+ }
384
+ if (npSource) { npSource.textContent = t.animeName || '—'; npSource.href = `#/anime/${encodeURIComponent(t.animeId)}`; }
385
+ if (npBg && t.cover) npBg.style.backgroundImage = `url("${t.cover}")`;
386
+
387
+ const vidBtn = $('#np-video-btn');
388
+ if (vidBtn) vidBtn.classList.toggle('is-disabled', !t.videoLink);
389
+
390
+ const lyricsMeta = $('#np-lyrics-meta');
391
+ if (lyricsMeta) {
392
+ lyricsMeta.innerHTML = `
393
+ <div><span>Anime</span><strong>${esc(t.animeName || '—')}</strong></div>
394
+ <div><span>Type</span><strong>${esc(t.type || '—')}</strong></div>
395
+ <div><span>Episodes</span><strong>${esc(t.episodes || '—')}</strong></div>
396
+ <div><span>Artists</span><strong>${esc((t.artists || []).map(a => a.name).join(', ') || '—')}</strong></div>
397
+ `;
398
+ }
399
+
400
+ _syncedLines = [];
401
+ _activeLyricIdx = -1;
402
+ const lyricsBody = $('#np-lyrics-body');
403
+ if (lyricsBody) {
404
+ lyricsBody.dataset.trackId = t.id;
405
+ lyricsBody.innerHTML = '<span class="np-lyric-line is-loading">Loading lyrics…</span>';
406
+ }
407
+ fetchLyricsFor(t);
408
+
409
+ updatePlayerLike();
410
+ }
411
+
412
+ let _lyricsCache = {};
413
+ let _syncedLines = [];
414
+ let _rawSyncedLines = [];
415
+ let _lyricsDuration = 0;
416
+ let _activeLyricIdx = -1;
417
+ let _lyricRaf = null;
418
+
419
+ function parseLRC(lrc) {
420
+ const out = [];
421
+ const lineRe = /^((?:\[\d+:\d+(?:\.\d+)?\])+)(.*)$/;
422
+ const tagRe = /\[(\d+):(\d+(?:\.\d+)?)\]/g;
423
+ for (const raw of String(lrc).split(/\r?\n/)) {
424
+ const m = raw.match(lineRe);
425
+ if (!m) continue;
426
+ const text = m[2].trim();
427
+ let tag;
428
+ while ((tag = tagRe.exec(m[1])) !== null) {
429
+ const t = parseInt(tag[1], 10) * 60 + parseFloat(tag[2]);
430
+ out.push({ t, text });
431
+ }
432
+ }
433
+ out.sort((a, b) => a.t - b.t);
434
+ return out;
435
+ }
436
+
437
+ function renderPlainLyrics(text) {
438
+ const body = $('#np-lyrics-body');
439
+ if (!body) return;
440
+ if (!text || !text.trim()) {
441
+ body.innerHTML = '<span class="np-lyric-line is-empty">We couldn\'t find lyrics for this track yet.</span>';
442
+ return;
443
+ }
444
+ const lines = text.split(/\r?\n/);
445
+ body.innerHTML = lines
446
+ .map(l => `<div class="np-lyric-line">${esc(l) || '&nbsp;'}</div>`)
447
+ .join('');
448
+ }
449
+
450
+ function renderSyncedLyrics(lines) {
451
+ const body = $('#np-lyrics-body');
452
+ if (!body) return;
453
+
454
+ body.style.transform = '';
455
+ body.scrollTop = 0;
456
+ body.innerHTML = lines
457
+ .map((l, i) => `<div class="np-lyric-line" data-i="${i}">${esc(l.text) || '&nbsp;'}</div>`)
458
+ .join('');
459
+ }
460
+
461
+ function recomputeSyncedLines() {
462
+ if (!_rawSyncedLines.length) { _syncedLines = []; return; }
463
+ _syncedLines = _rawSyncedLines.slice();
464
+ _activeLyricIdx = -1;
465
+ }
466
+
467
+ function updateLyricsHighlight(currentTime) {
468
+ if (!_syncedLines.length) return;
469
+
470
+ let lo = 0, hi = _syncedLines.length - 1, idx = -1;
471
+ while (lo <= hi) {
472
+ const mid = (lo + hi) >> 1;
473
+ if (_syncedLines[mid].t <= currentTime) { idx = mid; lo = mid + 1; }
474
+ else hi = mid - 1;
475
+ }
476
+ if (idx === _activeLyricIdx) return;
477
+ _activeLyricIdx = idx;
478
+ const body = $('#np-lyrics-body');
479
+ if (!body) return;
480
+ const lines = body.querySelectorAll('.np-lyric-line');
481
+ lines.forEach((el, i) => {
482
+ el.classList.toggle('is-active', i === idx);
483
+ el.classList.toggle('is-past', i < idx);
484
+ });
485
+ if (idx < 0) return;
486
+ const active = lines[idx];
487
+ if (!active) return;
488
+
489
+ const viewH = body.clientHeight;
490
+ if (viewH <= 0) return;
491
+
492
+ const ACTIVE_RATIO = 0.42;
493
+ const target = active.offsetTop + (active.offsetHeight / 2) - (viewH * ACTIVE_RATIO);
494
+ scrollLyricsTo(body, Math.max(0, target));
495
+ }
496
+
497
+ let _lyricScrollRaf = 0;
498
+ function scrollLyricsTo(body, target) {
499
+ if (!body) return;
500
+ target = Math.max(0, Math.min(target, body.scrollHeight - body.clientHeight));
501
+ if (_lyricScrollRaf) cancelAnimationFrame(_lyricScrollRaf);
502
+ const start = body.scrollTop;
503
+ const delta = target - start;
504
+ if (Math.abs(delta) < 0.5) { body.scrollTop = target; return; }
505
+
506
+ const dist = Math.abs(delta);
507
+ const dur = Math.min(900, Math.max(280, dist * 0.9));
508
+ const t0 = performance.now();
509
+ const ease = (x) => 1 - Math.pow(1 - x, 3);
510
+ const step = (now) => {
511
+ const k = Math.min(1, (now - t0) / dur);
512
+ body.scrollTop = start + delta * ease(k);
513
+ if (k < 1) _lyricScrollRaf = requestAnimationFrame(step);
514
+ else _lyricScrollRaf = 0;
515
+ };
516
+ _lyricScrollRaf = requestAnimationFrame(step);
517
+ }
518
+
519
+ function cleanLyricTitle(raw) {
520
+ if (!raw) return '';
521
+ let s = String(raw);
522
+
523
+ s = s.replace(/^\s*(?:OP|ED|IN)\s*\d*\s*[-–:]\s*/i, '');
524
+ s = s.replace(/^\s*(?:Opening|Ending|Insert\s*Song|Theme)\s*\d*\s*[-–:]\s*/i, '');
525
+
526
+ s = s.replace(/\([^)]*\)/g, '');
527
+ s = s.replace(/\[[^\]]*\]/g, '');
528
+
529
+ s = s.replace(/^[\s"'“”『「]+|[\s"'“”』」]+$/g, '');
530
+ return s.trim();
531
+ }
532
+
533
+ function waitForAudioDuration(m, timeoutMs) {
534
+ return new Promise((resolve) => {
535
+ if (!m) return resolve(0);
536
+ if (isFinite(m.duration) && m.duration > 0) return resolve(m.duration);
537
+ let done = false;
538
+ const finish = (v) => {
539
+ if (done) return;
540
+ done = true;
541
+ m.removeEventListener('loadedmetadata', onMeta);
542
+ m.removeEventListener('durationchange', onMeta);
543
+ resolve(v);
544
+ };
545
+ const onMeta = () => {
546
+ if (isFinite(m.duration) && m.duration > 0) finish(m.duration);
547
+ };
548
+ m.addEventListener('loadedmetadata', onMeta);
549
+ m.addEventListener('durationchange', onMeta);
550
+ setTimeout(() => finish(0), timeoutMs || 4000);
551
+ });
552
+ }
553
+
554
+ async function fetchLyricsFor(track) {
555
+ if (!track) return;
556
+ const lyricsBody = $('#np-lyrics-body');
557
+ if (!lyricsBody) return;
558
+ const key = track.id;
559
+ const apply = (data) => {
560
+ if (lyricsBody.dataset.trackId !== key) return;
561
+ const synced = data && data.synced;
562
+ const plain = data && data.lyrics;
563
+ _lyricsDuration = (data && Number(data.duration)) || 0;
564
+ if (synced) {
565
+ _rawSyncedLines = parseLRC(synced).filter(l => l.text || l.text === '');
566
+ if (_rawSyncedLines.length) {
567
+ recomputeSyncedLines();
568
+ renderSyncedLyrics(_syncedLines);
569
+ return;
570
+ }
571
+ }
572
+ _rawSyncedLines = [];
573
+ _syncedLines = [];
574
+ renderPlainLyrics(plain || (synced ? String(synced).replace(/^\[\d+:\d+(?:\.\d+)?\]\s?/gm, '') : ''));
575
+ };
576
+
577
+ const audioDur = await waitForAudioDuration(activeMedia(), 4000);
578
+ if (lyricsBody.dataset.trackId !== key) return;
579
+
580
+ const durBucket = audioDur ? Math.round(audioDur / 5) * 5 : 0;
581
+ const cacheKey = `${key}|${durBucket}`;
582
+ if (_lyricsCache[cacheKey] != null) { apply(_lyricsCache[cacheKey]); return; }
583
+
584
+ const title = cleanLyricTitle(track.title || '');
585
+
586
+ const firstArtist = ((track.artists || [])[0] || {}).name || '';
587
+ try {
588
+ const durQs = audioDur ? `&duration=${audioDur.toFixed(2)}` : '';
589
+ const url = `/api/lyrics?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(firstArtist)}${durQs}`;
590
+ const res = await fetch(url);
591
+ const data = await res.json();
592
+ _lyricsCache[cacheKey] = data;
593
+ apply(data);
594
+ } catch (_) {
595
+ apply({});
596
+ }
597
+ }
598
+
599
+ function updatePlayerLike() {
600
+ const t = state.queue[state.index];
601
+ const btn = $('#pb-like');
602
+ const npBtn = $('#np-like');
603
+ const liked = !!(t && state.liked.has(t.id));
604
+ if (btn) btn.classList.toggle('is-liked', liked);
605
+ if (npBtn) npBtn.classList.toggle('is-liked', liked);
606
+ }
607
+
608
+ function updateNowPlayingMarkers() {
609
+ const t = state.queue[state.index];
610
+ $$('.ms-track').forEach(row => {
611
+ row.classList.toggle('is-playing', !!(t && row.dataset.tid === t.id));
612
+ });
613
+ }
614
+
615
+ function setPlayUI(playing) {
616
+ const pause = '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>';
617
+ const play = '<path d="M8 5v14l11-7z"/>';
618
+ const ic = playing ? pause : play;
619
+ const set = (id) => {
620
+ const el = $('#' + id);
621
+ if (el) el.outerHTML = `<svg id="${id}" viewBox="0 0 24 24" fill="currentColor">${ic}</svg>`;
622
+ };
623
+ set('pb-play-svg');
624
+ set('pb-play-mobile-svg');
625
+ set('np-play-svg');
626
+
627
+ document.body.classList.toggle('is-playing', !!playing);
628
+ }
629
+
630
+ function openNP() {
631
+ if (playerBar.dataset.empty === '1') return;
632
+ npModal.hidden = false;
633
+ document.body.classList.add('np-open');
634
+
635
+ $('#np-shuffle').classList.toggle('is-on', state.shuffle);
636
+ $('#np-loop').classList.toggle('is-on', state.loop);
637
+ $('#np-video-btn').classList.toggle('is-on', mediaMode === 'video');
638
+ setSeek();
639
+ updatePlayerLike();
640
+ }
641
+ function closeNP() {
642
+ npModal.hidden = true;
643
+ document.body.classList.remove('np-open');
644
+ document.body.classList.remove('np-lyrics-open');
645
+ }
646
+
647
+ function setVolUI() {
648
+ const v = state.muted ? 0 : state.volume;
649
+ $('#pb-vol-fill').style.width = (v * 100) + '%';
650
+ const knob = $('#pb-vol-knob');
651
+ if (knob) knob.style.left = (v * 100) + '%';
652
+ const svg = $('#pb-vol-svg');
653
+ if (!svg) return;
654
+ let path;
655
+ if (v === 0) path = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>';
656
+ else if (v < 0.5) path = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>';
657
+ else path = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>';
658
+ svg.innerHTML = path;
659
+ }
660
+
661
+ function setSeek(t) {
662
+ const m = activeMedia();
663
+ const dur = m.duration;
664
+ const cur = t == null ? m.currentTime : t;
665
+ $('#pb-cur').textContent = fmtTime(cur);
666
+ $('#pb-dur').textContent = fmtTime(dur);
667
+ const pct = dur ? (cur / dur) * 100 : 0;
668
+ $('#pb-seek-fill').style.width = pct + '%';
669
+ const knob = $('#pb-seek-knob');
670
+ if (knob) knob.style.left = pct + '%';
671
+
672
+ const npCur = $('#np-cur'), npDur = $('#np-dur'), npFill = $('#np-seek-fill'), npKnob = $('#np-seek-knob');
673
+ if (npCur) npCur.textContent = fmtTime(cur);
674
+ if (npDur) npDur.textContent = fmtTime(dur);
675
+ if (npFill) npFill.style.width = pct + '%';
676
+ if (npKnob) npKnob.style.left = pct + '%';
677
+ }
678
+
679
+ function bindBar(bar, onChange) {
680
+ let pending = null;
681
+ let rafId = 0;
682
+ const flush = () => {
683
+ rafId = 0;
684
+ if (pending != null) { onChange(pending); pending = null; }
685
+ };
686
+ const schedule = (pct) => {
687
+ pending = pct;
688
+ if (!rafId) rafId = requestAnimationFrame(flush);
689
+ };
690
+ const setFromEvent = (e) => {
691
+ const r = bar.getBoundingClientRect();
692
+ const x = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
693
+ const pct = Math.max(0, Math.min(1, x / r.width));
694
+ schedule(pct);
695
+ };
696
+ let dragging = false;
697
+ const onUp = () => {
698
+ dragging = false;
699
+ state.scrubbing = false;
700
+ document.removeEventListener('mousemove', onMove);
701
+ document.removeEventListener('touchmove', onMove);
702
+ document.removeEventListener('mouseup', onUp);
703
+ document.removeEventListener('touchend', onUp);
704
+ if (rafId) { cancelAnimationFrame(rafId); rafId = 0; flush(); }
705
+ };
706
+ const onMove = (e) => { if (dragging) setFromEvent(e); };
707
+ bar.addEventListener('mousedown', (e) => {
708
+ dragging = true; state.scrubbing = true; setFromEvent(e);
709
+ document.addEventListener('mousemove', onMove);
710
+ document.addEventListener('mouseup', onUp);
711
+ });
712
+ bar.addEventListener('touchstart', (e) => {
713
+ dragging = true; state.scrubbing = true; setFromEvent(e);
714
+ document.addEventListener('touchmove', onMove, { passive: true });
715
+ document.addEventListener('touchend', onUp);
716
+ }, { passive: true });
717
+ bar.addEventListener('click', setFromEvent);
718
+ }
719
+
720
+ let _stageSpinnerTimer = 0;
721
+ function showStageLoading(on) {
722
+ const el = $('#np-loading');
723
+ if (!el) return;
724
+ if (on) {
725
+ if (_stageSpinnerTimer) return;
726
+ _stageSpinnerTimer = setTimeout(() => {
727
+ _stageSpinnerTimer = 0;
728
+ el.hidden = false;
729
+ }, 350);
730
+ } else {
731
+ if (_stageSpinnerTimer) { clearTimeout(_stageSpinnerTimer); _stageSpinnerTimer = 0; }
732
+ el.hidden = true;
733
+ }
734
+ }
735
+
736
+ function navigate(hash) {
737
+ if (location.hash !== hash) location.hash = hash;
738
+ else handleRoute();
739
+ }
740
+
741
+ function handleRoute() {
742
+ const hash = location.hash || '#/';
743
+ if (state.navStack[state.navStack.length - 1] !== hash) {
744
+ state.navStack.push(hash);
745
+ state.navFwd = [];
746
+ }
747
+ $('#ms-back').disabled = state.navStack.length <= 1;
748
+ $('#ms-fwd').disabled = state.navFwd.length === 0;
749
+
750
+ $$('.ms-link').forEach(l => {
751
+ l.classList.toggle('active', l.dataset.route === hash || (hash === '#/' && l.dataset.route === '#/'));
752
+ });
753
+
754
+ if (hash.startsWith('#/search?')) {
755
+ const q = decodeURIComponent(hash.split('q=')[1] || '');
756
+ $('#ms-search-input').value = q;
757
+ $('#ms-search-clear').hidden = !q;
758
+ renderSearch(q);
759
+ } else if (hash.startsWith('#/anime/')) {
760
+ renderAnime(decodeURIComponent(hash.split('/')[2] || ''));
761
+ } else if (hash.startsWith('#/artist/')) {
762
+ renderArtist(decodeURIComponent(hash.split('/')[2] || ''));
763
+ } else if (hash.startsWith('#/year/')) {
764
+ renderYear(hash.split('/')[2]);
765
+ } else if (hash === '#/recent') renderRecent();
766
+ else if (hash === '#/liked') renderLiked();
767
+ else if (hash === '#/history') renderHistory();
768
+ else if (hash === '#/artists') renderTopArtists();
769
+ else renderHome();
770
+
771
+ view.scrollTo({ top: 0 });
772
+ $('#music-main').scrollTo({ top: 0 });
773
+ closeSidebarIfMobile();
774
+ }
775
+
776
+ function loadingHTML(label) {
777
+ return `<div class="ms-loading"><div class="ms-spinner"></div><div>${esc(label || 'Loading…')}</div></div>`;
778
+ }
779
+
780
+ function emptyHTML(title, sub) {
781
+ return `<div class="ms-empty">
782
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
783
+ <h3>${esc(title)}</h3>
784
+ <p>${esc(sub || '')}</p>
785
+ </div>`;
786
+ }
787
+
788
+ function animeCardHTML(anime) {
789
+ const cover = coverUrl(anime);
790
+ const themesCount = (anime.animethemes || []).length;
791
+ const sub = themesCount ? `${themesCount} theme${themesCount > 1 ? 's' : ''} · ${anime.year || ''}` : `${anime.year || ''} · ${anime.media_format || ''}`;
792
+ return `<a class="ms-card" data-anime-id="${esc(anime.id)}" href="#/anime/${encodeURIComponent(anime.id)}">
793
+ <div class="ms-card-cover-wrap">
794
+ ${coverImg(cover, anime.name)}
795
+ <button class="ms-card-play" data-play-anime="${esc(anime.id)}" aria-label="Play">
796
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
797
+ </button>
798
+ </div>
799
+ <div class="ms-card-title">${esc(anime.name)}</div>
800
+ <div class="ms-card-sub">${esc(sub)}</div>
801
+ </a>`;
802
+ }
803
+
804
+ function artistCardHTML(artist) {
805
+ const img = artistImageUrl(artist);
806
+ return `<a class="ms-card is-artist" href="#/artist/${encodeURIComponent(artist.slug || artist.id)}">
807
+ <div class="ms-card-cover-wrap">
808
+ ${coverImg(img, artist.name)}
809
+ </div>
810
+ <div class="ms-card-title">${esc(artist.name)}</div>
811
+ <div class="ms-card-sub">Artist</div>
812
+ </a>`;
813
+ }
814
+
815
+ function songCardHTML(song) {
816
+ const themes = song.animethemes || [];
817
+ const firstAnime = themes[0]?.anime;
818
+ const artists = (song.artists || []).map(a => a.name).join(', ');
819
+ const href = firstAnime ? `#/anime/${encodeURIComponent(firstAnime.id)}` : '#/';
820
+ return `<a class="ms-card" href="${href}">
821
+ <div class="ms-card-cover-wrap">
822
+ ${placeholderCover(song.title)}
823
+ ${firstAnime ? `<button class="ms-card-play" data-play-song-anime="${esc(firstAnime.id)}" data-song-theme="${esc(themes[0].id)}" aria-label="Play">
824
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
825
+ </button>` : ''}
826
+ </div>
827
+ <div class="ms-card-title">${esc(song.title)}</div>
828
+ <div class="ms-card-sub">${esc(artists || (firstAnime?.name || ''))}</div>
829
+ </a>`;
830
+ }
831
+
832
+ function bindCardClicks(scope) {
833
+ $$('[data-play-anime]', scope).forEach(btn => {
834
+ btn.addEventListener('click', async (e) => {
835
+ e.preventDefault();
836
+ e.stopPropagation();
837
+ const id = btn.dataset.playAnime;
838
+ btn.disabled = true;
839
+ try {
840
+ const data = await api.anime(id);
841
+ const a = data.anime;
842
+ if (!a) return;
843
+ const tracks = tracksFromAnime(a);
844
+ if (tracks.length) play(tracks, 0);
845
+ } finally { btn.disabled = false; }
846
+ });
847
+ });
848
+ $$('[data-play-song-anime]', scope).forEach(btn => {
849
+ btn.addEventListener('click', async (e) => {
850
+ e.preventDefault();
851
+ e.stopPropagation();
852
+ const aid = btn.dataset.playSongAnime;
853
+ const themeId = btn.dataset.songTheme;
854
+ btn.disabled = true;
855
+ try {
856
+ const data = await api.anime(aid);
857
+ const a = data.anime;
858
+ if (!a) return;
859
+ const tracks = tracksFromAnime(a);
860
+ if (!tracks.length) return;
861
+ const idx = Math.max(0, tracks.findIndex(t => String(t.themeId) === String(themeId)));
862
+ play(tracks, idx);
863
+ } finally { btn.disabled = false; }
864
+ });
865
+ });
866
+ }
867
+
868
+ function renderHome() {
869
+ const hour = new Date().getHours();
870
+ const greet = hour < 5 ? 'Good evening' : hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
871
+
872
+ view.innerHTML = `
873
+ <div class="ms-home-hero">
874
+ <h1 class="ms-home-greet">${greet}</h1>
875
+ <div class="ms-quick-grid" id="home-quick"></div>
876
+ </div>
877
+
878
+ <div class="ms-section-title">
879
+ <h2>Newest themes</h2>
880
+ <a href="#/recent">View all</a>
881
+ </div>
882
+ <div class="ms-card-grid" id="home-recent">${loadingHTML()}</div>
883
+
884
+ <div class="ms-section-title">
885
+ <h2>Popular artists</h2>
886
+ <a href="#/artists">View all</a>
887
+ </div>
888
+ <div class="ms-card-grid" id="home-artists">${loadingHTML()}</div>
889
+
890
+ <div class="ms-section-title">
891
+ <h2>From your library</h2>
892
+ <a href="#/liked">View all</a>
893
+ </div>
894
+ <div id="home-liked"></div>
895
+
896
+ <div class="ms-section-title">
897
+ <h2>Browse by year</h2>
898
+ </div>
899
+ <div class="ms-card-grid ms-years-grid" id="home-years"></div>
900
+ `;
901
+
902
+ const quickEl = $('#home-quick');
903
+ const quickIds = [];
904
+ Array.from(state.liked).slice(0, 4).forEach(id => quickIds.push(id));
905
+ state.history.slice(0, 6).forEach(id => { if (!quickIds.includes(id)) quickIds.push(id); });
906
+ const quickTracks = loadStoredTracks(quickIds.slice(0, 6));
907
+ if (quickTracks.length) {
908
+ quickEl.innerHTML = quickTracks.map(t => `
909
+ <a class="ms-quick-tile" href="#/anime/${encodeURIComponent(t.animeId)}" data-quick-tid="${esc(t.id)}">
910
+ ${t.cover ? `<img class="ms-quick-cover" src="${esc(t.cover)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : `<div class="ms-quick-cover" style="background:linear-gradient(135deg,#4c1d95,#1e1b4b)"></div>`}
911
+ <span class="ms-quick-name">${esc(t.title)}</span>
912
+ <button class="ms-quick-play" data-play-quick="${esc(t.id)}" aria-label="Play">
913
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
914
+ </button>
915
+ </a>
916
+ `).join('');
917
+ $$('[data-play-quick]', quickEl).forEach(btn => {
918
+ btn.addEventListener('click', (e) => {
919
+ e.preventDefault(); e.stopPropagation();
920
+ const t = quickTracks.find(x => x.id === btn.dataset.playQuick);
921
+ if (t) play([t], 0);
922
+ });
923
+ });
924
+ } else {
925
+ quickEl.innerHTML = `<div class="ms-quick-empty">Like songs and they'll show up here for one-tap play.</div>`;
926
+ }
927
+
928
+ api.recent().then(d => {
929
+ const items = (d.anime || []).slice(0, 12);
930
+ $('#home-recent').innerHTML = items.length
931
+ ? items.map(animeCardHTML).join('')
932
+ : emptyHTML('No themes found', '');
933
+ bindCardClicks($('#home-recent'));
934
+ }).catch(() => { $('#home-recent').innerHTML = emptyHTML('Could not load', 'Try again in a moment.'); });
935
+
936
+ api.artists().then(d => {
937
+ const items = (d.artists || []).slice(0, 8);
938
+ $('#home-artists').innerHTML = items.length
939
+ ? items.map(artistCardHTML).join('')
940
+ : emptyHTML('No artists', '');
941
+ }).catch(() => { $('#home-artists').innerHTML = emptyHTML('Could not load', 'Try again in a moment.'); });
942
+
943
+ const likedEl = $('#home-liked');
944
+ const likedTracks = loadStoredTracks(Array.from(state.liked).slice(0, 6));
945
+ if (likedTracks.length) {
946
+ likedEl.innerHTML = `<div class="ms-card-grid">${likedTracks.map(t => `
947
+ <div class="ms-card" data-tid="${esc(t.id)}">
948
+ <div class="ms-card-cover-wrap">
949
+ ${t.cover ? `<img class="ms-card-cover" src="${esc(t.cover)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : placeholderCover(t.title)}
950
+ <button class="ms-card-play" data-play-liked="${esc(t.id)}" aria-label="Play">
951
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
952
+ </button>
953
+ </div>
954
+ <div class="ms-card-title">${esc(t.title)}</div>
955
+ <div class="ms-card-sub">${esc((t.artists || []).map(a => a.name).join(', ') || t.animeName || '')}</div>
956
+ </div>
957
+ `).join('')}</div>`;
958
+ $$('[data-play-liked]', likedEl).forEach(btn => {
959
+ btn.addEventListener('click', (e) => {
960
+ e.preventDefault(); e.stopPropagation();
961
+ const t = likedTracks.find(x => x.id === btn.dataset.playLiked);
962
+ if (t) play([t], 0);
963
+ });
964
+ });
965
+ } else {
966
+ likedEl.innerHTML = `<div class="ms-empty" style="padding:36px 12px"><h3>Your library is empty</h3><p>Like songs to see them here.</p></div>`;
967
+ }
968
+
969
+ const yearsEl = $('#home-years');
970
+ const cy = new Date().getFullYear();
971
+ const years = [];
972
+ for (let y = cy; y >= cy - 7; y--) years.push(y);
973
+ const yearGradients = [
974
+ ['#7c3aed', '#1e1b4b'], ['#5b21b6', '#0f172a'], ['#4c1d95', '#1e293b'],
975
+ ['#9d6ef8', '#312e81'], ['#6d28d9', '#0b1023'], ['#7c3aed', '#0f0d1f'],
976
+ ['#5b21b6', '#1f1430'], ['#4c1d95', '#0a0712'],
977
+ ];
978
+ yearsEl.innerHTML = years.map((y, i) => {
979
+ const [a, b] = yearGradients[i % yearGradients.length];
980
+ return `<a class="ms-year-card" href="#/year/${y}">
981
+ <div class="ms-year-cover" style="background:linear-gradient(135deg, ${a} 0%, ${b} 100%);">
982
+ <span class="ms-year-num">${y}</span>
983
+ </div>
984
+ <div class="ms-card-title">${y}</div>
985
+ <div class="ms-card-sub">Themes from ${y}</div>
986
+ </a>`;
987
+ }).join('');
988
+ }
989
+
990
+ function renderRecent() {
991
+ view.innerHTML = `<div class="ms-section-title" style="margin-top:14px"><h2>Newest themes</h2></div>
992
+ <div class="ms-card-grid" id="recent-grid">${loadingHTML()}</div>`;
993
+ api.recent().then(d => {
994
+ const items = d.anime || [];
995
+ $('#recent-grid').innerHTML = items.length
996
+ ? items.map(animeCardHTML).join('')
997
+ : emptyHTML('No themes available', '');
998
+ bindCardClicks($('#recent-grid'));
999
+ });
1000
+ }
1001
+
1002
+ function renderYear(y) {
1003
+ view.innerHTML = `<div class="ms-section-title" style="margin-top:14px"><h2>Themes from ${esc(y)}</h2></div>
1004
+ <div class="ms-card-grid" id="year-grid">${loadingHTML()}</div>`;
1005
+ api.year(y).then(d => {
1006
+ const items = d.anime || [];
1007
+ $('#year-grid').innerHTML = items.length
1008
+ ? items.map(animeCardHTML).join('')
1009
+ : emptyHTML('Nothing here yet', `No themes catalogued for ${y}.`);
1010
+ bindCardClicks($('#year-grid'));
1011
+ });
1012
+ }
1013
+
1014
+ function renderTopArtists() {
1015
+ view.innerHTML = `<div class="ms-section-title" style="margin-top:14px"><h2>Artists</h2></div>
1016
+ <div class="ms-card-grid" id="artists-grid">${loadingHTML()}</div>`;
1017
+ api.artists().then(d => {
1018
+ const items = d.artists || [];
1019
+ $('#artists-grid').innerHTML = items.length
1020
+ ? items.map(artistCardHTML).join('')
1021
+ : emptyHTML('No artists', '');
1022
+ });
1023
+ }
1024
+
1025
+ function renderLiked() {
1026
+ const tracks = loadStoredTracks(Array.from(state.liked));
1027
+ if (!tracks.length) {
1028
+ view.innerHTML = `<div class="ms-album-hero">
1029
+ <div class="ms-album-cover" style="display:flex;align-items:center;justify-content:center;">
1030
+ <svg viewBox="0 0 24 24" width="80" height="80" fill="#fff" style="opacity:0.85"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
1031
+ </div>
1032
+ <div class="ms-album-info">
1033
+ <div class="ms-album-eyebrow">Playlist</div>
1034
+ <h1 class="ms-album-title">Liked Songs</h1>
1035
+ <div class="ms-album-meta">0 tracks</div>
1036
+ </div>
1037
+ </div>${emptyHTML('No liked songs yet', 'Tap the heart on any track to save it here.')}`;
1038
+ return;
1039
+ }
1040
+ renderTracklist({
1041
+ eyebrow: 'Playlist',
1042
+ title: 'Liked Songs',
1043
+ cover: tracks[0].cover,
1044
+ countLabel: `${tracks.length} track${tracks.length > 1 ? 's' : ''}`,
1045
+ tracks,
1046
+ asPlaylist: true,
1047
+ });
1048
+ }
1049
+
1050
+ function renderHistory() {
1051
+ const tracks = loadStoredTracks(state.history);
1052
+ if (!tracks.length) {
1053
+ view.innerHTML = emptyHTML('Nothing recently played', 'Play some songs to build your history.');
1054
+ return;
1055
+ }
1056
+ renderTracklist({
1057
+ eyebrow: 'History',
1058
+ title: 'Recently Played',
1059
+ cover: tracks[0].cover,
1060
+ countLabel: `${tracks.length} track${tracks.length > 1 ? 's' : ''}`,
1061
+ tracks,
1062
+ asPlaylist: true,
1063
+ });
1064
+ }
1065
+
1066
+ async function renderAnime(id) {
1067
+ view.innerHTML = loadingHTML('Loading album…');
1068
+ try {
1069
+ const data = await api.anime(id);
1070
+ const a = data.anime;
1071
+ if (!a) { view.innerHTML = emptyHTML('Not found', 'This anime has no themes catalogued.'); return; }
1072
+ const tracks = tracksFromAnime(a);
1073
+ renderTracklist({
1074
+ eyebrow: a.media_format || 'Anime',
1075
+ title: a.name,
1076
+ cover: coverUrl(a),
1077
+ countLabel: `${tracks.length} track${tracks.length === 1 ? '' : 's'} · ${a.year || ''}${a.season ? ' · ' + a.season : ''}`,
1078
+ tracks,
1079
+ asPlaylist: false,
1080
+ });
1081
+ } catch (e) {
1082
+ view.innerHTML = emptyHTML('Could not load', 'Please try again.');
1083
+ }
1084
+ }
1085
+
1086
+ async function renderArtist(slug) {
1087
+ view.innerHTML = loadingHTML('Loading artist…');
1088
+ try {
1089
+ const data = await api.artist(slug);
1090
+ const a = data.artist;
1091
+ if (!a) { view.innerHTML = emptyHTML('Artist not found', ''); return; }
1092
+ const tracks = [];
1093
+ for (const song of (a.songs || [])) {
1094
+ for (const theme of (song.animethemes || [])) {
1095
+ if (!theme.anime) continue;
1096
+ const merged = { ...theme, song };
1097
+ const tk = trackFromTheme(theme.anime, merged);
1098
+ if (tk) tracks.push(tk);
1099
+ }
1100
+ }
1101
+ renderTracklist({
1102
+ eyebrow: 'Artist',
1103
+ title: a.name,
1104
+ cover: artistImageUrl(a),
1105
+ round: true,
1106
+ countLabel: `${tracks.length} track${tracks.length === 1 ? '' : 's'}`,
1107
+ tracks,
1108
+ asPlaylist: true,
1109
+ });
1110
+ } catch (e) {
1111
+ view.innerHTML = emptyHTML('Could not load', 'Please try again.');
1112
+ }
1113
+ }
1114
+
1115
+ function renderTracklist({ eyebrow, title, cover, countLabel, tracks, asPlaylist, round }) {
1116
+ const heroCoverHTML = cover
1117
+ ? `<img src="${esc(cover)}" alt="" referrerpolicy="no-referrer">`
1118
+ : `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:4rem;font-weight:900;">${esc(title.slice(0, 1))}</div>`;
1119
+ view.innerHTML = `
1120
+ <div class="ms-album-hero">
1121
+ <div class="ms-album-cover" ${round ? 'style="border-radius:50%;"' : ''}>${heroCoverHTML}</div>
1122
+ <div class="ms-album-info">
1123
+ <div class="ms-album-eyebrow">${esc(eyebrow)}</div>
1124
+ <h1 class="ms-album-title">${esc(title)}</h1>
1125
+ <div class="ms-album-meta">${esc(countLabel)}</div>
1126
+ </div>
1127
+ </div>
1128
+ <div class="ms-album-actions">
1129
+ <button class="ms-play-big" id="album-play" aria-label="Play all">
1130
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
1131
+ </button>
1132
+ <button class="ms-icon-btn" id="album-shuffle" aria-label="Shuffle">
1133
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
1134
+ </button>
1135
+ </div>
1136
+ <div class="ms-tracks">
1137
+ ${tracks.length ? `
1138
+ <div class="ms-tracks-head">
1139
+ <span style="text-align:center">#</span>
1140
+ <span>Title</span>
1141
+ <span class="ms-th-anime">${asPlaylist ? 'Anime' : 'Type'}</span>
1142
+ <span class="ms-th-tag">${asPlaylist ? 'Type' : 'Episodes'}</span>
1143
+ <span></span>
1144
+ </div>
1145
+ ${tracks.map((t, i) => trackRowHTML(t, i, asPlaylist)).join('')}
1146
+ ` : emptyHTML('No tracks found', '')}
1147
+ </div>
1148
+ `;
1149
+ const playAll = $('#album-play');
1150
+ if (playAll) playAll.addEventListener('click', () => { if (tracks.length) play(tracks, 0); });
1151
+ const shuf = $('#album-shuffle');
1152
+ if (shuf) shuf.addEventListener('click', () => {
1153
+ if (!tracks.length) return;
1154
+ const shuffled = tracks.slice().sort(() => Math.random() - 0.5);
1155
+ state.shuffle = false;
1156
+ play(shuffled, 0);
1157
+ });
1158
+ bindTrackRows(tracks);
1159
+ updateNowPlayingMarkers();
1160
+ }
1161
+
1162
+ function trackRowHTML(t, i, asPlaylist) {
1163
+ const tagClass = (t.typeKind || '').toUpperCase() === 'ED' ? 'ed' : ((t.typeKind || '').toUpperCase() === 'IN' ? 'in' : '');
1164
+ const artistsText = (t.artists || []).map(a => `<a href="#/artist/${encodeURIComponent(a.slug || a.id)}" data-stop>${esc(a.name)}</a>`).join(', ');
1165
+ const middleCol = asPlaylist
1166
+ ? `<a class="ms-track-anime" href="#/anime/${encodeURIComponent(t.animeId)}" data-stop>${esc(t.animeName)}</a>`
1167
+ : `<span class="ms-track-tag-cell"><span class="ms-track-tag ${tagClass}">${esc(t.type)}</span></span>`;
1168
+ const fourthCol = asPlaylist
1169
+ ? `<span class="ms-track-tag-cell"><span class="ms-track-tag ${tagClass}">${esc(t.type)}</span></span>`
1170
+ : `<span class="ms-track-anime">${esc(t.episodes || '—')}</span>`;
1171
+ return `<div class="ms-track" data-tid="${esc(t.id)}" data-idx="${i}">
1172
+ <div class="ms-track-num">
1173
+ <span class="ms-num">${i + 1}</span>
1174
+ <span class="ms-now-bars"><span></span><span></span><span></span></span>
1175
+ <span class="ms-play-mini"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></span>
1176
+ </div>
1177
+ <div class="ms-track-meta">
1178
+ ${t.cover ? `<img class="ms-track-cover" src="${esc(t.cover)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : '<div class="ms-track-cover"></div>'}
1179
+ <div class="ms-track-text">
1180
+ <div class="ms-track-title">${esc(t.title)}</div>
1181
+ <div class="ms-track-sub">${artistsText || esc(t.animeName)}</div>
1182
+ </div>
1183
+ </div>
1184
+ ${middleCol}
1185
+ ${fourthCol}
1186
+ <button class="ms-track-like ${state.liked.has(t.id) ? 'is-liked' : ''}" data-tid="${esc(t.id)}" data-stop aria-label="Like">
1187
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
1188
+ </button>
1189
+ </div>`;
1190
+ }
1191
+
1192
+ function bindTrackRows(tracks) {
1193
+ $$('.ms-track', view).forEach(row => {
1194
+ row.addEventListener('click', (e) => {
1195
+ if (e.target.closest('[data-stop]')) return;
1196
+ const idx = parseInt(row.dataset.idx, 10);
1197
+ if (!isNaN(idx)) play(tracks, idx);
1198
+ });
1199
+ });
1200
+ $$('.ms-track-like', view).forEach(btn => {
1201
+ btn.addEventListener('click', (e) => {
1202
+ e.preventDefault(); e.stopPropagation();
1203
+ const id = btn.dataset.tid;
1204
+ const t = tracks.find(x => x.id === id);
1205
+ if (t) toggleLike(t);
1206
+ });
1207
+ });
1208
+ }
1209
+
1210
+ function songRowHTML(track, idx) {
1211
+ const artistsText = (track.artists || []).map(a => `<a href="#/artist/${encodeURIComponent(a.slug || a.id)}" data-stop>${esc(a.name)}</a>`).join(', ');
1212
+ return `<div class="ms-song-row" data-tid="${esc(track.id)}" data-idx="${idx}">
1213
+ <div class="ms-song-cover-wrap">
1214
+ ${track.cover ? `<img class="ms-song-cover" src="${esc(track.cover)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : '<div class="ms-song-cover"></div>'}
1215
+ <span class="ms-song-play"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></span>
1216
+ </div>
1217
+ <div class="ms-song-text">
1218
+ <div class="ms-song-title">${esc(track.title)}</div>
1219
+ <div class="ms-song-sub">${artistsText || '<a href="#/anime/' + encodeURIComponent(track.animeId) + '" data-stop>' + esc(track.animeName) + '</a>'}</div>
1220
+ </div>
1221
+ <button class="ms-song-like ${state.liked.has(track.id) ? 'is-liked' : ''}" data-tid="${esc(track.id)}" data-stop aria-label="Like">
1222
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
1223
+ </button>
1224
+ </div>`;
1225
+ }
1226
+
1227
+ function bindSongRows(scope, tracks) {
1228
+ $$('.ms-song-row', scope).forEach(row => {
1229
+ row.addEventListener('click', (e) => {
1230
+ if (e.target.closest('[data-stop]')) return;
1231
+ const idx = parseInt(row.dataset.idx, 10);
1232
+ if (!isNaN(idx) && tracks[idx]) play(tracks, idx);
1233
+ });
1234
+ });
1235
+ $$('.ms-song-like', scope).forEach(btn => {
1236
+ btn.addEventListener('click', (e) => {
1237
+ e.preventDefault(); e.stopPropagation();
1238
+ const id = btn.dataset.tid;
1239
+ const t = tracks.find(x => x.id === id);
1240
+ if (t) toggleLike(t);
1241
+ });
1242
+ });
1243
+ }
1244
+
1245
+ async function renderSearch(q) {
1246
+ if (!q) { renderHome(); return; }
1247
+ view.innerHTML = loadingHTML(`Searching "${q}"…`);
1248
+ const filter = (state.searchFilter || 'all');
1249
+ try {
1250
+ const data = await api.search(q);
1251
+ window.__lastSearch = data;
1252
+ const anime = data.anime || [];
1253
+ const themes = data.animethemes || [];
1254
+ const artists = data.artists || [];
1255
+ const songs = data.songs || [];
1256
+
1257
+ const themeTracks = themes
1258
+ .map(th => th.anime ? trackFromTheme(th.anime, th) : null)
1259
+ .filter(Boolean);
1260
+
1261
+ const tabsHTML = `<div class="ms-search-tabs">
1262
+ ${['all','anime','songs','artists'].map(f =>
1263
+ `<button class="ms-tab ${filter===f?'active':''}" data-filter="${f}">${f[0].toUpperCase()+f.slice(1)}</button>`
1264
+ ).join('')}
1265
+ </div>`;
1266
+
1267
+ const noResults = !anime.length && !artists.length && !songs.length && !themeTracks.length;
1268
+ if (noResults) {
1269
+ view.innerHTML = `${tabsHTML}${emptyHTML(`No results for "${q}"`, 'Try a different anime, song, or artist.')}`;
1270
+ bindSearchTabs(q);
1271
+ return;
1272
+ }
1273
+
1274
+ let topResultHTML = '';
1275
+ const topAnime = anime[0];
1276
+ if (topAnime && (filter === 'all' || filter === 'anime')) {
1277
+ const cover = coverUrl(topAnime);
1278
+ topResultHTML = `<div class="ms-top-result">
1279
+ <div>
1280
+ <div class="ms-section-title" style="margin:0 0 14px"><h2>Top result</h2></div>
1281
+ <a class="ms-top-card" href="#/anime/${encodeURIComponent(topAnime.id)}">
1282
+ ${cover ? `<img class="ms-top-cover" src="${esc(cover)}" alt="" referrerpolicy="no-referrer">` : `<div class="ms-top-cover" style="background:linear-gradient(135deg,#4c1d95,#1e1b4b)"></div>`}
1283
+ <div class="ms-top-name">${esc(topAnime.name)}</div>
1284
+ <span class="ms-top-tag">Anime${topAnime.year ? ' · ' + esc(topAnime.year) : ''}</span>
1285
+ <button class="ms-top-play" data-play-anime="${esc(topAnime.id)}" aria-label="Play">
1286
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
1287
+ </button>
1288
+ </a>
1289
+ </div>
1290
+ ${themeTracks.length ? `<div class="ms-top-songs">
1291
+ <h3>Songs</h3>
1292
+ <div class="ms-songlist" id="search-top-songs">
1293
+ ${themeTracks.slice(0, 4).map((tk, i) => songRowHTML(tk, i)).join('')}
1294
+ </div>
1295
+ </div>` : ''}
1296
+ </div>`;
1297
+ }
1298
+
1299
+ const animeBlock = (filter === 'all' || filter === 'anime') && anime.length ? `<div class="ms-search-block">
1300
+ <div class="ms-section-title"><h2>Anime</h2></div>
1301
+ <div class="ms-card-grid">${anime.slice(0, filter === 'anime' ? 24 : 8).map(animeCardHTML).join('')}</div>
1302
+ </div>` : '';
1303
+
1304
+ const artistsBlock = (filter === 'all' || filter === 'artists') && artists.length ? `<div class="ms-search-block">
1305
+ <div class="ms-section-title"><h2>Artists</h2></div>
1306
+ <div class="ms-card-grid">${artists.slice(0, filter === 'artists' ? 24 : 8).map(artistCardHTML).join('')}</div>
1307
+ </div>` : '';
1308
+
1309
+ let songsBlock = '';
1310
+ if ((filter === 'all' || filter === 'songs')) {
1311
+ const songTracksAll = themeTracks.length ? themeTracks : [];
1312
+ if (songTracksAll.length) {
1313
+ const limit = filter === 'songs' ? songTracksAll.length : 8;
1314
+ songsBlock = `<div class="ms-search-block">
1315
+ <div class="ms-section-title"><h2>Songs</h2></div>
1316
+ <div class="ms-songlist" id="search-songs-list">
1317
+ ${songTracksAll.slice(0, limit).map((tk, i) => songRowHTML(tk, i)).join('')}
1318
+ </div>
1319
+ </div>`;
1320
+ } else if (songs.length) {
1321
+ songsBlock = `<div class="ms-search-block">
1322
+ <div class="ms-section-title"><h2>Songs</h2></div>
1323
+ <div class="ms-card-grid">${songs.slice(0, 8).map(songCardHTML).join('')}</div>
1324
+ </div>`;
1325
+ }
1326
+ }
1327
+
1328
+ view.innerHTML = `
1329
+ ${tabsHTML}
1330
+ ${topResultHTML}
1331
+ ${animeBlock}
1332
+ ${songsBlock}
1333
+ ${artistsBlock}
1334
+ `;
1335
+
1336
+ bindSearchTabs(q);
1337
+ bindCardClicks(view);
1338
+
1339
+ const topListEl = $('#search-top-songs');
1340
+ if (topListEl) bindSongRows(topListEl, themeTracks.slice(0, 4));
1341
+ const songsListEl = $('#search-songs-list');
1342
+ if (songsListEl) {
1343
+ const limit = filter === 'songs' ? themeTracks.length : 8;
1344
+ bindSongRows(songsListEl, themeTracks.slice(0, limit));
1345
+ }
1346
+ updateNowPlayingMarkers();
1347
+ } catch (e) {
1348
+ console.error('[music] search failed', e);
1349
+ view.innerHTML = emptyHTML('Search failed', 'Please try again.');
1350
+ }
1351
+ }
1352
+
1353
+ function bindSearchTabs(q) {
1354
+ $$('.ms-tab', view).forEach(tab => {
1355
+ tab.addEventListener('click', () => {
1356
+ state.searchFilter = tab.dataset.filter;
1357
+ renderSearch(q);
1358
+ });
1359
+ });
1360
+ }
1361
+
1362
+ function closeSidebarIfMobile() {
1363
+ if (window.innerWidth <= 1024) {
1364
+ $('#music-sidebar').classList.remove('open');
1365
+ $('#ms-sidebar-backdrop').classList.remove('active');
1366
+ }
1367
+ }
1368
+
1369
+ function openYearSidebarLinks() {
1370
+ const yearGrid = $('#ms-year-grid');
1371
+ const cy = new Date().getFullYear();
1372
+ const years = [];
1373
+ for (let y = cy; y >= cy - 9; y--) years.push(y);
1374
+ yearGrid.innerHTML = years.map(y => `<a data-route="#/year/${y}" href="#/year/${y}">${y}</a>`).join('');
1375
+ }
1376
+
1377
+ function init() {
1378
+ audio.volume = state.volume;
1379
+ setVolUI();
1380
+
1381
+ openYearSidebarLinks();
1382
+
1383
+ let searchTimer;
1384
+ const searchInput = $('#ms-search-input');
1385
+ searchInput.addEventListener('input', () => {
1386
+ const q = searchInput.value.trim();
1387
+ $('#ms-search-clear').hidden = !q;
1388
+ clearTimeout(searchTimer);
1389
+ if (!q) {
1390
+ if (location.hash.startsWith('#/search')) navigate('#/');
1391
+ return;
1392
+ }
1393
+ searchTimer = setTimeout(() => {
1394
+ navigate('#/search?q=' + encodeURIComponent(q));
1395
+ }, 320);
1396
+ });
1397
+ searchInput.addEventListener('keydown', (e) => {
1398
+ if (e.key === 'Escape') { searchInput.value = ''; navigate('#/'); }
1399
+ });
1400
+ $('#ms-search-clear').addEventListener('click', () => {
1401
+ searchInput.value = '';
1402
+ $('#ms-search-clear').hidden = true;
1403
+ navigate('#/');
1404
+ searchInput.focus();
1405
+ });
1406
+
1407
+ $('#pb-play').addEventListener('click', togglePlay);
1408
+ $('#pb-play-mobile').addEventListener('click', togglePlay);
1409
+ $('#pb-prev').addEventListener('click', prev);
1410
+ $('#pb-next').addEventListener('click', () => next(false));
1411
+ $('#pb-shuffle').addEventListener('click', () => {
1412
+ state.shuffle = !state.shuffle;
1413
+ $('#pb-shuffle').classList.toggle('is-on', state.shuffle);
1414
+ });
1415
+ $('#pb-loop').addEventListener('click', () => {
1416
+ state.loop = !state.loop;
1417
+ $('#pb-loop').classList.toggle('is-on', state.loop);
1418
+ audio.loop = false;
1419
+ });
1420
+ $('#pb-mute').addEventListener('click', () => {
1421
+ state.muted = !state.muted;
1422
+ audio.muted = state.muted;
1423
+ video.muted = state.muted;
1424
+ setVolUI();
1425
+ });
1426
+ $('#pb-like').addEventListener('click', () => {
1427
+ const t = state.queue[state.index];
1428
+ if (t) toggleLike(t);
1429
+ });
1430
+
1431
+ bindBar($('#pb-seek'), (pct) => {
1432
+ const m = activeMedia();
1433
+ if (m.duration) m.currentTime = pct * m.duration;
1434
+ setSeek(pct * (m.duration || 0));
1435
+ });
1436
+ bindBar($('#pb-vol'), (pct) => {
1437
+ state.volume = pct;
1438
+ state.muted = false;
1439
+ audio.muted = false;
1440
+ video.muted = false;
1441
+ audio.volume = pct;
1442
+ video.volume = pct;
1443
+ persistVol();
1444
+ setVolUI();
1445
+ });
1446
+
1447
+ [audio, video].forEach((m) => {
1448
+ m.addEventListener('play', () => {
1449
+ if (m === activeMedia()) { state.isPlaying = true; setPlayUI(true); }
1450
+ if (m === video) showStageLoading(false);
1451
+ });
1452
+ m.addEventListener('pause', () => {
1453
+ if (m === activeMedia()) { state.isPlaying = false; setPlayUI(false); }
1454
+ if (m === video) showStageLoading(false);
1455
+ });
1456
+ m.addEventListener('ended', () => {
1457
+ if (m !== activeMedia()) return;
1458
+ if (state.loop) { m.currentTime = 0; m.play().catch(() => { }); }
1459
+ else next(true);
1460
+ });
1461
+ m.addEventListener('timeupdate', () => {
1462
+ if (m === activeMedia() && !state.scrubbing) setSeek();
1463
+ if (m === activeMedia() && _syncedLines.length) updateLyricsHighlight(m.currentTime);
1464
+ });
1465
+
1466
+ m.addEventListener('play', () => {
1467
+ if (m !== activeMedia()) return;
1468
+ if (_lyricRaf) cancelAnimationFrame(_lyricRaf);
1469
+ const tick = () => {
1470
+ if (m !== activeMedia() || m.paused || m.ended) {
1471
+ _lyricRaf = null;
1472
+ return;
1473
+ }
1474
+ if (_syncedLines.length && document.body.classList.contains('np-lyrics-open')) {
1475
+ updateLyricsHighlight(m.currentTime);
1476
+ }
1477
+ _lyricRaf = requestAnimationFrame(tick);
1478
+ };
1479
+ _lyricRaf = requestAnimationFrame(tick);
1480
+ });
1481
+ m.addEventListener('loadedmetadata', () => {
1482
+ if (m !== activeMedia()) return;
1483
+ setSeek();
1484
+
1485
+ if (_rawSyncedLines.length) {
1486
+ recomputeSyncedLines();
1487
+ renderSyncedLyrics(_syncedLines);
1488
+ }
1489
+ });
1490
+ m.addEventListener('durationchange', () => {
1491
+ if (m !== activeMedia() || !_rawSyncedLines.length) return;
1492
+ recomputeSyncedLines();
1493
+ renderSyncedLyrics(_syncedLines);
1494
+ });
1495
+
1496
+ m.addEventListener('seeked', () => {
1497
+ if (m !== activeMedia() || !_syncedLines.length) return;
1498
+ _activeLyricIdx = -2;
1499
+ updateLyricsHighlight(m.currentTime || 0);
1500
+ });
1501
+ m.addEventListener('error', () => {
1502
+ if (m !== activeMedia()) return;
1503
+ if (m === video) showStageLoading(false);
1504
+ const t = state.queue[state.index];
1505
+ if (t) console.warn('[music] failed to play', t.title);
1506
+ setTimeout(() => next(true), 800);
1507
+ });
1508
+ });
1509
+
1510
+ video.addEventListener('waiting', () => { if (mediaMode === 'video' && !video.paused) showStageLoading(true); });
1511
+ video.addEventListener('loadstart', () => { if (mediaMode === 'video') showStageLoading(true); });
1512
+ video.addEventListener('seeking', () => { if (mediaMode === 'video') showStageLoading(true); });
1513
+ video.addEventListener('stalled', () => { if (mediaMode === 'video' && !video.paused) showStageLoading(true); });
1514
+ video.addEventListener('canplay', () => showStageLoading(false));
1515
+ video.addEventListener('canplaythrough', () => showStageLoading(false));
1516
+ video.addEventListener('playing', () => showStageLoading(false));
1517
+ video.addEventListener('seeked', () => showStageLoading(false));
1518
+ video.addEventListener('progress', () => { if (video.readyState >= 3) showStageLoading(false); });
1519
+
1520
+ $('#pb-expand').addEventListener('click', openNP);
1521
+ $('#np-close').addEventListener('click', closeNP);
1522
+ $('#np-shuffle').addEventListener('click', () => {
1523
+ state.shuffle = !state.shuffle;
1524
+ $('#pb-shuffle').classList.toggle('is-on', state.shuffle);
1525
+ $('#np-shuffle').classList.toggle('is-on', state.shuffle);
1526
+ });
1527
+ $('#np-loop').addEventListener('click', () => {
1528
+ state.loop = !state.loop;
1529
+ $('#pb-loop').classList.toggle('is-on', state.loop);
1530
+ $('#np-loop').classList.toggle('is-on', state.loop);
1531
+ });
1532
+ $('#np-prev').addEventListener('click', prev);
1533
+ $('#np-next').addEventListener('click', () => next(false));
1534
+ $('#np-play').addEventListener('click', togglePlay);
1535
+ $('#np-like').addEventListener('click', () => {
1536
+ const t = state.queue[state.index];
1537
+ if (!t) return;
1538
+ toggleLike(t);
1539
+ const btn = $('#np-like');
1540
+ if (btn) {
1541
+ btn.classList.remove('just-liked');
1542
+
1543
+ void btn.offsetWidth;
1544
+ btn.classList.add('just-liked');
1545
+ }
1546
+ });
1547
+ bindBar($('#np-seek'), (pct) => {
1548
+ const m = activeMedia();
1549
+ if (m.duration) m.currentTime = pct * m.duration;
1550
+ setSeek(pct * (m.duration || 0));
1551
+ });
1552
+ function setPanel(which) {
1553
+
1554
+ $('#np-lyrics').hidden = which !== 'lyrics';
1555
+ $('#np-queue').hidden = which !== 'queue';
1556
+ $('#np-lyrics-btn').classList.toggle('is-on', which === 'lyrics');
1557
+ $('#np-queue-btn').classList.toggle('is-on', which === 'queue');
1558
+
1559
+ document.body.classList.toggle('np-lyrics-open', which === 'lyrics');
1560
+ if (which === 'queue') renderQueue();
1561
+
1562
+ if (which === 'lyrics' && _syncedLines.length) {
1563
+ _activeLyricIdx = -2;
1564
+ requestAnimationFrame(() => {
1565
+ updateLyricsHighlight(activeMedia().currentTime || 0);
1566
+ });
1567
+ }
1568
+ }
1569
+ $('#np-video-btn').addEventListener('click', () => {
1570
+ const t = state.queue[state.index];
1571
+ if (!t) return;
1572
+ if (!t.videoLink) { toast('No video available for this track'); return; }
1573
+ setPanel(null);
1574
+ setVideoMode(mediaMode !== 'video');
1575
+ });
1576
+ $('#np-lyrics-btn').addEventListener('click', () => {
1577
+ const showing = $('#np-lyrics').hidden;
1578
+ setPanel(showing ? 'lyrics' : null);
1579
+ });
1580
+ $('#np-queue-btn').addEventListener('click', () => {
1581
+ const showing = $('#np-queue').hidden;
1582
+ setPanel(showing ? 'queue' : null);
1583
+ });
1584
+
1585
+ $('#np-lyrics-close')?.addEventListener('click', () => setPanel(null));
1586
+ $('#np-queue-close') ?.addEventListener('click', () => setPanel(null));
1587
+
1588
+ $('#np-bg').addEventListener('click', closeNP);
1589
+ document.addEventListener('keydown', (e) => {
1590
+ if (e.key === 'Escape' && !npModal.hidden) closeNP();
1591
+ });
1592
+
1593
+ $('#pb-title').addEventListener('click', () => {
1594
+ if (playerBar.dataset.empty === '1') return;
1595
+ openNP();
1596
+ });
1597
+
1598
+ $('#pb-dismiss')?.addEventListener('click', () => {
1599
+ try { audio.pause(); video.pause(); } catch (_) {}
1600
+ document.body.classList.add('pb-hidden');
1601
+ });
1602
+
1603
+ $('#ms-back').addEventListener('click', () => {
1604
+ if (state.navStack.length > 1) {
1605
+ const cur = state.navStack.pop();
1606
+ state.navFwd.push(cur);
1607
+ const prev = state.navStack[state.navStack.length - 1];
1608
+ state.navStack.pop();
1609
+ location.hash = prev;
1610
+ }
1611
+ });
1612
+ $('#ms-fwd').addEventListener('click', () => {
1613
+ const fwd = state.navFwd.pop();
1614
+ if (fwd) location.hash = fwd;
1615
+ });
1616
+
1617
+ $('#ms-sidebar-toggle').addEventListener('click', () => {
1618
+ const isDesktop = window.innerWidth > 1024;
1619
+ if (isDesktop) {
1620
+ $('#music-app').classList.toggle('sb-collapsed');
1621
+ } else {
1622
+ $('#music-sidebar').classList.toggle('open');
1623
+ $('#ms-sidebar-backdrop').classList.toggle('active');
1624
+ }
1625
+ });
1626
+ $('#ms-sidebar-backdrop').addEventListener('click', closeSidebarIfMobile);
1627
+
1628
+ const sidebarEl = $('#music-sidebar');
1629
+ if (sidebarEl) {
1630
+ let downTarget = null;
1631
+ let downX = 0, downY = 0;
1632
+ let pointerHandledAt = 0;
1633
+ const TAP_SLOP = 10;
1634
+
1635
+ sidebarEl.addEventListener('pointerdown', (e) => {
1636
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
1637
+ downTarget = e.target.closest('a, .ms-link, [data-route]');
1638
+ downX = e.clientX; downY = e.clientY;
1639
+ });
1640
+
1641
+ const fireTap = (e) => {
1642
+ const link = e.target.closest('a, .ms-link, [data-route]');
1643
+ if (!link || !sidebarEl.contains(link)) return false;
1644
+
1645
+ if (downTarget && downTarget !== link) return false;
1646
+ if (Math.abs(e.clientX - downX) > TAP_SLOP) return false;
1647
+ if (Math.abs(e.clientY - downY) > TAP_SLOP) return false;
1648
+ const route = link.dataset.route || link.getAttribute('href');
1649
+ if (route && route.startsWith('#')) {
1650
+ navigate(route);
1651
+ } else {
1652
+ closeSidebarIfMobile();
1653
+ }
1654
+ return true;
1655
+ };
1656
+
1657
+ sidebarEl.addEventListener('pointerup', (e) => {
1658
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
1659
+ if (fireTap(e)) pointerHandledAt = performance.now();
1660
+ downTarget = null;
1661
+ });
1662
+
1663
+ sidebarEl.addEventListener('click', (e) => {
1664
+ const link = e.target.closest('a, .ms-link, [data-route]');
1665
+ if (!link || !sidebarEl.contains(link)) return;
1666
+ e.preventDefault();
1667
+ if (performance.now() - pointerHandledAt < 600) return;
1668
+ const route = link.dataset.route || link.getAttribute('href');
1669
+ if (route && route.startsWith('#')) navigate(route);
1670
+ else closeSidebarIfMobile();
1671
+ });
1672
+
1673
+ sidebarEl.addEventListener('pointercancel', () => { downTarget = null; });
1674
+ }
1675
+
1676
+ document.body.classList.remove('header-hidden');
1677
+ const _hdr = document.getElementById('header');
1678
+ if (_hdr) _hdr.classList.remove('is-hidden');
1679
+ const musicMain = document.querySelector('.music-main');
1680
+ if (musicMain && _hdr) {
1681
+ let lastY = 0;
1682
+ let ticking = false;
1683
+ const SCROLL_THRESHOLD = 8;
1684
+ const apply = () => {
1685
+ ticking = false;
1686
+ if (window.innerWidth > 1024) {
1687
+
1688
+ _hdr.classList.remove('is-hidden');
1689
+ document.body.classList.remove('header-hidden');
1690
+ return;
1691
+ }
1692
+ const y = musicMain.scrollTop;
1693
+ const dy = y - lastY;
1694
+ if (Math.abs(dy) < SCROLL_THRESHOLD) return;
1695
+ if (dy > 0 && y > 40) {
1696
+ _hdr.classList.add('is-hidden');
1697
+ document.body.classList.add('header-hidden');
1698
+ } else if (dy < 0) {
1699
+ _hdr.classList.remove('is-hidden');
1700
+ document.body.classList.remove('header-hidden');
1701
+ }
1702
+ lastY = y;
1703
+ };
1704
+ musicMain.addEventListener('scroll', () => {
1705
+ if (ticking) return;
1706
+ ticking = true;
1707
+ requestAnimationFrame(apply);
1708
+ }, { passive: true });
1709
+ window.addEventListener('resize', apply);
1710
+ }
1711
+
1712
+ window.addEventListener('hashchange', handleRoute);
1713
+
1714
+ document.addEventListener('keydown', (e) => {
1715
+ if (e.target.matches('input, textarea')) return;
1716
+ if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
1717
+ else if (e.key === 'ArrowRight' && e.shiftKey) next(false);
1718
+ else if (e.key === 'ArrowLeft' && e.shiftKey) prev();
1719
+ });
1720
+
1721
+ handleRoute();
1722
+ }
1723
+
1724
+ if (document.readyState === 'loading') {
1725
+ document.addEventListener('DOMContentLoaded', init);
1726
+ } else {
1727
+ init();
1728
+ }
1729
+ })();
frontend/src/App.jsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from "react";
2
+ import { Route, Routes, useLocation } from "react-router-dom";
3
+ import Header from "./components/Header.jsx";
4
+ import MobileNav from "./components/MobileNav.jsx";
5
+ import ErrorBoundary from "./components/ErrorBoundary.jsx";
6
+ import Landing from "./pages/Landing.jsx";
7
+ import Home from "./pages/Home.jsx";
8
+ import AuthCallback from "./pages/AuthCallback.jsx";
9
+ import Search from "./pages/Search.jsx";
10
+ import Schedule from "./pages/Schedule.jsx";
11
+ import Anime from "./pages/Anime.jsx";
12
+ import Watch from "./pages/Watch.jsx";
13
+ import Manga from "./pages/Manga.jsx";
14
+ import MangaDetails from "./pages/MangaDetails.jsx";
15
+ import Reader from "./pages/Reader.jsx";
16
+ import Music from "./pages/Music.jsx";
17
+ import Movies from "./pages/Movies.jsx";
18
+ import MovieDetails from "./pages/MovieDetails.jsx";
19
+ import MovieWatch from "./pages/MovieWatch.jsx";
20
+
21
+ function ScrollToTop() {
22
+ const { pathname } = useLocation();
23
+ useEffect(() => {
24
+ window.scrollTo({ top: 0 });
25
+ }, [pathname]);
26
+ return null;
27
+ }
28
+
29
+ export default function App() {
30
+ return (
31
+ <>
32
+ <ScrollToTop />
33
+ <Header />
34
+ <MobileNav />
35
+ <main className="main-content">
36
+ <ErrorBoundary>
37
+ <Routes>
38
+ <Route path="/" element={<Landing />} />
39
+ <Route path="/home" element={<Home />} />
40
+ <Route path="/auth/callback" element={<AuthCallback />} />
41
+ <Route path="/search" element={<Search />} />
42
+ <Route path="/browse" element={<Search />} />
43
+ <Route path="/schedule" element={<Schedule />} />
44
+ <Route path="/anime/:id" element={<Anime />} />
45
+ <Route path="/watch/*" element={<Watch />} />
46
+ <Route path="/manga" element={<Manga />} />
47
+ <Route path="/manga/read" element={<Reader />} />
48
+ <Route path="/manga/:source/:id" element={<MangaDetails />} />
49
+ <Route path="/music" element={<Music />} />
50
+ <Route path="/movies" element={<Movies />} />
51
+ <Route path="/movies/:slug" element={<MovieDetails />} />
52
+ <Route path="/movies/:slug/watch" element={<MovieWatch />} />
53
+ <Route path="*" element={<Landing />} />
54
+ </Routes>
55
+ </ErrorBoundary>
56
+ </main>
57
+ </>
58
+ );
59
+ }
frontend/src/api.js ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_URL = (import.meta.env.VITE_API_URL || "/api").replace(/\/$/, "");
2
+ const STREAM_PROXY = (import.meta.env.VITE_STREAM_PROXY_URL || "/hls").replace(/\/$/, "");
3
+ export const STREAM_KEY = import.meta.env.VITE_STREAM_KEY || "";
4
+ import { getToken } from "./utils/auth.js";
5
+
6
+ async function req(path, options = {}) {
7
+ const res = await fetch(`${API_URL}${path}`, {
8
+ headers: { Accept: "application/json", ...(options.headers || {}) },
9
+ ...options,
10
+ });
11
+ if (!res.ok) {
12
+ let detail = `HTTP ${res.status}`;
13
+ try {
14
+ const body = await res.json();
15
+ detail = body.detail?.error || body.detail?.hint || body.detail || detail;
16
+ // FastAPI validation errors are an array of { msg } objects — flatten them.
17
+ if (Array.isArray(detail)) detail = detail.map((d) => d.msg).join("; ");
18
+ } catch {
19
+ /* ignore */
20
+ }
21
+ const err = new Error(detail);
22
+ err.status = res.status; // lets pages distinguish 404 from server errors
23
+ throw err;
24
+ }
25
+ return res.json();
26
+ }
27
+
28
+ export const api = {
29
+ // catalog
30
+ search: (q, page = 1, mal = false) =>
31
+ req(`/anime/search?q=${encodeURIComponent(q)}&page=${page}&mal=${mal ? 1 : 0}`),
32
+ trending: (page = 1) => req(`/anime/trending?page=${page}`),
33
+ popular: (page = 1) => req(`/anime/popular?page=${page}`),
34
+ upcoming: (page = 1) => req(`/anime/upcoming?page=${page}`),
35
+ recent: (page = 1) => req(`/anime/recent?page=${page}`),
36
+ schedule: (page = 1) => req(`/anime/schedule?page=${page}`),
37
+ details: (id) => req(`/anime/${id}`),
38
+ mal: (id) => req(`/anime/${id}/mal`),
39
+ // streaming
40
+ episodes: (id) => req(`/anime/${id}/episodes`),
41
+ watch: (episodeId) => req(`/watch/${episodeId}`),
42
+ // manga (backed by the MangaVault sidecar)
43
+ mangaHome: () => req(`/manga/home`),
44
+ mangaSearch: (q) => req(`/manga/search?q=${encodeURIComponent(q)}`),
45
+ mangaDetails: (source, id) => req(`/manga/${source}/${encodeURIComponent(id)}/details`),
46
+ mangaChapterImages: (path) => req(`/manga/chapter-images?path=${encodeURIComponent(path)}`),
47
+ mangaImg: (url, src) => `${API_URL}/manga/img?url=${encodeURIComponent(url)}&src=${src}`,
48
+ // auth — local accounts + AniList OAuth
49
+ authRegister: (username, email, password) =>
50
+ req("/auth/register", {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({ username, email, password }),
54
+ }),
55
+ authLogin: (identifier, password) =>
56
+ req("/auth/login", {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({ identifier, password }),
60
+ }),
61
+ authMe: () => req("/auth/me", { headers: { Authorization: `Bearer ${getToken()}` } }),
62
+ authLogout: () =>
63
+ req("/auth/logout", {
64
+ method: "POST",
65
+ headers: { Authorization: `Bearer ${getToken()}` },
66
+ }),
67
+ // movies & tv (backed by the Moviebox-API sidecar)
68
+ movieHome: () => req(`/movies/home`),
69
+ movieCatalog: (type = "movie", page = 1, sort = "RECOMMEND") =>
70
+ req(`/movies/catalog?type=${type}&page=${page}&sort=${sort}`),
71
+ movieSearch: (q, page = 1) =>
72
+ req(`/movies/search?q=${encodeURIComponent(q)}&page=${page}`),
73
+ movieSuggest: (q) => req(`/movies/suggest?q=${encodeURIComponent(q)}`),
74
+ movieDetails: (slug) => req(`/movies/${encodeURIComponent(slug)}`),
75
+ movieStream: (slug, se = 0, ep = 0) =>
76
+ req(`/movies/${encodeURIComponent(slug)}/stream?se=${se}&ep=${ep}`),
77
+ // images
78
+ img: (url) => (url ? `${API_URL}/img?url=${encodeURIComponent(url)}` : ""),
79
+ };
80
+
81
+ /** Wrap an upstream m3u8/segment URL so it flows through the HLS proxy worker.
82
+ * `ref` (optional) sets the Referer/Origin the worker sends upstream. */
83
+ export function proxiedStreamUrl(url, ref) {
84
+ let out = `${STREAM_PROXY}?url=${encodeURIComponent(url)}`;
85
+ if (ref) out += `&ref=${encodeURIComponent(ref)}`;
86
+ return out;
87
+ }
88
+
89
+ export function streamHeaders() {
90
+ return STREAM_KEY ? { "x-stream-key": STREAM_KEY } : {};
91
+ }
92
+
93
+ /** Parse "watch/<provider>/<anilistId>/<category>/<ref>" → parts. */
94
+ export function parseEpisodeId(episodeId) {
95
+ const parts = (episodeId || "").split("/");
96
+ if (parts.length >= 4 && parts[0] === "watch") {
97
+ return {
98
+ provider: parts[1],
99
+ anilistId: Number(parts[2]),
100
+ category: parts[3],
101
+ ref: parts.slice(4).join("/"),
102
+ };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** Pick the best title from a media object. */
108
+ export function titleOf(media) {
109
+ return (
110
+ media?.title?.english ||
111
+ media?.title?.romaji ||
112
+ media?.title?.native ||
113
+ "Untitled"
114
+ );
115
+ }
frontend/src/components/Ambient.jsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from "react";
2
+
3
+ const INTERACTIVES = 'a, button, .card, .nav-item, .try-btn, .copy-btn, label, [role="button"]';
4
+ const TILT_SELECTOR = ".card, .info-cell";
5
+
6
+ export default function Ambient() {
7
+ useEffect(() => {
8
+ /* ── custom cursor (dot + ring, lerped) ─────────────────────── */
9
+ const dot = document.querySelector(".cursor-dot");
10
+ const ring = document.querySelector(".cursor-ring");
11
+ let cursorCleanup = () => {};
12
+
13
+ if (dot && ring) {
14
+ let mx = -200, my = -200;
15
+ let rx = -200, ry = -200;
16
+ let visible = false;
17
+ let rafId = 0;
18
+ const LERP = 0.22;
19
+
20
+ const tick = () => {
21
+ rx += (mx - rx) * LERP;
22
+ ry += (my - ry) * LERP;
23
+ dot.style.transform = `translate(${mx}px,${my}px)`;
24
+ ring.style.transform = `translate(${Math.round(rx * 10) / 10}px,${Math.round(ry * 10) / 10}px)`;
25
+ rafId = requestAnimationFrame(tick);
26
+ };
27
+ rafId = requestAnimationFrame(tick);
28
+
29
+ const onMove = (e) => {
30
+ mx = e.clientX;
31
+ my = e.clientY;
32
+ if (!visible) {
33
+ visible = true;
34
+ dot.classList.remove("is-hidden");
35
+ ring.classList.remove("is-hidden");
36
+ rx = mx; ry = my;
37
+ }
38
+ };
39
+ const onLeave = () => {
40
+ dot.classList.add("is-hidden");
41
+ ring.classList.add("is-hidden");
42
+ visible = false;
43
+ };
44
+ const onEnter = () => {
45
+ dot.classList.remove("is-hidden");
46
+ ring.classList.remove("is-hidden");
47
+ visible = true;
48
+ };
49
+ const onDown = () => {
50
+ ring.classList.add("is-clicking");
51
+ ring.classList.remove("is-hovering");
52
+ };
53
+ const onUp = () => ring.classList.remove("is-clicking");
54
+ const onOver = (e) => {
55
+ if (e.target.closest(INTERACTIVES)) {
56
+ dot.classList.add("is-hovering");
57
+ ring.classList.add("is-hovering");
58
+ }
59
+ };
60
+ const onOut = (e) => {
61
+ if (e.target.closest(INTERACTIVES)) {
62
+ dot.classList.remove("is-hovering");
63
+ ring.classList.remove("is-hovering");
64
+ }
65
+ };
66
+ const onTouch = () => {
67
+ dot.style.display = "none";
68
+ ring.style.display = "none";
69
+ document.body.style.cursor = "";
70
+ };
71
+
72
+ document.addEventListener("mousemove", onMove, { passive: true });
73
+ document.addEventListener("mouseleave", onLeave, { passive: true });
74
+ document.addEventListener("mouseenter", onEnter, { passive: true });
75
+ document.addEventListener("mousedown", onDown, { passive: true });
76
+ document.addEventListener("mouseup", onUp, { passive: true });
77
+ document.addEventListener("mouseover", onOver, { passive: true });
78
+ document.addEventListener("mouseout", onOut, { passive: true });
79
+ window.addEventListener("touchstart", onTouch, { once: true, passive: true });
80
+
81
+ cursorCleanup = () => {
82
+ cancelAnimationFrame(rafId);
83
+ document.removeEventListener("mousemove", onMove);
84
+ document.removeEventListener("mouseleave", onLeave);
85
+ document.removeEventListener("mouseenter", onEnter);
86
+ document.removeEventListener("mousedown", onDown);
87
+ document.removeEventListener("mouseup", onUp);
88
+ document.removeEventListener("mouseover", onOver);
89
+ document.removeEventListener("mouseout", onOut);
90
+ window.removeEventListener("touchstart", onTouch);
91
+ };
92
+ }
93
+
94
+ /* ── reveal-on-scroll ───────────────────────────────────────── */
95
+ const revealObs = new IntersectionObserver(
96
+ (entries) => {
97
+ entries.forEach((e) => {
98
+ if (e.isIntersecting) {
99
+ e.target.classList.add("visible");
100
+ revealObs.unobserve(e.target);
101
+ }
102
+ });
103
+ },
104
+ { threshold: 0.07, rootMargin: "0px 0px -40px 0px" }
105
+ );
106
+ const observeNew = () => {
107
+ document.querySelectorAll(".section-reveal:not(.observed)").forEach((el) => {
108
+ el.classList.add("observed");
109
+ revealObs.observe(el);
110
+ });
111
+ };
112
+ observeNew();
113
+ const mo = new MutationObserver(observeNew);
114
+ mo.observe(document.body, { childList: true, subtree: true });
115
+
116
+ /* ── 3D tilt delegation (dynamic-friendly) ──────────────────── */
117
+ let tiltRaf = null;
118
+ const onTilt = (e) => {
119
+ const el = e.target.closest(TILT_SELECTOR);
120
+ if (!el) return;
121
+ if (tiltRaf) return;
122
+ tiltRaf = requestAnimationFrame(() => {
123
+ const r = el.getBoundingClientRect();
124
+ const x = (e.clientX - r.left) / r.width - 0.5;
125
+ const y = (e.clientY - r.top) / r.height - 0.5;
126
+ const maxDeg = el.classList.contains("info-cell") ? 5 : 6;
127
+ el.style.setProperty("--rx", `${(-y * maxDeg).toFixed(2)}deg`);
128
+ el.style.setProperty("--ry", `${(x * maxDeg).toFixed(2)}deg`);
129
+ tiltRaf = null;
130
+ });
131
+ };
132
+ const onTiltOut = (e) => {
133
+ const el = e.target.closest(TILT_SELECTOR);
134
+ if (!el) return;
135
+ // mouseout bubbles — ignore moves that stay inside the element
136
+ // (e.g. poster img → title), only reset when actually leaving it.
137
+ if (e.relatedTarget && el.contains(e.relatedTarget)) return;
138
+ el.style.setProperty("--rx", "0deg");
139
+ el.style.setProperty("--ry", "0deg");
140
+ };
141
+ document.addEventListener("mousemove", onTilt, { passive: true });
142
+ document.addEventListener("mouseout", onTiltOut, { passive: true });
143
+
144
+ return () => {
145
+ cursorCleanup();
146
+ revealObs.disconnect();
147
+ mo.disconnect();
148
+ document.removeEventListener("mousemove", onTilt);
149
+ document.removeEventListener("mouseout", onTiltOut);
150
+ };
151
+ }, []);
152
+
153
+ return (
154
+ <>
155
+ <div className="cursor-dot" aria-hidden="true" />
156
+ <div className="cursor-ring" aria-hidden="true" />
157
+ </>
158
+ );
159
+ }
frontend/src/components/AnimeCard.jsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useNavigate } from "react-router-dom";
2
+ import { api, titleOf } from "../api.js";
3
+
4
+ export default function AnimeCard({ item, rank, isEpisode }) {
5
+ const navigate = useNavigate();
6
+
7
+ if (!item) return null;
8
+
9
+ const title = titleOf(item);
10
+ const poster = item.coverImage || item.image || item.poster;
11
+ const score = item.score ? (item.score > 10 ? (item.score / 10).toFixed(1) : item.score) : null;
12
+ const targetUrl = isEpisode ? `/watch/${item.id}` : `/anime/${item.id}`;
13
+
14
+ return (
15
+ <div className="anime-card" onClick={() => navigate(targetUrl)}>
16
+ <div className="card-poster">
17
+ {poster ? (
18
+ <img src={api.img(poster)} alt={title} loading="lazy" />
19
+ ) : (
20
+ <div className="skeleton" style={{ width: "100%", height: "100%" }} />
21
+ )}
22
+ <div className="card-overlay" />
23
+ {rank ? <div className="card-rank">{rank}</div> : null}
24
+ {item.episodes ? (
25
+ <div className="card-ep-badge">EP {item.episodes}</div>
26
+ ) : item.type ? (
27
+ <div className="card-type-badge">{item.type}</div>
28
+ ) : null}
29
+ <div className="card-quick">
30
+ <div className="card-quick-btn">
31
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
32
+ <polygon points="5 3 19 12 5 21 5 3" />
33
+ </svg>
34
+ Watch Now
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <div className="card-body">
39
+ <div className="card-title" title={title}>{title}</div>
40
+ <div className="card-meta">
41
+ {score ? (
42
+ <span className="card-score">
43
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor">
44
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
45
+ </svg>
46
+ {score}
47
+ </span>
48
+ ) : null}
49
+ {item.format ? <span className="card-meta-item">• {item.format}</span> : null}
50
+ {item.seasonYear ? <span className="card-meta-item">• {item.seasonYear}</span> : null}
51
+ </div>
52
+ </div>
53
+ </div>
54
+ );
55
+ }
frontend/src/components/AnimeRow.jsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef } from "react";
2
+ import AnimeCard from "./AnimeCard.jsx";
3
+
4
+ export default function AnimeRow({ title, items = [], isRanked = false, viewAllUrl = null }) {
5
+ const rowRef = useRef(null);
6
+
7
+ function scroll(dir) {
8
+ if (!rowRef.current) return;
9
+ const scrollAmount = dir === "left" ? -600 : 600;
10
+ rowRef.current.scrollBy({ left: scrollAmount, behavior: "smooth" });
11
+ }
12
+
13
+ if (!items || items.length === 0) return null;
14
+
15
+ return (
16
+ <section className="content-section">
17
+ <div className="container">
18
+ <div className="section-header">
19
+ <div className="section-title">{title}</div>
20
+ {viewAllUrl ? (
21
+ <a href={viewAllUrl} className="section-link">View All</a>
22
+ ) : null}
23
+ </div>
24
+ <div className="genre-nav-wrap" style={{ position: "relative" }}>
25
+ <button className="row-nav-btn prev" onClick={() => scroll("left")} aria-label="Scroll left">
26
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
27
+ <path d="M15 18l-6-6 6-6" />
28
+ </svg>
29
+ </button>
30
+ <div className="scroll-row" ref={rowRef}>
31
+ {items.map((item, index) => (
32
+ <AnimeCard key={item.id || index} item={item} rank={isRanked ? index + 1 : null} />
33
+ ))}
34
+ </div>
35
+ <button className="row-nav-btn next" onClick={() => scroll("right")} aria-label="Scroll right">
36
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
37
+ <path d="M9 18l6-6-6-6" />
38
+ </svg>
39
+ </button>
40
+ </div>
41
+ </div>
42
+ </section>
43
+ );
44
+ }
frontend/src/components/AuthModal.jsx ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { api } from "../api.js";
3
+ import { setSession } from "../utils/auth.js";
4
+
5
+ const SQLI_RE = /(\b(select|insert|update|delete|drop|union|exec|cast|convert|declare|xp_|char|nchar|varchar|alter|create|truncate|sleep|benchmark|waitfor|information_schema)\b|--|;|\/\*|\*\/|0x[0-9a-fA-F]+|\bor\b\s+.{0,30}[=<>]|\band\b\s+.{0,30}[=<>])/i;
6
+ function hasSQLI(val) { return SQLI_RE.test(val); }
7
+
8
+ function getPasswordStrength(pw) {
9
+ const score = [/.{8,}/, /[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(r => r.test(pw)).length;
10
+ const widths = ['0%', '25%', '45%', '70%', '100%'];
11
+ const colors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#7c3aed'];
12
+ return { width: widths[score] || '0%', color: colors[score - 1] || '#334155' };
13
+ }
14
+
15
+ export default function AuthModal({ open, onClose, onAuthed }) {
16
+ const [mode, setMode] = useState("login"); // "login" | "register"
17
+ const [identifier, setIdentifier] = useState("");
18
+ const [username, setUsername] = useState("");
19
+ const [email, setEmail] = useState("");
20
+ const [password, setPassword] = useState("");
21
+ const [error, setError] = useState("");
22
+ const [busy, setBusy] = useState(false);
23
+ const [pwStrength, setPwStrength] = useState({ width: '0%', color: '#334155' });
24
+
25
+ // Reset state + close on Escape.
26
+ useEffect(() => {
27
+ if (!open) return;
28
+ setError("");
29
+ setBusy(false);
30
+ setPassword("");
31
+ const onKey = (e) => e.key === "Escape" && onClose();
32
+ window.addEventListener("keydown", onKey);
33
+ return () => window.removeEventListener("keydown", onKey);
34
+ }, [open, onClose]);
35
+
36
+ if (!open) return null;
37
+
38
+ async function handleSubmit(e) {
39
+ e.preventDefault();
40
+ setError("");
41
+ if (mode === "login") {
42
+ if (!identifier.trim() || !password) {
43
+ setError("Enter your username/email and password.");
44
+ return;
45
+ }
46
+ if (hasSQLI(identifier) || hasSQLI(password)) {
47
+ setError("Invalid input detected.");
48
+ return;
49
+ }
50
+ } else {
51
+ if (username.trim().length < 3) {
52
+ setError("Username must be at least 3 characters.");
53
+ return;
54
+ }
55
+ if (username.trim().length > 15) {
56
+ setError("Username must be 15 characters or less.");
57
+ return;
58
+ }
59
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim())) {
60
+ setError("Enter a valid email address.");
61
+ return;
62
+ }
63
+ if (email.trim().length > 20) {
64
+ setError("Email must be 20 characters or less.");
65
+ return;
66
+ }
67
+ if (password.length < 6) {
68
+ setError("Password must be at least 6 characters.");
69
+ return;
70
+ }
71
+ if (hasSQLI(username) || hasSQLI(email) || hasSQLI(password)) {
72
+ setError("Invalid input detected.");
73
+ return;
74
+ }
75
+ }
76
+ setBusy(true);
77
+ try {
78
+ const res =
79
+ mode === "login"
80
+ ? await api.authLogin(identifier.trim(), password)
81
+ : await api.authRegister(username.trim(), email.trim(), password);
82
+ setSession(res.token, res.user);
83
+ onAuthed?.(res.user);
84
+ onClose();
85
+ } catch (err) {
86
+ setError(err.message || "Something went wrong. Try again.");
87
+ } finally {
88
+ setBusy(false);
89
+ }
90
+ }
91
+
92
+ return (
93
+ <div className="auth-overlay" onClick={onClose}>
94
+ <div className="auth-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
95
+ <button className="auth-modal-close" onClick={onClose} aria-label="Close">
96
+
97
+ </button>
98
+
99
+ <div className="auth-modal-logo"><span className="auth-modal-logo-text">ANICOVE</span></div>
100
+
101
+ <div className="auth-tabs">
102
+ <button
103
+ className={`auth-tab${mode === "login" ? " active" : ""}`}
104
+ onClick={() => { setMode("login"); setError(""); }}
105
+ >
106
+ Sign in
107
+ </button>
108
+ <button
109
+ className={`auth-tab${mode === "register" ? " active" : ""}`}
110
+ onClick={() => { setMode("register"); setError(""); }}
111
+ >
112
+ Create account
113
+ </button>
114
+ </div>
115
+
116
+ <form className="auth-form" onSubmit={handleSubmit}>
117
+ {mode === "login" ? (
118
+ <label className="auth-field">
119
+ <span>Username or email</span>
120
+ <input
121
+ type="text"
122
+ value={identifier}
123
+ onChange={(e) => setIdentifier(e.target.value)}
124
+ placeholder="username@example.com"
125
+ autoComplete="username"
126
+ autoFocus
127
+ />
128
+ </label>
129
+ ) : (
130
+ <>
131
+ <label className="auth-field">
132
+ <span>Username</span>
133
+ <input
134
+ type="text"
135
+ value={username}
136
+ onChange={(e) => setUsername(e.target.value)}
137
+ placeholder="Your username"
138
+ autoComplete="username"
139
+ autoFocus
140
+ />
141
+ </label>
142
+ <label className="auth-field">
143
+ <span>Email</span>
144
+ <input
145
+ type="email"
146
+ value={email}
147
+ onChange={(e) => setEmail(e.target.value)}
148
+ placeholder="you@example.com"
149
+ autoComplete="email"
150
+ />
151
+ </label>
152
+ </>
153
+ )}
154
+
155
+ <label className="auth-field">
156
+ <span>Password {mode === "register" && <span style={{color:'#475569',fontWeight:400,textTransform:'none',letterSpacing:0}}>(min 6 characters)</span>}</span>
157
+ <input
158
+ type="password"
159
+ value={password}
160
+ onChange={(e) => {
161
+ setPassword(e.target.value);
162
+ if (mode === "register") setPwStrength(getPasswordStrength(e.target.value));
163
+ }}
164
+ placeholder="••••••••"
165
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
166
+ maxLength={18}
167
+ />
168
+ {mode === "register" && (
169
+ <div className="auth-pw-bar"><div className="auth-pw-fill" style={{width: pwStrength.width, background: pwStrength.color}}></div></div>
170
+ )}
171
+ </label>
172
+
173
+ {error ? <div className="auth-error show">{error}</div> : null}
174
+
175
+ <button type="submit" className="btn btn-primary auth-submit" disabled={busy}>
176
+ <span className="auth-submit-text">{busy ? "Please wait…" : mode === "login" ? "Sign in" : "Create account"}</span>
177
+ <span className="auth-submit-spinner"></span>
178
+ </button>
179
+ </form>
180
+
181
+ <div className="auth-divider"><span>OR</span></div>
182
+
183
+ <button
184
+ className="auth-anilist-btn"
185
+ onClick={() => { window.location.href = "/api/auth/anilist/login"; }}
186
+ title="Sign in with your AniList account"
187
+ >
188
+ <img src="https://anilist.co/img/icons/icon.svg" style={{width:20,height:20}} alt="AniList" />
189
+ <span>Continue with AniList</span>
190
+ </button>
191
+
192
+ {mode === "login" ? (
193
+ <p className="auth-switch">No account? <a onClick={() => { setMode("register"); setError(""); }}>Create one</a></p>
194
+ ) : (
195
+ <p className="auth-switch">Already have an account? <a onClick={() => { setMode("login"); setError(""); }}>Sign in</a></p>
196
+ )}
197
+
198
+ <div className="auth-recaptcha-terms">
199
+ This site is protected by reCAPTCHA and the Google
200
+ <a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a> and
201
+ <a href="https://policies.google.com/terms" target="_blank" rel="noopener noreferrer">Terms of Service</a> apply.
202
+ </div>
203
+ </div>
204
+ </div>
205
+ );
206
+ }
frontend/src/components/ContinueWatching.jsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { api } from "../api.js";
4
+ import { getContinueWatching, removeProgress } from "../utils/watchProgress.js";
5
+
6
+ function timeAgo(ts) {
7
+ if (!ts) return "";
8
+ const s = Math.max(1, Math.floor((Date.now() - ts) / 1000));
9
+ if (s < 60) return "just now";
10
+ const m = Math.floor(s / 60);
11
+ if (m < 60) return `${m}m ago`;
12
+ const h = Math.floor(m / 60);
13
+ if (h < 24) return `${h}h ago`;
14
+ const d = Math.floor(h / 24);
15
+ if (d < 30) return `${d}d ago`;
16
+ return new Date(ts).toLocaleDateString();
17
+ }
18
+
19
+ function timeLeft(position, duration) {
20
+ const left = Math.max(0, (duration || 0) - (position || 0));
21
+ const m = Math.floor(left / 60);
22
+ const s = Math.floor(left % 60);
23
+ return `${m}:${String(s).padStart(2, "0")}`;
24
+ }
25
+
26
+ export default function ContinueWatching() {
27
+ const [items, setItems] = useState([]);
28
+ const rowRef = useRef(null);
29
+
30
+ function refresh() {
31
+ setItems(getContinueWatching());
32
+ }
33
+
34
+ useEffect(() => {
35
+ refresh();
36
+ window.addEventListener("storage", refresh);
37
+ return () => window.removeEventListener("storage", refresh);
38
+ }, []);
39
+
40
+ function handleRemove(key, e) {
41
+ e.preventDefault();
42
+ e.stopPropagation();
43
+ removeProgress(key);
44
+ refresh();
45
+ }
46
+
47
+ function scroll(dir) {
48
+ if (!rowRef.current) return;
49
+ rowRef.current.scrollBy({ left: dir === "left" ? -600 : 600, behavior: "smooth" });
50
+ }
51
+
52
+ if (items.length === 0) return null;
53
+
54
+ return (
55
+ <section className="content-section">
56
+ <div className="container">
57
+ <div className="section-header">
58
+ <div className="section-title">Continue Watching</div>
59
+ <span className="cw-subtitle">
60
+ {items.length} {items.length === 1 ? "title" : "titles"} in progress
61
+ </span>
62
+ </div>
63
+ <div className="genre-nav-wrap">
64
+ <button className="row-nav-btn prev" onClick={() => scroll("left")} aria-label="Scroll left">
65
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
66
+ <path d="M15 18l-6-6 6-6" />
67
+ </svg>
68
+ </button>
69
+ <div className="scroll-row cw-row" ref={rowRef}>
70
+ {items.map((item) => {
71
+ const pct = item.duration ? Math.min(100, (item.position / item.duration) * 100) : 0;
72
+ return (
73
+ <Link key={item.key} to={item.href} className="cw-card" title={item.title}>
74
+ <div className="cw-poster">
75
+ {item.image ? (
76
+ <img src={api.img(item.image)} alt={item.title} loading="lazy" />
77
+ ) : (
78
+ <div className="skeleton" style={{ width: "100%", height: "100%" }} />
79
+ )}
80
+ <span className="cw-badge">{item.label}</span>
81
+ <div className="cw-overlay">
82
+ <svg viewBox="0 0 24 24" fill="currentColor">
83
+ <path d="M8 5v14l11-7z" />
84
+ </svg>
85
+ <span>Resume</span>
86
+ </div>
87
+ <div className="cw-progress">
88
+ <div className="cw-progress-fill" style={{ width: `${pct}%` }} />
89
+ </div>
90
+ <button
91
+ className="cw-remove"
92
+ onClick={(e) => handleRemove(item.key, e)}
93
+ aria-label="Remove from Continue Watching"
94
+ title="Remove"
95
+ >
96
+ ×
97
+ </button>
98
+ </div>
99
+ <div className="cw-body">
100
+ <h3>{item.title}</h3>
101
+ <div className="cw-meta">
102
+ <span className="cw-meta-left">
103
+ {item.category ? item.category.toUpperCase() : null}
104
+ {item.category ? " · " : ""}
105
+ {timeAgo(item.updatedAt)}
106
+ </span>
107
+ <span className="cw-left">⏱ {timeLeft(item.position, item.duration)} left</span>
108
+ </div>
109
+ </div>
110
+ </Link>
111
+ );
112
+ })}
113
+ </div>
114
+ <button className="row-nav-btn next" onClick={() => scroll("right")} aria-label="Scroll right">
115
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
116
+ <path d="M9 18l6-6-6-6" />
117
+ </svg>
118
+ </button>
119
+ </div>
120
+ </div>
121
+ </section>
122
+ );
123
+ }
frontend/src/components/ErrorBoundary.jsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component } from "react";
2
+
3
+ /**
4
+ * Catches render/effect errors so a single crashing component (e.g. the
5
+ * player) can never unmount the whole app and leave a black screen.
6
+ */
7
+ export default class ErrorBoundary extends Component {
8
+ constructor(props) {
9
+ super(props);
10
+ this.state = { error: null };
11
+ }
12
+
13
+ static getDerivedStateFromError(error) {
14
+ return { error };
15
+ }
16
+
17
+ componentDidCatch(error, info) {
18
+ console.error("[ErrorBoundary]", error, info);
19
+ }
20
+
21
+ render() {
22
+ if (this.state.error) {
23
+ const message = String(
24
+ this.state.error?.message || this.state.error || "Unknown error"
25
+ ).slice(0, 240);
26
+ return (
27
+ <div
28
+ className="player-error"
29
+ style={{
30
+ position: "static",
31
+ minHeight: 220,
32
+ margin: "24px auto",
33
+ maxWidth: 560,
34
+ }}
35
+ >
36
+ <div className="big">⚠️</div>
37
+ <p>Something went wrong rendering this page.</p>
38
+ {message ? <p style={{ fontSize: "0.8rem", opacity: 0.7 }}>{message}</p> : null}
39
+ <button
40
+ className="btn btn-primary btn-sm"
41
+ style={{ marginTop: 14 }}
42
+ onClick={() => this.setState({ error: null })}
43
+ >
44
+ Try again
45
+ </button>
46
+ </div>
47
+ );
48
+ }
49
+ return this.props.children;
50
+ }
51
+ }
frontend/src/components/Header.jsx ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { Link, useLocation, useNavigate } from "react-router-dom";
3
+ import { api } from "../api.js";
4
+ import { clearSession, getToken, getUser, setSession } from "../utils/auth.js";
5
+ import AuthModal from "./AuthModal.jsx";
6
+
7
+ const NAV = [
8
+ { to: "/home", label: "Home" },
9
+ { to: "/browse", label: "Browse" },
10
+ { to: "/schedule", label: "Schedule" },
11
+ { to: "/movies", label: "Movies & TV" },
12
+ { to: "/manga", label: "Manga" },
13
+ { to: "/music", label: "Music" },
14
+ ];
15
+
16
+ export default function Header() {
17
+ const [scrolled, setScrolled] = useState(false);
18
+ const [q, setQ] = useState("");
19
+ const [user, setUser] = useState(() => getUser());
20
+ const [menuOpen, setMenuOpen] = useState(false);
21
+ const [modalOpen, setModalOpen] = useState(false);
22
+ const location = useLocation();
23
+ const navigate = useNavigate();
24
+
25
+ useEffect(() => {
26
+ const fn = () => setScrolled(window.scrollY > 8);
27
+ window.addEventListener("scroll", fn, { passive: true });
28
+ return () => window.removeEventListener("scroll", fn);
29
+ }, []);
30
+
31
+ // Hydrate a stored token into the full user profile on load.
32
+ useEffect(() => {
33
+ if (getToken() && !getUser()) {
34
+ api
35
+ .authMe()
36
+ .then((u) => setSession(getToken(), u))
37
+ .then(() => setUser(getUser()))
38
+ .catch(() => clearSession());
39
+ }
40
+ }, []);
41
+
42
+ // Close the dropdown when clicking outside / navigating.
43
+ useEffect(() => setMenuOpen(false), [location.pathname]);
44
+ useEffect(() => {
45
+ if (!menuOpen) return;
46
+ const close = (e) => {
47
+ if (!e.target.closest?.(".auth-nav")) setMenuOpen(false);
48
+ };
49
+ document.addEventListener("click", close);
50
+ return () => document.removeEventListener("click", close);
51
+ }, [menuOpen]);
52
+
53
+ useEffect(() => {
54
+ const openAuth = () => {
55
+ setMenuOpen(false);
56
+ setModalOpen(true);
57
+ };
58
+ window.addEventListener("open-auth-modal", openAuth);
59
+ return () => window.removeEventListener("open-auth-modal", openAuth);
60
+ }, []);
61
+
62
+ function handleSearch(e) {
63
+ e.preventDefault();
64
+ if (q.trim()) navigate(`/search?q=${encodeURIComponent(q.trim())}`);
65
+ }
66
+
67
+ async function handleLogout() {
68
+ try {
69
+ await api.authLogout();
70
+ } catch {
71
+ /* token already invalid — clear locally anyway */
72
+ }
73
+ clearSession();
74
+ setUser(null);
75
+ setMenuOpen(false);
76
+ }
77
+
78
+ return (
79
+ <header id="header" className={scrolled ? "scrolled" : ""}>
80
+ <div className="container">
81
+ <Link to="/home" className="logo">
82
+ <div className="logo-wordmark">
83
+ {"ANICOVE".split("").map((ch, i) => (
84
+ <span key={i} className="l">{ch}</span>
85
+ ))}
86
+ </div>
87
+ </Link>
88
+
89
+ <nav id="main-nav">
90
+ {NAV.map(({ to, label }) => (
91
+ <Link
92
+ key={to}
93
+ to={to}
94
+ className={location.pathname === to ? "active" : ""}
95
+ >
96
+ {label}
97
+ </Link>
98
+ ))}
99
+ </nav>
100
+
101
+ <div className="search-wrap">
102
+ <span className="search-icon">
103
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
104
+ <circle cx="11" cy="11" r="8" />
105
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
106
+ </svg>
107
+ </span>
108
+ <form onSubmit={handleSearch}>
109
+ <input
110
+ id="header-search"
111
+ type="text"
112
+ value={q}
113
+ onChange={e => setQ(e.target.value)}
114
+ className="search-input"
115
+ placeholder="Search anime..."
116
+ autoComplete="off"
117
+ />
118
+ </form>
119
+ </div>
120
+
121
+ <div className="auth-nav">
122
+ <button
123
+ className={`auth-avatar-btn${user ? "" : " guest"}${menuOpen ? " open" : ""}`}
124
+ onClick={() => setMenuOpen((v) => !v)}
125
+ aria-label={user ? `${user.username} menu` : "Account menu"}
126
+ aria-expanded={menuOpen}
127
+ >
128
+ {user?.avatar ? (
129
+ <img className="auth-avatar-circle auth-avatar-img" src={user.avatar} alt="" />
130
+ ) : (
131
+ <div className="auth-avatar-circle auth-avatar-icon">
132
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
133
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
134
+ <circle cx="12" cy="7" r="4" />
135
+ </svg>
136
+ </div>
137
+ )}
138
+ <span>{user ? user.username : "Guest"}</span>
139
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
140
+ <path d="M6 9l6 6 6-6" />
141
+ </svg>
142
+ </button>
143
+
144
+ {menuOpen ? (
145
+ <div className="auth-dropdown">
146
+ {user ? (
147
+ <>
148
+ <div className="auth-dropdown-head">
149
+ <div className="auth-dropdown-name">{user.username}</div>
150
+ <div className="auth-dropdown-sub">{user.email || user.provider}</div>
151
+ </div>
152
+ <button
153
+ className="auth-dropdown-item"
154
+ onClick={() => { setMenuOpen(false); navigate("/home"); }}
155
+ >
156
+ Home
157
+ </button>
158
+ <button className="auth-dropdown-item danger" onClick={handleLogout}>
159
+ Sign out
160
+ </button>
161
+ </>
162
+ ) : (
163
+ <>
164
+ <button
165
+ className="auth-dropdown-item"
166
+ onClick={() => { setMenuOpen(false); setModalOpen(true); }}
167
+ >
168
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
169
+ <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
170
+ <path d="M10 17l5-5-5-5M15 12H3" />
171
+ </svg>
172
+ Sign in
173
+ </button>
174
+ <button
175
+ className="auth-dropdown-item"
176
+ onClick={() => { window.location.href = "/api/auth/anilist/login"; }}
177
+ >
178
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
179
+ <path d="M2 3h20v18H2V3zm5.5 5.5h-1.2v7h1.2v-7zm2.6 0h1.2l2.1 5.1 2.1-5.1h1.2v7h-1.15v-4.6L12 15.6l-1.75-4.25v4.15H9.1v-7z" />
180
+ </svg>
181
+ Sign in with AniList
182
+ </button>
183
+ </>
184
+ )}
185
+ </div>
186
+ ) : null}
187
+ </div>
188
+ <AuthModal
189
+ open={modalOpen}
190
+ onClose={() => setModalOpen(false)}
191
+ onAuthed={(u) => setUser(u)}
192
+ />
193
+ </div>
194
+ </header>
195
+ );
196
+ }
frontend/src/components/HeroCarousel.jsx ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { api, titleOf } from "../api.js";
4
+
5
+ export default function HeroCarousel({ items = [] }) {
6
+ const [index, setIndex] = useState(0);
7
+ const timer = useRef(null);
8
+
9
+ const count = items.length;
10
+ const go = useCallback(
11
+ (i) => setIndex(((i % count) + count) % count),
12
+ [count]
13
+ );
14
+
15
+ useEffect(() => {
16
+ if (count <= 1) return undefined;
17
+ timer.current = setInterval(() => setIndex((i) => (i + 1) % count), 6000);
18
+ return () => clearInterval(timer.current);
19
+ }, [count]);
20
+
21
+ if (!count) return null;
22
+ const media = items[index];
23
+ const title = titleOf(media);
24
+ const native = media.title?.native;
25
+ const genres = (media.genres || []).slice(0, 3);
26
+ const banner = media.bannerImage || media.coverImage;
27
+ const epRef = media.id;
28
+
29
+ return (
30
+ <div className="hero">
31
+ {banner ? (
32
+ <div
33
+ className="hero-bg"
34
+ style={{ backgroundImage: `url(${api.img(banner)})` }}
35
+ />
36
+ ) : null}
37
+ <div className="hero-scrim" />
38
+ <button
39
+ className="hero-nav prev"
40
+ aria-label="Previous"
41
+ onClick={() => go(index - 1)}
42
+ >
43
+
44
+ </button>
45
+ <button
46
+ className="hero-nav next"
47
+ aria-label="Next"
48
+ onClick={() => go(index + 1)}
49
+ >
50
+
51
+ </button>
52
+ <div className="hero-content">
53
+ <div className="hero-kicker">Trending now</div>
54
+ <h1>{title}</h1>
55
+ {native && native !== title ? <div className="detail-native">{native}</div> : null}
56
+ <div className="hero-meta">
57
+ {media.score ? <span className="chip score">★ {media.score}%</span> : null}
58
+ {media.format ? <span className="chip">{media.format}</span> : null}
59
+ {media.seasonYear ? <span className="chip dim">{media.seasonYear}</span> : null}
60
+ {media.episodes ? <span className="chip dim">{media.episodes} eps</span> : null}
61
+ </div>
62
+ {media.synopsis ? <p className="hero-desc">{media.synopsis}</p> : null}
63
+ <div className="hero-actions">
64
+ <Link to={`/anime/${epRef}`} className="btn btn-primary">
65
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
66
+ <path d="M8 5v14l11-7z" />
67
+ </svg>
68
+ Watch
69
+ </Link>
70
+ <Link to={`/anime/${epRef}`} className="btn btn-ghost">
71
+ Details
72
+ </Link>
73
+ </div>
74
+ {genres.length ? (
75
+ <div className="genre-pills">
76
+ {genres.map((g) => (
77
+ <Link key={g} to={`/search?q=${encodeURIComponent(g)}`}>
78
+ {g}
79
+ </Link>
80
+ ))}
81
+ </div>
82
+ ) : null}
83
+ </div>
84
+ {count > 1 ? (
85
+ <div className="hero-dots">
86
+ {items.map((_, i) => (
87
+ <button
88
+ key={i}
89
+ className={i === index ? "active" : ""}
90
+ aria-label={`Slide ${i + 1}`}
91
+ onClick={() => setIndex(i)}
92
+ />
93
+ ))}
94
+ </div>
95
+ ) : null}
96
+ </div>
97
+ );
98
+ }
frontend/src/components/HeroSection.jsx ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+
4
+ const POPULAR_SEARCHES = ["One Piece", "Solo Leveling", "Demon Slayer", "Jujutsu Kaisen", "Frieren", "Attack on Titan"];
5
+
6
+ export default function HeroSection() {
7
+ const navigate = useNavigate();
8
+ const [searchQuery, setSearchQuery] = useState("");
9
+ const [activeCategory, setActiveCategory] = useState("All");
10
+
11
+ function handleSearch(e) {
12
+ e.preventDefault();
13
+ if (searchQuery.trim()) {
14
+ navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
15
+ }
16
+ }
17
+
18
+ function handleFilterClick(cat) {
19
+ setActiveCategory(cat);
20
+ if (cat === "Anime") {
21
+ navigate("/search");
22
+ } else if (cat === "Manga") {
23
+ navigate("/manga");
24
+ } else if (cat === "Movies & TV") {
25
+ navigate("/search?q=movie");
26
+ }
27
+ }
28
+
29
+ return (
30
+ <section className="home-hero">
31
+ <div className="hero-glow" />
32
+ <div className="hero-badge">
33
+ <span className="hero-badge-dot" />
34
+ <span>ANIME • MANGA • MOVIES • MUSIC</span>
35
+ </div>
36
+
37
+ <h1 className="hero-logo-text">
38
+ ANI<span className="hero-logo-e">C</span>OVE
39
+ </h1>
40
+
41
+ <div className="hero-subtitle">YOUR STREAMING UNIVERSE</div>
42
+ <p className="hero-desc">
43
+ Watch, read and listen without losing your place. <strong>Zero friction.</strong><br />
44
+ <strong>Everything connected.</strong>
45
+ </p>
46
+
47
+ {/* Filter Pills */}
48
+ <div className="hero-filters">
49
+ {["All", "Anime", "Manga", "Movies & TV", "Music"].map(cat => (
50
+ <button
51
+ key={cat}
52
+ className={`hero-filter-pill ${activeCategory === cat ? "active" : ""}`}
53
+ onClick={() => handleFilterClick(cat)}
54
+ >
55
+ {cat}
56
+ </button>
57
+ ))}
58
+ </div>
59
+
60
+ {/* Search Bar */}
61
+ <form className="hero-search-wrap" onSubmit={handleSearch}>
62
+ <input
63
+ type="text"
64
+ className="hero-search-input"
65
+ placeholder="Search anime, manga, movies, music..."
66
+ value={searchQuery}
67
+ onChange={e => setSearchQuery(e.target.value)}
68
+ />
69
+ <button type="submit" className="hero-search-btn" aria-label="Search">
70
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
71
+ <circle cx="11" cy="11" r="8" />
72
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
73
+ </svg>
74
+ </button>
75
+ </form>
76
+
77
+ {/* Popular Searches */}
78
+ <div className="hero-popular">
79
+ <div className="hero-popular-label">POPULAR SEARCHES</div>
80
+ <div className="hero-popular-tags">
81
+ {POPULAR_SEARCHES.map(query => (
82
+ <span
83
+ key={query}
84
+ className="hero-popular-tag"
85
+ onClick={() => navigate(`/search?q=${encodeURIComponent(query)}`)}
86
+ >
87
+ {query}
88
+ </span>
89
+ ))}
90
+ </div>
91
+ </div>
92
+
93
+ {/* Action Buttons */}
94
+ <div className="hero-ctas">
95
+ <button className="hero-cta hero-cta-anime" onClick={() => navigate("/home")}>
96
+ <span>Enter anicove</span>
97
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
98
+ <path d="M5 12h14M12 5l7 7-7 7" />
99
+ </svg>
100
+ </button>
101
+ <button className="hero-cta hero-cta-manga" onClick={() => navigate("/manga")}>
102
+ <span>Read Manga</span>
103
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
104
+ <path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
105
+ </svg>
106
+ </button>
107
+ <button className="hero-cta hero-cta-movies" onClick={() => navigate("/movies")}>
108
+ <span>Movies &amp; TV</span>
109
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
110
+ <polygon points="5 3 19 12 5 21 5 3" />
111
+ </svg>
112
+ </button>
113
+ <button className="hero-cta hero-cta-music" onClick={() => navigate("/music")}>
114
+ <span>Play Music</span>
115
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
116
+ <path d="M9 18V5l12-2v13M9 9l12-2" />
117
+ </svg>
118
+ </button>
119
+ </div>
120
+
121
+ {/* Stats */}
122
+ <div className="hero-stats">
123
+ <div className="hero-stat">
124
+ <div className="hero-stat-val">10K+</div>
125
+ <div className="hero-stat-label">SERIES & MOVIES</div>
126
+ </div>
127
+ <div className="hero-stat">
128
+ <div className="hero-stat-val">SUB+DUB</div>
129
+ <div className="hero-stat-label">AUDIO CHOICES</div>
130
+ </div>
131
+ <div className="hero-stat">
132
+ <div className="hero-stat-val">HD</div>
133
+ <div className="hero-stat-label">ADAPTIVE PLAYBACK</div>
134
+ </div>
135
+ <div className="hero-stat">
136
+ <div className="hero-stat-val">4-IN-1</div>
137
+ <div className="hero-stat-label">ANIME • MANGA • MOVIES • MUSIC</div>
138
+ </div>
139
+ </div>
140
+
141
+ <div className="scroll-indicator">
142
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
143
+ <path d="M19 9l-7 7-7-7" />
144
+ </svg>
145
+ </div>
146
+ </section>
147
+ );
148
+ }
frontend/src/components/HlsPlayer.jsx ADDED
@@ -0,0 +1,1012 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Hls from "hls.js";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { proxiedStreamUrl, streamHeaders } from "../api.js";
4
+
5
+ /** blob:/data: URLs are already local — never route them through the proxy. */
6
+ function subtitleSrc(file, referer) {
7
+ if (/^(blob|data):/i.test(file || "")) return file;
8
+ return proxiedStreamUrl(file, referer);
9
+ }
10
+
11
+ function fmt(t) {
12
+ if (!Number.isFinite(t) || t < 0) t = 0;
13
+ const h = Math.floor(t / 3600);
14
+ const m = Math.floor((t % 3600) / 60);
15
+ const s = Math.floor(t % 60);
16
+ const mm = h ? String(m).padStart(2, "0") : String(m);
17
+ return h ? `${h}:${mm}:${String(s).padStart(2, "0")}` : `${mm}:${String(s).padStart(2, "0")}`;
18
+ }
19
+
20
+ const S = { fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" };
21
+
22
+ const IconPlay = (p) => (
23
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} fill="currentColor">
24
+ <path d="M8 5.14v13.72c0 .8.87 1.3 1.56.9l11-6.86a1.05 1.05 0 000-1.8l-11-6.86c-.69-.4-1.56.1-1.56.9z" />
25
+ </svg>
26
+ );
27
+ const IconPause = (p) => (
28
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} fill="currentColor">
29
+ <rect x="6" y="5" width="4" height="14" rx="1.2" />
30
+ <rect x="14" y="5" width="4" height="14" rx="1.2" />
31
+ </svg>
32
+ );
33
+ const IconReplay = (p) => (
34
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
35
+ <path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8" />
36
+ <path d="M3 3v5h5" />
37
+ </svg>
38
+ );
39
+ const IconVol = (p) => (
40
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
41
+ <path d="M11 5L6 9H2v6h4l5 4V5z" />
42
+ <path d="M15.5 8.5a5 5 0 010 7" />
43
+ <path d="M18.5 5.5a9 9 0 010 13" />
44
+ </svg>
45
+ );
46
+ const IconMuted = (p) => (
47
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
48
+ <path d="M11 5L6 9H2v6h4l5 4V5z" />
49
+ <path d="M22 9l-6 6M16 9l6 6" />
50
+ </svg>
51
+ );
52
+ const IconGear = (p) => (
53
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
54
+ <circle cx="12" cy="12" r="3" />
55
+ <path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33h0a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51h0a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z" />
56
+ </svg>
57
+ );
58
+ const IconCast = (p) => (
59
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
60
+ <path d="M2 16.1A5.9 5.9 0 017.9 22" />
61
+ <path d="M2 12.05A9.95 9.95 0 0111.95 22" />
62
+ <path d="M2 8V6a2 2 0 012-2h16a2 2 0 012 2v12a2 2 0 01-2 2h-6" />
63
+ <circle cx="2" cy="20" r="1.4" fill="currentColor" stroke="none" />
64
+ </svg>
65
+ );
66
+ const IconPip = (p) => (
67
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
68
+ <rect x="2" y="4" width="20" height="16" rx="2" />
69
+ <rect x="12" y="12" width="8" height="5" rx="1" fill="currentColor" stroke="none" />
70
+ </svg>
71
+ );
72
+ const IconFs = (p) => (
73
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
74
+ <path d="M8 3H5a2 2 0 00-2 2v3M16 3h3a2 2 0 012 2v3M8 21H5a2 2 0 01-2-2v-3M16 21h3a2 2 0 002-2v-3" />
75
+ </svg>
76
+ );
77
+ const IconShrink = (p) => (
78
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
79
+ <path d="M8 3v3a2 2 0 01-2 2H3M21 8h-3a2 2 0 01-2-2V3M3 16h3a2 2 0 012 2v3M16 21v-3a2 2 0 012-2h3" />
80
+ </svg>
81
+ );
82
+ const IconTheater = (p) => (
83
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
84
+ <rect x="2" y="3" width="20" height="14" rx="2" />
85
+ <path d="M7 21h10" />
86
+ <path d="M10 17v4" />
87
+ <path d="M14 17v4" />
88
+ </svg>
89
+ );
90
+ const IconScreenshot = (p) => (
91
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
92
+ <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
93
+ <circle cx="12" cy="13" r="4" />
94
+ </svg>
95
+ );
96
+ const IconRewind = (p) => (
97
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
98
+ <polygon points="11 19 2 12 11 5 11 19" />
99
+ <polygon points="22 19 13 12 22 5 22 19" />
100
+ </svg>
101
+ );
102
+ const IconForward = (p) => (
103
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
104
+ <polygon points="13 19 22 12 13 5 13 19" />
105
+ <polygon points="2 19 11 12 2 5 2 19" />
106
+ </svg>
107
+ );
108
+ const IconLoop = (p) => (
109
+ <svg viewBox="0 0 24 24" width={p.s || 20} height={p.s || 20} {...S}>
110
+ <path d="M17 1l4 4-4 4" />
111
+ <path d="M3 11V9a4 4 0 0 1 4-4h14" />
112
+ <path d="M7 23l-4-4 4-4" />
113
+ <path d="M21 13v2a4 4 0 0 1-4 4H3" />
114
+ </svg>
115
+ );
116
+
117
+ export default function HlsPlayer({ sources, autoPlay = true, onFatal = null, onProgress = null, startAt = 0 }) {
118
+ const wrapRef = useRef(null);
119
+ const videoRef = useRef(null);
120
+ const seekBarRef = useRef(null);
121
+ const hlsRef = useRef(null);
122
+ const onFatalRef = useRef(onFatal);
123
+ const onProgressRef = useRef(onProgress);
124
+ const startAtRef = useRef(startAt);
125
+ const hideTimer = useRef(null);
126
+ const seeking = useRef(false);
127
+ const resumeAt = useRef(0);
128
+ const playingRef = useRef(false);
129
+ const timeRef = useRef(0);
130
+ const durationRef = useRef(0);
131
+
132
+ useEffect(() => {
133
+ onFatalRef.current = onFatal;
134
+ onProgressRef.current = onProgress;
135
+ startAtRef.current = startAt;
136
+ }, [onFatal, onProgress, startAt]);
137
+
138
+ const streams = useMemo(
139
+ () => (sources?.streams || []).filter((s) => s && s.url),
140
+ [sources]
141
+ );
142
+
143
+ const [streamIdx, setStreamIdx] = useState(0);
144
+ const stream = streams[Math.min(streamIdx, streams.length - 1)] || streams[0];
145
+
146
+ const [playing, setPlaying] = useState(false);
147
+ const [currentTime, setCurrentTime] = useState(0);
148
+ const [duration, setDuration] = useState(0);
149
+ const [buffered, setBuffered] = useState(0);
150
+ const [volume, setVolume] = useState(1);
151
+ const [muted, setMuted] = useState(false);
152
+ const [rate, setRate] = useState(1);
153
+ const [fullscreen, setFullscreen] = useState(false);
154
+ const [ui, setUi] = useState(true);
155
+ const [settings, setSettings] = useState(null); // "quality" | "speed" | null
156
+ const [levels, setLevels] = useState([]);
157
+ const [hlsLevel, setHlsLevel] = useState(-1);
158
+ const [castOk, setCastOk] = useState(false);
159
+ const [casting, setCasting] = useState(false);
160
+ const [fatal, setFatal] = useState(null);
161
+ const [showSkip, setShowSkip] = useState(null);
162
+ const [ended, setEnded] = useState(false);
163
+ const [hover, setHover] = useState(null); // { pct, time }
164
+ const [theaterMode, setTheaterMode] = useState(false);
165
+ const [loop, setLoop] = useState(false);
166
+ const [volumeBoost, setVolumeBoost] = useState(1);
167
+
168
+ const intro = sources?.intro || { start: 0, end: 0 };
169
+ const outro = sources?.outro || { start: 0, end: 0 };
170
+ const subtitles = sources?.subtitles || [];
171
+
172
+ const isHls =
173
+ stream?.type === "hls" ||
174
+ (stream?.url || "").split("?")[0].toLowerCase().endsWith(".m3u8");
175
+
176
+ useEffect(() => {
177
+ playingRef.current = playing;
178
+ timeRef.current = currentTime;
179
+ durationRef.current = duration;
180
+ }, [playing, currentTime, duration]);
181
+
182
+ // ── player attach (hls.js for m3u8, native for mp4) ────────────
183
+ useEffect(() => {
184
+ const video = videoRef.current;
185
+ if (!video || !stream?.url) return undefined;
186
+
187
+ // Resume from a saved position (startAt), or from a position carried over
188
+ // when the parent swaps the stream (e.g. movie quality change).
189
+ const resume = startAtRef.current || resumeAt.current;
190
+ startAtRef.current = 0;
191
+ resumeAt.current = 0;
192
+
193
+ // Capture the current position before teardown so the next stream can
194
+ // resume seamlessly. Runs before hls.destroy() resets the video element.
195
+ const carryPosition = () => {
196
+ const v = videoRef.current;
197
+ if (v && v.readyState >= 1 && v.currentTime > 5) resumeAt.current = v.currentTime;
198
+ };
199
+
200
+ setFatal(null);
201
+ setEnded(false);
202
+
203
+ // Streams marked `direct` are already proxied (e.g. the MovieBox sidecar's
204
+ // own /api/stream endpoint) — play them as-is instead of routing them
205
+ // through the HLS worker again.
206
+ const resolveSrc = (st) => (st?.direct ? st.url : proxiedStreamUrl(st.url, st.referer));
207
+
208
+ // MP4 path: when the browser rejects the source, probe the resolved URL to
209
+ // tell apart a server refusal (429/403/HTML — the usual cause) from a real
210
+ // codec problem, so the message is honest instead of a generic "codec".
211
+ let retryCount = 0;
212
+ let retryTimer = null;
213
+ let alive = true;
214
+ let diagnosing = false;
215
+ const diagnoseStream = (st) => {
216
+ // The error event and the play().catch fallback can both fire for one
217
+ // failure — only run the probe once per failure to keep the retry count honest.
218
+ if (diagnosing) return;
219
+ diagnosing = true;
220
+ const probeUrl = resolveSrc(st);
221
+ fetch(probeUrl, {
222
+ headers: { ...streamHeaders(), Range: "bytes=0-63" },
223
+ signal: AbortSignal.timeout(12000),
224
+ })
225
+ .then((res) => {
226
+ if (!alive) return;
227
+ const ct = res.headers.get("content-type") || "";
228
+ if (res.status === 429) {
229
+ if (retryCount < 1) {
230
+ retryCount += 1;
231
+ setFatal("Stream server is busy (HTTP 429). Retrying…");
232
+ retryTimer = setTimeout(reloadSrc, 3000);
233
+ } else {
234
+ const msg =
235
+ "The stream server is rate-limiting requests right now. Try again in a few minutes.";
236
+ setFatal(msg);
237
+ onFatalRef.current?.(msg);
238
+ }
239
+ return;
240
+ }
241
+ if (res.status >= 400 || !/video|mp4|octet-stream|quicktime/i.test(ct)) {
242
+ const msg = `The stream server refused the video (HTTP ${res.status}).`;
243
+ setFatal(msg);
244
+ onFatalRef.current?.(msg);
245
+ return;
246
+ }
247
+ // Bytes look like video — the container/codec is genuinely unsupported.
248
+ const msg = "This video codec is not supported by your browser.";
249
+ setFatal(msg);
250
+ onFatalRef.current?.(msg);
251
+ })
252
+ .catch(() => {
253
+ if (!alive) return;
254
+ const msg = "This stream could not be played.";
255
+ setFatal(msg);
256
+ onFatalRef.current?.(msg);
257
+ });
258
+ };
259
+
260
+ const reloadSrc = () => {
261
+ const v = videoRef.current;
262
+ if (!alive || !v || v.getAttribute("data-stream-url") !== stream.url) return;
263
+ diagnosing = false; // a fresh failure after the reload may be diagnosed again
264
+ v.removeAttribute("src");
265
+ v.load();
266
+ v.src = resolveSrc(stream);
267
+ v.load();
268
+ if (autoPlay) v.play().catch(() => {});
269
+ };
270
+
271
+ const onVideoError = () => {
272
+ const code = video.error?.code;
273
+ if (!isHls && code === 4 && stream?.url) {
274
+ diagnoseStream(stream);
275
+ return;
276
+ }
277
+ const msg =
278
+ code === 4
279
+ ? "This video codec is not supported by your browser."
280
+ : "This stream could not be played.";
281
+ setFatal(msg);
282
+ onFatalRef.current?.(msg);
283
+ };
284
+ video.setAttribute("data-stream-url", stream.url);
285
+ video.addEventListener("error", onVideoError);
286
+
287
+ if (!isHls) {
288
+ setLevels([]);
289
+ video.src = resolveSrc(stream);
290
+ const onMeta = () => {
291
+ try {
292
+ video.currentTime = Math.min(resume, video.duration || resume);
293
+ } catch {
294
+ /* ignore */
295
+ }
296
+ };
297
+ if (resume > 0) video.addEventListener("loadedmetadata", onMeta, { once: true });
298
+ if (autoPlay) {
299
+ video.play().catch(() => {
300
+ if (video.error) onVideoError();
301
+ });
302
+ }
303
+ return () => {
304
+ alive = false;
305
+ if (retryTimer) clearTimeout(retryTimer);
306
+ carryPosition();
307
+ video.removeEventListener("error", onVideoError);
308
+ video.removeEventListener("loadedmetadata", onMeta);
309
+ };
310
+ }
311
+
312
+ let hls = null;
313
+ let manifestTimer = null;
314
+ let stuckTimer = null;
315
+
316
+ try {
317
+ if (Hls.isSupported()) {
318
+ hls = new Hls({
319
+ enableWorker: true,
320
+ maxBufferLength: 40,
321
+ maxMaxBufferLength: 120,
322
+ xhrSetup: (xhr) => {
323
+ for (const [k, v] of Object.entries(streamHeaders())) {
324
+ xhr.setRequestHeader(k, v);
325
+ }
326
+ },
327
+ });
328
+ let manifestLoaded = false;
329
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
330
+ manifestLoaded = true;
331
+ if (!hls.levels?.length) {
332
+ const msg = "This server returned no playable quality levels.";
333
+ setFatal(msg);
334
+ onFatalRef.current?.(msg);
335
+ return;
336
+ }
337
+ setLevels(
338
+ hls.levels.map((l, i) => ({
339
+ i,
340
+ height: l.height,
341
+ name: l.height ? `${l.height}p` : `Level ${i + 1}`,
342
+ }))
343
+ );
344
+ if (resume > 0) {
345
+ try {
346
+ video.currentTime = Math.min(resume, video.duration || resume);
347
+ } catch {
348
+ /* ignore */
349
+ }
350
+ }
351
+ if (autoPlay) video.play().catch(() => {});
352
+ stuckTimer = setTimeout(() => {
353
+ if (video.readyState < 2) {
354
+ const msg = "This stream is not delivering data.";
355
+ setFatal(msg);
356
+ onFatalRef.current?.(msg);
357
+ }
358
+ }, 20000);
359
+ video.addEventListener("loadeddata", () => clearTimeout(stuckTimer), {
360
+ once: true,
361
+ });
362
+ });
363
+ let retries = 0;
364
+ hls.on(Hls.Events.ERROR, (_e, data) => {
365
+ if (!data.fatal) return;
366
+ if (data.type === Hls.ErrorTypes.NETWORK_ERROR && retries < 3) {
367
+ retries += 1;
368
+ hls.startLoad();
369
+ } else {
370
+ const msg = data.details || "This stream could not be loaded.";
371
+ setFatal(msg);
372
+ onFatalRef.current?.(msg);
373
+ }
374
+ });
375
+ manifestTimer = setTimeout(() => {
376
+ if (!manifestLoaded) {
377
+ const msg = "Timed out loading this stream.";
378
+ setFatal(msg);
379
+ onFatalRef.current?.(msg);
380
+ }
381
+ }, 12000);
382
+
383
+ hls.loadSource(proxiedStreamUrl(stream.url, stream.referer));
384
+ hls.attachMedia(video);
385
+ hlsRef.current = hls;
386
+ } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
387
+ video.src = proxiedStreamUrl(stream.url, stream.referer); // native HLS (Safari)
388
+ }
389
+ } catch (err) {
390
+ const msg = err?.message || "Player error";
391
+ console.error("[HlsPlayer]", err);
392
+ setFatal(msg);
393
+ onFatalRef.current?.(msg);
394
+ }
395
+
396
+ return () => {
397
+ alive = false;
398
+ if (retryTimer) clearTimeout(retryTimer);
399
+ carryPosition();
400
+ if (manifestTimer) clearTimeout(manifestTimer);
401
+ if (stuckTimer) clearTimeout(stuckTimer);
402
+ video.removeEventListener("error", onVideoError);
403
+ if (hls) hls.destroy();
404
+ hlsRef.current = null;
405
+ };
406
+ }, [stream?.url, autoPlay, isHls]);
407
+
408
+ // ── media element events ────────────────────────────────────────
409
+ useEffect(() => {
410
+ const video = videoRef.current;
411
+ if (!video) return undefined;
412
+ let lastSave = 0;
413
+ const save = (ended = false) => {
414
+ const v = videoRef.current;
415
+ if (!v || !Number.isFinite(v.currentTime) || !Number.isFinite(v.duration) || v.duration <= 0) return;
416
+ onProgressRef.current?.({ position: v.currentTime, duration: v.duration, ended });
417
+ };
418
+ const onPlay = () => setPlaying(true);
419
+ const onPause = () => {
420
+ setPlaying(false);
421
+ setUi(true);
422
+ save();
423
+ };
424
+ const onTime = () => {
425
+ setCurrentTime(video.currentTime);
426
+ const now = Date.now();
427
+ if (now - lastSave >= 5000) {
428
+ lastSave = now;
429
+ save();
430
+ }
431
+ };
432
+ const onDur = () => setDuration(video.duration || 0);
433
+ const onProg = () => {
434
+ const b = video.buffered;
435
+ setBuffered(b.length ? b.end(b.length - 1) : 0);
436
+ };
437
+ const onVol = () => {
438
+ setVolume(video.volume);
439
+ setMuted(video.muted);
440
+ };
441
+ const onEnd = () => {
442
+ setPlaying(false);
443
+ setEnded(true);
444
+ setUi(true);
445
+ save(true);
446
+ };
447
+ const onFs = () =>
448
+ setFullscreen(Boolean(document.fullscreenElement || document.webkitFullscreenElement));
449
+ const onCastState = () => {
450
+ try {
451
+ setCasting(video.remote?.state === "connected");
452
+ } catch {
453
+ /* ignore */
454
+ }
455
+ };
456
+ video.addEventListener("play", onPlay);
457
+ video.addEventListener("pause", onPause);
458
+ video.addEventListener("timeupdate", onTime);
459
+ video.addEventListener("durationchange", onDur);
460
+ video.addEventListener("progress", onProg);
461
+ video.addEventListener("volumechange", onVol);
462
+ video.addEventListener("ended", onEnd);
463
+ document.addEventListener("fullscreenchange", onFs);
464
+ try {
465
+ video.remote?.addEventListener?.("statechange", onCastState);
466
+ } catch {
467
+ /* ignore */
468
+ }
469
+ return () => {
470
+ save();
471
+ video.removeEventListener("play", onPlay);
472
+ video.removeEventListener("pause", onPause);
473
+ video.removeEventListener("timeupdate", onTime);
474
+ video.removeEventListener("durationchange", onDur);
475
+ video.removeEventListener("progress", onProg);
476
+ video.removeEventListener("volumechange", onVol);
477
+ video.removeEventListener("ended", onEnd);
478
+ document.removeEventListener("fullscreenchange", onFs);
479
+ try {
480
+ video.remote?.removeEventListener?.("statechange", onCastState);
481
+ } catch {
482
+ /* ignore */
483
+ }
484
+ };
485
+ }, []);
486
+
487
+ // ── cast / airplay availability ─────────────────────────────────
488
+ useEffect(() => {
489
+ const video = videoRef.current;
490
+ if (!video) return undefined;
491
+ let alive = true;
492
+ const hasPicker = typeof video.webkitShowPlaybackTargetPicker === "function";
493
+ try {
494
+ if (video.remote?.watchAvailability) {
495
+ video.remote
496
+ .watchAvailability((avail) => {
497
+ if (alive) setCastOk(Boolean(avail) || hasPicker);
498
+ })
499
+ .catch(() => {
500
+ if (alive) setCastOk(hasPicker);
501
+ });
502
+ } else {
503
+ setCastOk(hasPicker);
504
+ }
505
+ } catch {
506
+ setCastOk(hasPicker);
507
+ }
508
+ return () => {
509
+ alive = false;
510
+ };
511
+ }, [stream?.url]);
512
+
513
+ // ── auto-hide controls ──────────────────────────────────────────
514
+ useEffect(() => {
515
+ clearTimeout(hideTimer.current);
516
+ if (!playing) {
517
+ setUi(true);
518
+ return undefined;
519
+ }
520
+ hideTimer.current = setTimeout(() => {
521
+ if (!seeking.current) setUi(false);
522
+ }, 2600);
523
+ return () => clearTimeout(hideTimer.current);
524
+ }, [playing]);
525
+
526
+ const poke = useCallback(() => {
527
+ setUi(true);
528
+ clearTimeout(hideTimer.current);
529
+ if (playingRef.current) {
530
+ hideTimer.current = setTimeout(() => {
531
+ if (!seeking.current) setUi(false);
532
+ }, 2600);
533
+ }
534
+ }, []);
535
+
536
+ // ── controls ────────────────────────────────────────────────────
537
+ const togglePlay = useCallback(() => {
538
+ const video = videoRef.current;
539
+ if (!video) return;
540
+ if (video.paused) video.play().catch(() => {});
541
+ else video.pause();
542
+ }, []);
543
+
544
+ const seekTo = useCallback((sec) => {
545
+ const video = videoRef.current;
546
+ if (!video) return;
547
+ const d = durationRef.current || video.duration || 0;
548
+ video.currentTime = Math.min(Math.max(sec, 0), d);
549
+ }, []);
550
+
551
+ const seekFromEvent = useCallback(
552
+ (clientX) => {
553
+ const bar = seekBarRef.current;
554
+ if (!bar) return;
555
+ const r = bar.getBoundingClientRect();
556
+ const ratio = Math.min(Math.max((clientX - r.left) / r.width, 0), 1);
557
+ seekTo(ratio * (durationRef.current || 0));
558
+ },
559
+ [seekTo]
560
+ );
561
+
562
+ function toggleMute() {
563
+ const video = videoRef.current;
564
+ if (!video) return;
565
+ video.muted = !video.muted;
566
+ }
567
+
568
+ function setVol(v) {
569
+ const video = videoRef.current;
570
+ if (!video) return;
571
+ const boosted = v * volumeBoost;
572
+ video.volume = Math.min(boosted, 1);
573
+ if (v > 0) video.muted = false;
574
+ }
575
+
576
+ function toggleVolumeBoost() {
577
+ const next = volumeBoost === 1 ? 2 : 1;
578
+ setVolumeBoost(next);
579
+ const video = videoRef.current;
580
+ if (video) {
581
+ video.volume = Math.min(video.volume * (next / volumeBoost), 1);
582
+ }
583
+ }
584
+
585
+ function toggleFullscreen() {
586
+ const el = wrapRef.current;
587
+ if (!el) return;
588
+ try {
589
+ if (document.fullscreenElement || document.webkitFullscreenElement) {
590
+ (document.exitFullscreen || document.webkitExitFullscreen).call(document);
591
+ } else {
592
+ (el.requestFullscreen || el.webkitRequestFullscreen).call(el);
593
+ }
594
+ } catch {
595
+ /* ignore */
596
+ }
597
+ }
598
+
599
+ async function togglePip() {
600
+ const video = videoRef.current;
601
+ if (!video) return;
602
+ try {
603
+ if (document.pictureInPictureElement) {
604
+ await document.exitPictureInPicture();
605
+ } else if (video.requestPictureInPicture) {
606
+ await video.requestPictureInPicture();
607
+ }
608
+ } catch {
609
+ /* ignore */
610
+ }
611
+ }
612
+
613
+ async function handleCast() {
614
+ const video = videoRef.current;
615
+ if (!video) return;
616
+ try {
617
+ if (video.remote?.prompt) {
618
+ await video.remote.prompt();
619
+ return;
620
+ }
621
+ } catch {
622
+ /* fall through */
623
+ }
624
+ try {
625
+ video.webkitShowPlaybackTargetPicker?.();
626
+ } catch {
627
+ /* ignore */
628
+ }
629
+ }
630
+
631
+ function setSpeed(r) {
632
+ const video = videoRef.current;
633
+ if (!video) return;
634
+ video.playbackRate = r;
635
+ setRate(r);
636
+ setSettings(null);
637
+ }
638
+
639
+ function switchHlsQuality(i) {
640
+ const hls = hlsRef.current;
641
+ if (hls) hls.currentLevel = i;
642
+ setHlsLevel(i);
643
+ setSettings(null);
644
+ }
645
+
646
+ function pickStream(i) {
647
+ if (i === streamIdx) {
648
+ setSettings(null);
649
+ return;
650
+ }
651
+ resumeAt.current = videoRef.current?.currentTime || 0;
652
+ setStreamIdx(i);
653
+ setSettings(null);
654
+ }
655
+
656
+ function replay() {
657
+ const video = videoRef.current;
658
+ if (!video) return;
659
+ video.currentTime = 0;
660
+ video.play().catch(() => {});
661
+ }
662
+
663
+ function takeScreenshot() {
664
+ const video = videoRef.current;
665
+ if (!video) return;
666
+ try {
667
+ const canvas = document.createElement('canvas');
668
+ canvas.width = video.videoWidth;
669
+ canvas.height = video.videoHeight;
670
+ const ctx = canvas.getContext('2d');
671
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
672
+ const link = document.createElement('a');
673
+ link.download = `anicove-screenshot-${Date.now()}.png`;
674
+ link.href = canvas.toDataURL('image/png');
675
+ link.click();
676
+ } catch (err) {
677
+ console.error('Screenshot failed:', err);
678
+ }
679
+ }
680
+
681
+ function toggleTheaterMode() {
682
+ setTheaterMode(!theaterMode);
683
+ }
684
+
685
+ function toggleLoop() {
686
+ const video = videoRef.current;
687
+ if (!video) return;
688
+ video.loop = !loop;
689
+ setLoop(!loop);
690
+ }
691
+
692
+ function skip() {
693
+ const video = videoRef.current;
694
+ if (!video) return;
695
+ const target = showSkip === "intro" ? intro.end + 0.5 : outro.end + 0.5;
696
+ video.currentTime = target;
697
+ setShowSkip(null);
698
+ }
699
+
700
+ // ── skip intro/outro detection ──────────────────────────────────
701
+ useEffect(() => {
702
+ const video = videoRef.current;
703
+ if (!video) return undefined;
704
+ const onTime = () => {
705
+ const t = video.currentTime;
706
+ const inIntro = intro.end > intro.start + 3 && t >= intro.start && t < intro.end - 2;
707
+ const inOutro = outro.end > outro.start + 3 && t >= outro.start && t < outro.end - 2;
708
+ setShowSkip(inIntro ? "intro" : inOutro ? "outro" : null);
709
+ };
710
+ video.addEventListener("timeupdate", onTime);
711
+ return () => video.removeEventListener("timeupdate", onTime);
712
+ }, [intro.start, intro.end, outro.start, outro.end]);
713
+
714
+ // ── keyboard shortcuts ──────────────────────────────────────────
715
+ useEffect(() => {
716
+ const el = wrapRef.current;
717
+ if (!el) return undefined;
718
+ const onKey = (e) => {
719
+ const tag = (e.target.tagName || "").toLowerCase();
720
+ if (tag === "input" || tag === "textarea" || tag === "select") return;
721
+ switch (e.key.toLowerCase()) {
722
+ case " ":
723
+ case "k":
724
+ e.preventDefault();
725
+ togglePlay();
726
+ break;
727
+ case "arrowright":
728
+ e.preventDefault();
729
+ seekTo(timeRef.current + (e.shiftKey ? 10 : 5));
730
+ break;
731
+ case "arrowleft":
732
+ e.preventDefault();
733
+ seekTo(timeRef.current - (e.shiftKey ? 10 : 5));
734
+ break;
735
+ case "arrowup":
736
+ e.preventDefault();
737
+ setVol(Math.min((videoRef.current?.volume || 0) + 0.1, 1));
738
+ break;
739
+ case "arrowdown":
740
+ e.preventDefault();
741
+ setVol(Math.max((videoRef.current?.volume || 0) - 0.1, 0));
742
+ break;
743
+ case "m":
744
+ toggleMute();
745
+ break;
746
+ case "f":
747
+ toggleFullscreen();
748
+ break;
749
+ case "p":
750
+ togglePip();
751
+ break;
752
+ case "l":
753
+ toggleLoop();
754
+ break;
755
+ case "t":
756
+ toggleTheaterMode();
757
+ break;
758
+ case "s":
759
+ takeScreenshot();
760
+ break;
761
+ default:
762
+ break;
763
+ }
764
+ };
765
+ el.addEventListener("keydown", onKey);
766
+ return () => el.removeEventListener("keydown", onKey);
767
+ }, [togglePlay, seekTo]);
768
+
769
+ // ── quality options ─────────────────────────────────────────────
770
+ const qualityOptions = useMemo(() => {
771
+ if (isHls) {
772
+ return levels.map((l) => ({ key: l.i, label: l.name }));
773
+ }
774
+ return streams.map((s, i) => ({
775
+ key: i,
776
+ label:
777
+ s.resolution || s.format || s.server || (s.type === "hls" ? "HLS" : `Quality ${i + 1}`),
778
+ }));
779
+ }, [isHls, levels, streams]);
780
+
781
+ const currentQualityLabel = useMemo(() => {
782
+ if (isHls) return hlsLevel === -1 ? "Auto" : levels.find((l) => l.i === hlsLevel)?.name || "Auto";
783
+ return stream?.resolution || stream?.format || stream?.server || "Auto";
784
+ }, [isHls, levels, hlsLevel, stream]);
785
+
786
+ const pipOk =
787
+ typeof document !== "undefined" &&
788
+ document.pictureInPictureEnabled &&
789
+ typeof HTMLVideoElement !== "undefined" &&
790
+ "requestPictureInPicture" in HTMLVideoElement.prototype;
791
+
792
+ const pct = (v) => `${Math.min(Math.max((v / (durationRef.current || duration || 1)) * 100, 0), 100)}%`;
793
+
794
+ const skipLabel = showSkip === "intro" ? "Skip Intro" : "Skip Outro";
795
+
796
+ return (
797
+ <div className={`player-wrap${theaterMode ? " theater-mode" : ""}`}>
798
+ <div
799
+ className="custom-player"
800
+ ref={wrapRef}
801
+ tabIndex={0}
802
+ onMouseMove={poke}
803
+ onPointerDown={poke}
804
+ >
805
+ <video ref={videoRef} playsInline onClick={togglePlay} onDoubleClick={(e) => {
806
+ const rect = videoRef.current.getBoundingClientRect();
807
+ const x = e.clientX - rect.left;
808
+ if (x < rect.width / 3) {
809
+ seekTo(timeRef.current - 10);
810
+ } else if (x > (rect.width / 3) * 2) {
811
+ seekTo(timeRef.current + 10);
812
+ } else {
813
+ togglePlay();
814
+ }
815
+ }}>
816
+ {subtitles.map((s, i) => (
817
+ <track
818
+ key={`${s.file}-${i}`}
819
+ kind={s.kind || "captions"}
820
+ src={subtitleSrc(s.file, stream?.referer)}
821
+ label={s.label || "Subtitles"}
822
+ srcLang={s.srcLang || "en"}
823
+ default={i === 0}
824
+ />
825
+ ))}
826
+ </video>
827
+
828
+ {fatal ? (
829
+ <div className="player-error">
830
+ <div className="big">📺</div>
831
+ <p>{fatal}</p>
832
+ </div>
833
+ ) : null}
834
+
835
+ {/* center play / replay */}
836
+ {!fatal && ended ? (
837
+ <button className="cp-center-play" onClick={replay} aria-label="Replay">
838
+ <IconReplay s={34} />
839
+ </button>
840
+ ) : !fatal && !playing ? (
841
+ <button className="cp-center-play" onClick={togglePlay} aria-label="Play">
842
+ <IconPlay s={34} />
843
+ </button>
844
+ ) : null}
845
+
846
+ {/* skip intro/outro */}
847
+ <button className={`skip-btn ${showSkip ? "show" : ""}`} onClick={skip}>
848
+ ⏭ {skipLabel}
849
+ </button>
850
+
851
+ {/* bottom gradient + controls */}
852
+ <div className={`cp-fade ${ui ? "" : "hide"}`} />
853
+ <div className={`cp-controls ${ui ? "" : "hide"}`} onClick={(e) => e.stopPropagation()}>
854
+ <div
855
+ className="cp-seek"
856
+ ref={seekBarRef}
857
+ onPointerDown={(e) => {
858
+ seeking.current = true;
859
+ e.currentTarget.setPointerCapture?.(e.pointerId);
860
+ seekFromEvent(e.clientX);
861
+ }}
862
+ onPointerMove={(e) => {
863
+ if (seeking.current) {
864
+ seekFromEvent(e.clientX);
865
+ } else {
866
+ const r = e.currentTarget.getBoundingClientRect();
867
+ const ratio = Math.min(Math.max((e.clientX - r.left) / r.width, 0), 1);
868
+ setHover({ pct: `${ratio * 100}%`, time: ratio * (durationRef.current || 0) });
869
+ }
870
+ }}
871
+ onPointerUp={() => {
872
+ seeking.current = false;
873
+ setHover(null);
874
+ }}
875
+ onPointerLeave={() => {
876
+ if (!seeking.current) setHover(null);
877
+ }}
878
+ >
879
+ <div className="cp-seek-track" />
880
+ <div className="cp-buffered" style={{ width: pct(buffered) }} />
881
+ <div className="cp-progress" style={{ width: pct(currentTime) }} />
882
+ <div className="cp-thumb" style={{ left: pct(currentTime) }} />
883
+ {hover ? (
884
+ <div className="cp-hover-time" style={{ left: hover.pct }}>
885
+ {fmt(hover.time)}
886
+ </div>
887
+ ) : null}
888
+ </div>
889
+
890
+ <div className="cp-row">
891
+ <button className="cp-btn" onClick={togglePlay} aria-label={playing ? "Pause" : "Play"}>
892
+ {playing ? <IconPause /> : <IconPlay />}
893
+ </button>
894
+ <button className="cp-btn" onClick={() => seekTo(timeRef.current - 10)} aria-label="Rewind 10s">
895
+ <IconRewind />
896
+ </button>
897
+ <button className="cp-btn" onClick={() => seekTo(timeRef.current + 10)} aria-label="Forward 10s">
898
+ <IconForward />
899
+ </button>
900
+ <button className="cp-btn" onClick={toggleMute} aria-label={muted ? "Unmute" : "Mute"}>
901
+ {muted || volume === 0 ? <IconMuted /> : <IconVol />}
902
+ </button>
903
+ <input
904
+ className="cp-vol-slider"
905
+ type="range"
906
+ min="0"
907
+ max="1"
908
+ step="0.05"
909
+ value={muted ? 0 : volume}
910
+ onChange={(e) => setVol(Number(e.target.value))}
911
+ aria-label="Volume"
912
+ />
913
+ <span className="cp-time">
914
+ {fmt(currentTime)} <em>/</em> {fmt(duration)}
915
+ </span>
916
+ <div className="cp-spacer" />
917
+
918
+ <button
919
+ className={`cp-btn cp-gear ${settings ? "active" : ""}`}
920
+ onClick={() => setSettings(settings ? null : "quality")}
921
+ title="Quality & speed"
922
+ aria-label="Settings"
923
+ >
924
+ <IconGear />
925
+ </button>
926
+
927
+ {pipOk ? (
928
+ <button className="cp-btn" onClick={togglePip} title="Picture-in-Picture" aria-label="Picture in picture">
929
+ <IconPip />
930
+ </button>
931
+ ) : null}
932
+
933
+ {castOk ? (
934
+ <button
935
+ className={`cp-btn ${casting ? "active" : ""}`}
936
+ onClick={handleCast}
937
+ title={casting ? "Connected to a cast device" : "Cast / AirPlay"}
938
+ aria-label="Cast"
939
+ >
940
+ <IconCast />
941
+ </button>
942
+ ) : null}
943
+
944
+ <button className="cp-btn" onClick={toggleTheaterMode} title="Theater Mode" aria-label="Theater mode">
945
+ <IconTheater />
946
+ </button>
947
+ <button className="cp-btn" onClick={takeScreenshot} title="Screenshot" aria-label="Screenshot">
948
+ <IconScreenshot />
949
+ </button>
950
+ <button className={`cp-btn${loop ? " active" : ""}`} onClick={toggleLoop} title="Loop" aria-label="Loop">
951
+ <IconLoop />
952
+ </button>
953
+ <button className="cp-btn" onClick={toggleFullscreen} title="Fullscreen" aria-label="Fullscreen">
954
+ {fullscreen ? <IconShrink /> : <IconFs />}
955
+ </button>
956
+ </div>
957
+
958
+ {settings ? (
959
+ <div className="cp-menu">
960
+ {qualityOptions.length > 1 ? (
961
+ <>
962
+ <div className="cp-menu-title">Quality</div>
963
+ {isHls && hlsLevel !== -1 ? (
964
+ <button
965
+ className="cp-menu-item"
966
+ onClick={() => switchHlsQuality(-1)}
967
+ >
968
+ Auto
969
+ </button>
970
+ ) : null}
971
+ {qualityOptions.map((q) => {
972
+ const active = isHls ? q.key === hlsLevel : q.key === streamIdx;
973
+ return (
974
+ <button
975
+ key={q.key}
976
+ className={`cp-menu-item ${active ? "active" : ""}`}
977
+ onClick={() => (isHls ? switchHlsQuality(q.key) : pickStream(q.key))}
978
+ >
979
+ {q.label}
980
+ </button>
981
+ );
982
+ })}
983
+ </>
984
+ ) : null}
985
+ <div className="cp-menu-title">Speed</div>
986
+ {[0.5, 0.75, 1, 1.25, 1.5, 2].map((r) => (
987
+ <button
988
+ key={r}
989
+ className={`cp-menu-item ${rate === r ? "active" : ""}`}
990
+ onClick={() => setSpeed(r)}
991
+ >
992
+ {r}x
993
+ </button>
994
+ ))}
995
+ <div className="cp-menu-title">Volume Boost</div>
996
+ {[1, 1.5, 2].map((boost) => (
997
+ <button
998
+ key={boost}
999
+ className={`cp-menu-item ${volumeBoost === boost ? "active" : ""}`}
1000
+ onClick={() => toggleVolumeBoost()}
1001
+ >
1002
+ {boost}x
1003
+ </button>
1004
+ ))}
1005
+ <div className="cp-menu-note">Current: {currentQualityLabel}</div>
1006
+ </div>
1007
+ ) : null}
1008
+ </div>
1009
+ </div>
1010
+ </div>
1011
+ );
1012
+ }
frontend/src/components/LibraryMarquee.jsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useNavigate } from "react-router-dom";
2
+ import { api } from "../api.js";
3
+
4
+ export default function LibraryMarquee({ items = [] }) {
5
+ const navigate = useNavigate();
6
+ const list = items.filter((x) => x && x.coverImage);
7
+ if (!list.length) return null;
8
+
9
+ const rowA = list.slice(0, 15);
10
+ // Second row usually offsets by 10; fall back to row A when the library is
11
+ // too small so the reverse row never renders empty.
12
+ const rowB = list.length > 10 ? list.slice(10, 25) : rowA;
13
+
14
+ return (
15
+ <section className="library-section">
16
+ <div className="library-header">
17
+ <div className="library-header-line" />
18
+ <div className="library-header-text">A LIBRARY THAT KEEPS MOVING</div>
19
+ <div className="library-header-line right" />
20
+ </div>
21
+ <div className="marquee-track">
22
+ <div className="marquee-row">
23
+ {rowA.concat(rowA).map((item, idx) => (
24
+ <div key={idx} className="marquee-poster" onClick={() => navigate(`/anime/${item.id}`)}>
25
+ <img src={api.img(item.coverImage)} alt={item.id} loading="lazy" />
26
+ </div>
27
+ ))}
28
+ </div>
29
+ <div className="marquee-row reverse">
30
+ {rowB.concat(rowB).map((item, idx) => (
31
+ <div key={idx} className="marquee-poster" onClick={() => navigate(`/anime/${item.id}`)}>
32
+ <img src={api.img(item.coverImage)} alt={item.id} loading="lazy" />
33
+ </div>
34
+ ))}
35
+ </div>
36
+ </div>
37
+ </section>
38
+ );
39
+ }
frontend/src/components/MangaCard.jsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link } from "react-router-dom";
2
+ import { api } from "../api.js";
3
+
4
+ export default function MangaCard({ manga }) {
5
+ if (!manga?.id) return null;
6
+ const title = manga.title || "Untitled";
7
+
8
+ return (
9
+ <Link
10
+ to={`/manga/${manga.source}/${encodeURIComponent(manga.id)}`}
11
+ className="anime-card manga-card"
12
+ title={title}
13
+ >
14
+ <div className="card-poster">
15
+ {manga.cover ? (
16
+ <img
17
+ src={api.mangaImg(manga.cover, manga.source)}
18
+ alt={title}
19
+ loading="lazy"
20
+ />
21
+ ) : (
22
+ <div className="skeleton" style={{ width: "100%", height: "100%" }} />
23
+ )}
24
+ <div className="card-overlay" />
25
+ {manga.latest ? (
26
+ <div className="card-ep-badge">CH {manga.latest}</div>
27
+ ) : null}
28
+ <div className="card-quick">
29
+ <div className="card-quick-btn">
30
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
31
+ <path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
32
+ </svg>
33
+ Read Now
34
+ </div>
35
+ </div>
36
+ </div>
37
+ <div className="card-body">
38
+ <div className="card-title">{title}</div>
39
+ <div className="card-meta">
40
+ {manga.type ? <span className="card-meta-item">{manga.type}</span> : null}
41
+ {manga.status ? <span className="card-meta-item">• {manga.status}</span> : null}
42
+ </div>
43
+ </div>
44
+ </Link>
45
+ );
46
+ }
frontend/src/components/MediaCard.jsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link } from "react-router-dom";
2
+ import { api, titleOf } from "../api.js";
3
+
4
+ export default function MediaCard({ media }) {
5
+ const title = titleOf(media);
6
+ const sub = [media.seasonYear, media.format].filter(Boolean).join(" · ");
7
+
8
+ return (
9
+ <Link to={`/anime/${media.id}`} className="card" title={title}>
10
+ <div className="card-poster">
11
+ {media.score ? <span className="card-score">{media.score}%</span> : null}
12
+ {media.coverImage ? (
13
+ <img src={api.img(media.coverImage)} alt={title} loading="lazy" />
14
+ ) : (
15
+ <div className="skeleton" style={{ width: "100%", height: "100%" }} />
16
+ )}
17
+ <div className="card-overlay">
18
+ <span className="card-play">
19
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
20
+ <path d="M8 5v14l11-7z" />
21
+ </svg>
22
+ </span>
23
+ <span className="card-eps">
24
+ {media.episodes ? `${media.episodes} ep` : media.status || ""}
25
+ </span>
26
+ </div>
27
+ </div>
28
+ <div className="card-body">
29
+ <h3>{title}</h3>
30
+ {sub ? <div className="sub">{sub}</div> : null}
31
+ </div>
32
+ </Link>
33
+ );
34
+ }
frontend/src/components/MediaRow.jsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import MediaCard from "./MediaCard.jsx";
2
+
3
+ export default function MediaRow({ title, items = [], to }) {
4
+ if (!items.length) return null;
5
+ return (
6
+ <section className="section-reveal">
7
+ <div className="section-head">
8
+ <h2>{title}</h2>
9
+ {to ? (
10
+ <a className="link" href={to}>
11
+ See all →
12
+ </a>
13
+ ) : null}
14
+ </div>
15
+ <div className="row">
16
+ {items.map((m) => (
17
+ <MediaCard key={m.id} media={m} />
18
+ ))}
19
+ </div>
20
+ </section>
21
+ );
22
+ }
frontend/src/components/MobileNav.jsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NavLink, useLocation, useNavigate } from "react-router-dom";
2
+
3
+ const ICON = {
4
+ home: (
5
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
6
+ <path d="M3 10.5 12 3l9 7.5" />
7
+ <path d="M5 9.5V21h14V9.5" />
8
+ </svg>
9
+ ),
10
+ browse: (
11
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
12
+ <circle cx="11" cy="11" r="7" />
13
+ <path d="m20 20-3.5-3.5" />
14
+ </svg>
15
+ ),
16
+ search: (
17
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
18
+ <circle cx="11" cy="11" r="7" />
19
+ <path d="m20 20-3.5-3.5" />
20
+ </svg>
21
+ ),
22
+ schedule: (
23
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
24
+ <rect x="3" y="4" width="18" height="18" rx="2" />
25
+ <path d="M16 2v4M8 2v4M3 10h18" />
26
+ </svg>
27
+ ),
28
+ movies: (
29
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
30
+ <rect x="2" y="4" width="20" height="16" rx="2" />
31
+ <path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5" />
32
+ </svg>
33
+ ),
34
+ manga: (
35
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
36
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
37
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
38
+ </svg>
39
+ ),
40
+ music: (
41
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
42
+ <path d="M9 18V5l12-2v13" />
43
+ <circle cx="6" cy="18" r="3" />
44
+ <circle cx="18" cy="16" r="3" />
45
+ </svg>
46
+ ),
47
+ profile: (
48
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
49
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
50
+ <circle cx="12" cy="7" r="4" />
51
+ </svg>
52
+ ),
53
+ };
54
+
55
+ const ITEMS = [
56
+ { to: "/home", label: "Home", icon: ICON.home },
57
+ { to: "/browse", label: "Browse", icon: ICON.browse },
58
+ { to: "/movies", label: "Movies", icon: ICON.movies },
59
+ { to: "/schedule", label: "Schedule", icon: ICON.schedule },
60
+ { to: "/manga", label: "Manga", icon: ICON.manga },
61
+ { to: "/music", label: "Music", icon: ICON.music },
62
+ { to: "/home", label: "Profile", icon: ICON.profile, action: "profile" },
63
+ ];
64
+
65
+ export default function MobileNav() {
66
+ const { pathname } = useLocation();
67
+ const navigate = useNavigate();
68
+
69
+ const isFullscreen =
70
+ pathname.startsWith("/watch") ||
71
+ pathname === "/manga/read" ||
72
+ pathname.startsWith("/manga/read/") ||
73
+ /^\/movies\/.+\/watch$/.test(pathname);
74
+ if (isFullscreen) return null;
75
+
76
+ const isActive = (to, action) => {
77
+ if (action === "profile") return pathname === "/home" || pathname === "/";
78
+ if (to === "/browse") return pathname === "/browse" || pathname === "/search";
79
+ return pathname === to;
80
+ };
81
+
82
+ const handleClick = (item) => {
83
+ if (item.action === "profile") {
84
+ window.dispatchEvent(new CustomEvent("open-auth-modal"));
85
+ return;
86
+ }
87
+ if (item.to) navigate(item.to);
88
+ };
89
+
90
+ return (
91
+ <nav className="mobile-nav" aria-label="Mobile navigation">
92
+ {ITEMS.map(({ to, label, icon, action }) => (
93
+ <button
94
+ key={`${label}-${to}`}
95
+ type="button"
96
+ onClick={() => handleClick({ to, action })}
97
+ className={`mobile-nav-item${isActive(to, action) ? " active" : ""}`}
98
+ aria-label={label}
99
+ >
100
+ {icon}
101
+ <span className="mobile-nav-label">{label}</span>
102
+ </button>
103
+ ))}
104
+ </nav>
105
+ );
106
+ }
frontend/src/components/MovieCard.jsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useNavigate } from "react-router-dom";
2
+ import { api } from "../api.js";
3
+
4
+ export default function MovieCard({ item, rank = null }) {
5
+ const navigate = useNavigate();
6
+ if (!item?.slug) return null;
7
+ const title = item.title || "Untitled";
8
+ const comingSoon = item.upcoming || item.badge === "Coming Soon";
9
+
10
+ return (
11
+ <div className="anime-card" onClick={() => navigate(`/movies/${item.slug}`)} title={title}>
12
+ <div className="card-poster">
13
+ {item.cover ? (
14
+ <img src={api.img(item.cover)} alt={title} loading="lazy" />
15
+ ) : (
16
+ <div className="skeleton" style={{ width: "100%", height: "100%" }} />
17
+ )}
18
+ <div className="card-overlay" />
19
+ {rank ? <div className="card-rank">{rank}</div> : null}
20
+ {item.badge ? (
21
+ <div className={`card-type-badge${comingSoon ? " coming-soon" : ""}`}>{item.badge}</div>
22
+ ) : null}
23
+ <div className="card-quick">
24
+ <div className={`card-quick-btn${comingSoon ? " coming-soon" : ""}`}>
25
+ {comingSoon ? (
26
+ <>
27
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
28
+ <path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm1 10.59 3.66 2.2-.67 1.11L11 13.02V7h2z" />
29
+ </svg>
30
+ Coming Soon
31
+ </>
32
+ ) : (
33
+ <>
34
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
35
+ <polygon points="5 3 19 12 5 21 5 3" />
36
+ </svg>
37
+ Watch Now
38
+ </>
39
+ )}
40
+ </div>
41
+ </div>
42
+ </div>
43
+ <div className="card-body">
44
+ <div className="card-title" title={title}>{title}</div>
45
+ <div className="card-meta">
46
+ {item.rating ? (
47
+ <span className="card-score">
48
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor">
49
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
50
+ </svg>
51
+ {item.rating}
52
+ </span>
53
+ ) : null}
54
+ {item.year ? <span className="card-meta-item">• {item.year}</span> : null}
55
+ </div>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
frontend/src/components/MovieRow.jsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef } from "react";
2
+ import MovieCard from "./MovieCard.jsx";
3
+
4
+ export default function MovieRow({ title, items = [], isRanked = false }) {
5
+ const rowRef = useRef(null);
6
+
7
+ function scroll(dir) {
8
+ if (!rowRef.current) return;
9
+ const scrollAmount = dir === "left" ? -600 : 600;
10
+ rowRef.current.scrollBy({ left: scrollAmount, behavior: "smooth" });
11
+ }
12
+
13
+ if (!items || items.length === 0) return null;
14
+
15
+ return (
16
+ <section className="content-section">
17
+ <div className="container">
18
+ <div className="section-header">
19
+ <div className="section-title">{title}</div>
20
+ </div>
21
+ <div className="genre-nav-wrap">
22
+ <button className="row-nav-btn prev" onClick={() => scroll("left")} aria-label="Scroll left">
23
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
24
+ <path d="M15 18l-6-6 6-6" />
25
+ </svg>
26
+ </button>
27
+ <div className="scroll-row" ref={rowRef}>
28
+ {items.map((item, index) => (
29
+ <MovieCard key={item.slug || index} item={item} rank={isRanked ? index + 1 : null} />
30
+ ))}
31
+ </div>
32
+ <button className="row-nav-btn next" onClick={() => scroll("right")} aria-label="Scroll right">
33
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
34
+ <path d="M9 18l6-6-6-6" />
35
+ </svg>
36
+ </button>
37
+ </div>
38
+ </div>
39
+ </section>
40
+ );
41
+ }
frontend/src/components/Sidebar.jsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { Link, NavLink, useNavigate } from "react-router-dom";
3
+
4
+ const ICONS = {
5
+ home: (
6
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
7
+ <path d="M3 10.5 12 3l9 7.5" />
8
+ <path d="M5 9.5V21h14V9.5" />
9
+ </svg>
10
+ ),
11
+ search: (
12
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
13
+ <circle cx="11" cy="11" r="7" />
14
+ <path d="m20 20-3.5-3.5" />
15
+ </svg>
16
+ ),
17
+ book: (
18
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
19
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
20
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
21
+ </svg>
22
+ ),
23
+ };
24
+
25
+ function NavItem({ to, end, label, icon, onClick }) {
26
+ return (
27
+ <NavLink
28
+ to={to}
29
+ end={end}
30
+ className={({ isActive }) => `nav-item${isActive ? " active" : ""}`}
31
+ onClick={onClick}
32
+ >
33
+ <span className="nav-icon">{ICONS[icon]}</span>
34
+ {label}
35
+ </NavLink>
36
+ );
37
+ }
38
+
39
+ export default function Sidebar() {
40
+ const navigate = useNavigate();
41
+ const [open, setOpen] = useState(false);
42
+ const [q, setQ] = useState("");
43
+
44
+ useEffect(() => {
45
+ const onKey = (e) => {
46
+ if (e.key === "Escape") setOpen(false);
47
+ };
48
+ window.addEventListener("keydown", onKey);
49
+ return () => window.removeEventListener("keydown", onKey);
50
+ }, []);
51
+
52
+ function submit(e) {
53
+ e.preventDefault();
54
+ if (!q.trim()) return;
55
+ close();
56
+ navigate(`/search?q=${encodeURIComponent(q.trim())}`);
57
+ }
58
+
59
+ return (
60
+ <>
61
+ <header className="topbar">
62
+ <span className="mark">◈</span>
63
+ <span className="topbar-title">anicove</span>
64
+ <button
65
+ className={`menu-btn${open ? " active" : ""}`}
66
+ onClick={() => setOpen((v) => !v)}
67
+ aria-label="Toggle menu"
68
+ >
69
+ <span />
70
+ <span />
71
+ <span />
72
+ </button>
73
+ </header>
74
+
75
+ <div className={`overlay${open ? " open" : ""}`} onClick={close} />
76
+
77
+ <aside className={`sidebar${open ? " open" : ""}`}>
78
+ <div className="logo">
79
+ <Link to="/home" onClick={close} style={{ display: "flex", alignItems: "center", gap: 10 }}>
80
+ <span className="mark">◈</span>
81
+ <span className="logo-text">anicove</span>
82
+ </Link>
83
+ <span className="logo-version">v0.1</span>
84
+ </div>
85
+
86
+ <form className="sidebar-search" onSubmit={submit}>
87
+ {ICONS.search}
88
+ <input
89
+ value={q}
90
+ onChange={(e) => setQ(e.target.value)}
91
+ placeholder="Search anime…"
92
+ aria-label="Search anime"
93
+ />
94
+ </form>
95
+
96
+ <nav className="nav">
97
+ <div className="nav-section">
98
+ <div className="nav-label">Browse</div>
99
+ <NavItem to="/home" label="Home" icon="home" onClick={close} />
100
+ <NavItem to="/search" label="Search" icon="search" onClick={close} />
101
+ </div>
102
+ <div className="nav-section">
103
+ <div className="nav-label">Library</div>
104
+ <NavItem to="/manga" label="Manga" icon="book" onClick={close} />
105
+ </div>
106
+ </nav>
107
+
108
+ <div className="sidebar-foot">
109
+ Streaming via <b>Anicove</b> · 13 providers
110
+ <br />
111
+ Fallback: <b>Aniraku</b>
112
+ </div>
113
+ </aside>
114
+ </>
115
+ );
116
+ }
frontend/src/components/Toast.jsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+
3
+ export default function Toast({ message, duration = 3500, onClose }) {
4
+ const [visible, setVisible] = useState(true);
5
+
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => {
8
+ setVisible(false);
9
+ setTimeout(onClose, 300);
10
+ }, duration);
11
+ return () => clearTimeout(timer);
12
+ }, [duration, onClose]);
13
+
14
+ if (!visible) return null;
15
+
16
+ return (
17
+ <div className="auth-toast">
18
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
19
+ <polyline points="20,6 9,17 4,12" />
20
+ </svg>
21
+ {message}
22
+ </div>
23
+ );
24
+ }
25
+
26
+ export function showToast(message, duration = 3500) {
27
+ const container = document.getElementById('toast-container') || createToastContainer();
28
+ const toastEl = document.createElement('div');
29
+ toastEl.className = 'auth-toast';
30
+ toastEl.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20,6 9,17 4,12"/></svg> ${message}`;
31
+ container.appendChild(toastEl);
32
+ setTimeout(() => {
33
+ toastEl.style.opacity = '0';
34
+ toastEl.style.transform = 'translateY(16px)';
35
+ setTimeout(() => toastEl.remove(), 300);
36
+ }, duration);
37
+ }
38
+
39
+ function createToastContainer() {
40
+ const container = document.createElement('div');
41
+ container.id = 'toast-container';
42
+ container.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column;gap:10px;';
43
+ document.body.appendChild(container);
44
+ return container;
45
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,1072 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ANICOVE UI � Complete Design System */
2
+ :root {
3
+ --bg-base: #080808;
4
+ --bg-surface: #0d0d0d;
5
+ --bg-elevated: #111111;
6
+ --bg-card: #0f0f0f;
7
+ --bg-card-hover: #1a1a1a;
8
+ --accent-v: #7c3aed;
9
+ --accent-v2: #9d6ef8;
10
+ --accent-c: #5b21b6;
11
+ --accent-c2: #c4b5fd;
12
+ --accent-green: #22c55e;
13
+ --accent-amber: #f59e0b;
14
+ --grad-accent: linear-gradient(135deg, #7c3aed, #4c1d95);
15
+ --grad-card: linear-gradient(180deg, transparent 35%, rgba(8,8,8,0.99) 100%);
16
+ --text-primary: #ffffff;
17
+ --text-secondary: #777790;
18
+ --text-muted: #404050;
19
+ --border: rgba(255,255,255,0.07);
20
+ --border-bright: rgba(124,58,237,0.5);
21
+ --glass-border: rgba(255,255,255,0.06);
22
+ --radius-sm: 6px;
23
+ --radius-md: 10px;
24
+ --radius-lg: 16px;
25
+ --radius-pill: 999px;
26
+ --shadow-hover: 0 8px 40px rgba(124,58,237,0.35);
27
+ --transition: all 0.22s cubic-bezier(0.4,0,0.2,1);
28
+ --header-h: 62px;
29
+ /* Legacy layout cap keeps the 158px cards visually dense instead of stretching across ultra-wide screens. */
30
+ --max-w: 1380px;
31
+ }
32
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
33
+ html{scroll-behavior:smooth;scrollbar-width:none}
34
+ ::-webkit-scrollbar{display:none}
35
+ body{font-family:'Outfit','Inter',sans-serif;background:
36
+ radial-gradient(circle at top, rgba(124,58,237,0.22), transparent 32%),
37
+ linear-gradient(180deg, #06070a 0%, #0b0b12 36%, #08090d 100%);
38
+ color:var(--text-primary);min-height:100vh;line-height:1.6;overflow-x:hidden}
39
+ #root{min-height:100vh}
40
+ img{display:block;max-width:100%}
41
+ a{color:inherit;text-decoration:none}
42
+ button{cursor:pointer;border:none;background:none;font-family:inherit;color:inherit}
43
+ ul,ol{list-style:none}
44
+ input{font-family:inherit}
45
+ .container{max-width:var(--max-w);margin:0 auto;padding:0 28px}
46
+ .main-content{position:relative;min-height:100vh;overflow-x:hidden}
47
+ .page-shell{padding-top:calc(var(--header-h) + 30px);padding-bottom:80px}
48
+ .catalog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:20px 16px}
49
+ .search-shell{display:flex;flex-direction:column;gap:18px}
50
+ .section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:22px}
51
+ .section-header.compact{margin-bottom:16px}
52
+
53
+ /* HEADER */
54
+ #header{position:fixed;top:0;left:0;right:0;z-index:100;height:var(--header-h);transition:var(--transition)}
55
+ #header::before{content:'';position:absolute;inset:0;background:linear-gradient(180deg, rgba(10,10,18,0.82), rgba(10,10,18,0.38));backdrop-filter:blur(14px);opacity:0;transition:var(--transition);pointer-events:none}
56
+ #header.scrolled::before,#header:not(.scrolled){opacity:1}
57
+ #header.scrolled{border-bottom:1px solid var(--border);box-shadow:0 12px 28px rgba(0,0,0,0.18)}
58
+ #header .container{height:100%;display:flex;align-items:center;gap:24px;position:relative;z-index:1}
59
+ .logo{display:flex;align-items:center;flex-shrink:0}
60
+ .logo-wordmark{display:flex;align-items:flex-end;line-height:1}
61
+ .logo-wordmark .l{font-family:'Bebas Neue','Outfit',sans-serif;font-size:2.2rem;font-weight:400;line-height:1;color:#fff;letter-spacing:0.02em}
62
+ nav{display:flex;align-items:center;gap:2px}
63
+ nav a{padding:6px 14px;border-radius:var(--radius-pill);font-size:0.88rem;font-weight:500;color:var(--text-secondary);transition:var(--transition)}
64
+ nav a:hover,nav a.active{color:var(--text-primary);background:rgba(139,92,246,0.15)}
65
+ .search-wrap{flex:1;max-width:360px;position:relative}
66
+ .search-input{width:100%;padding:9px 16px 9px 42px;border-radius:var(--radius-pill);background:rgba(255,255,255,0.06);border:1px solid var(--border);color:var(--text-primary);font-size:0.88rem;outline:none;transition:var(--transition)}
67
+ .search-input::placeholder{color:var(--text-muted)}
68
+ .search-input:focus{background:rgba(255,255,255,0.1);border-color:var(--accent-v);box-shadow:0 0 0 3px rgba(139,92,246,0.12)}
69
+ .search-icon{position:absolute;left:14px;top:50%;transform:translateY(-50%);pointer-events:none;color:var(--text-muted)}
70
+ .search-icon svg{width:16px;height:16px}
71
+ .auth-nav{margin-left:auto;flex-shrink:0}
72
+ .auth-avatar-btn{display:flex;align-items:center;gap:8px;padding:7px 14px 7px 8px;border-radius:var(--radius-pill);background:rgba(255,255,255,0.06);border:1px solid var(--border);transition:var(--transition);font-size:0.85rem;font-weight:700;letter-spacing:0.01em;color:var(--text-primary);box-shadow:0 8px 18px rgba(0,0,0,0.12)}
73
+ .auth-avatar-btn:hover{background:rgba(255,255,255,0.1);border-color:var(--border-bright);transform:translateY(-1px)}
74
+ .auth-avatar-btn.open{border-color:var(--border-bright);background:linear-gradient(180deg, rgba(124,58,237,0.18), rgba(255,255,255,0.08));box-shadow:0 12px 30px rgba(124,58,237,0.18)}
75
+ .auth-avatar-btn.guest{background:linear-gradient(135deg, rgba(124,58,237,0.18), rgba(255,255,255,0.08));border-color:rgba(124,58,237,0.42)}
76
+ .auth-avatar-btn.guest:hover{border-color:rgba(124,58,237,0.7);box-shadow:0 12px 32px rgba(124,58,237,0.22)}
77
+ .auth-avatar-circle{width:28px;height:28px;border-radius:50%;background:rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;flex-shrink:0}
78
+ .auth-avatar-icon{background:linear-gradient(135deg, rgba(124,58,237,0.26), rgba(255,255,255,0.08));color:var(--accent-v2)}
79
+ .auth-avatar-img{object-fit:cover}
80
+ .auth-nav{position:relative}
81
+
82
+ @media (max-width: 1024px) {
83
+ .container {
84
+ padding: 0 20px;
85
+ }
86
+ nav {
87
+ display: none;
88
+ }
89
+ .search-wrap {
90
+ max-width: 260px;
91
+ }
92
+ }
93
+
94
+ @media (max-width: 768px) {
95
+ .container {
96
+ padding: 0 16px;
97
+ }
98
+ .search-wrap {
99
+ display: none;
100
+ }
101
+ .anime-card {
102
+ width: 130px;
103
+ }
104
+ .hero-next,
105
+ .hero-prev {
106
+ display: none !important;
107
+ }
108
+ .hero-cover-wrap {
109
+ display: none;
110
+ }
111
+ .home-layout {
112
+ grid-template-columns: 1fr;
113
+ gap: 14px;
114
+ }
115
+ .home-sidebar {
116
+ position: static;
117
+ }
118
+ }
119
+
120
+ @media (max-width: 680px){
121
+ .container {
122
+ padding: 0 16px;
123
+ }
124
+ .auth-avatar-btn {
125
+ gap: 0;
126
+ padding: 5px 8px 5px 6px;
127
+ min-width: 42px;
128
+ justify-content: center;
129
+ }
130
+ .auth-avatar-btn span,
131
+ .auth-avatar-btn svg:last-child{
132
+ display: none;
133
+ }
134
+ .auth-avatar-circle {
135
+ width: 26px;
136
+ height: 26px;
137
+ }
138
+ .auth-avatar-icon svg {
139
+ width: 14px;
140
+ height: 14px;
141
+ }
142
+ .main-content {
143
+ padding-bottom: calc(66px + env(safe-area-inset-bottom));
144
+ }
145
+ .home-layout {
146
+ grid-template-columns: 1fr;
147
+ gap: 14px;
148
+ }
149
+ .spotlight-featured {
150
+ min-height: 262px;
151
+ border-radius: 14px;
152
+ }
153
+ .spotlight-featured-body {
154
+ padding: 12px 12px 38px;
155
+ }
156
+ .spotlight-featured-title {
157
+ font-size: 1.28rem;
158
+ margin-bottom: 4px;
159
+ }
160
+ .spotlight-nav-btn {
161
+ width: 30px;
162
+ height: 30px;
163
+ opacity: 1;
164
+ }
165
+ .spotlight-nav-btn.prev {
166
+ left: 8px;
167
+ }
168
+ .spotlight-nav-btn.next {
169
+ right: 8px;
170
+ }
171
+ .genre-row-wrap {
172
+ gap: 8px;
173
+ }
174
+ .genre-row {
175
+ gap: 8px;
176
+ }
177
+ .genre-pill {
178
+ padding: 7px 12px;
179
+ font-size: 0.74rem;
180
+ }
181
+ .section-header {
182
+ margin-bottom: 14px;
183
+ }
184
+ .section-title {
185
+ font-size: 1.02rem;
186
+ }
187
+ .section-title::before {
188
+ height: 19px;
189
+ }
190
+ .scroll-row {
191
+ gap: 10px;
192
+ }
193
+ .anime-card {
194
+ width: 138px;
195
+ }
196
+ .card-body {
197
+ padding: 8px 9px 10px;
198
+ }
199
+ .card-title {
200
+ font-size: 0.78rem;
201
+ }
202
+ .btn {
203
+ padding: 10px 18px;
204
+ font-size: 0.85rem;
205
+ }
206
+ .btn-sm {
207
+ padding: 6px 13px;
208
+ font-size: 0.78rem;
209
+ }
210
+ }
211
+
212
+ @media (max-width: 420px) {
213
+ .anime-card {
214
+ width: 126px;
215
+ }
216
+ .hero-ctas {
217
+ flex-direction: column;
218
+ }
219
+ .hero-cta {
220
+ width: 100%;
221
+ justify-content: center;
222
+ }
223
+ .spotlight-featured {
224
+ min-height: 240px;
225
+ }
226
+ .auth-dropdown {
227
+ left: 12px;
228
+ right: 12px;
229
+ min-width: 0;
230
+ }
231
+ }
232
+
233
+ /* ACCOUNT DROPDOWN */
234
+ .auth-dropdown{position:absolute;top:calc(100% + 10px);right:0;z-index:130;min-width:240px;background:rgba(12,12,20,0.96);backdrop-filter:blur(18px);border:1px solid var(--border-bright);border-radius:var(--radius-lg);box-shadow:0 22px 52px rgba(0,0,0,0.7);padding:10px;animation:fadeUp .18s ease both;overflow:hidden}
235
+ .auth-dropdown-head{padding:12px 12px 10px;border-bottom:1px solid var(--border);margin-bottom:8px;background:linear-gradient(180deg, rgba(124,58,237,0.1), transparent);border-radius:8px 8px 0 0}
236
+ .auth-dropdown-name{font-size:0.9rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
237
+ .auth-dropdown-sub{font-size:0.72rem;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
238
+ .auth-dropdown-item{display:flex;align-items:center;gap:9px;width:100%;padding:10px 12px;border-radius:var(--radius-md);font-size:0.85rem;font-weight:600;color:var(--text-primary);transition:var(--transition);cursor:pointer;background:none;border:none;text-align:left;margin-top:2px}
239
+ .auth-dropdown-item svg{color:var(--accent-v2);flex-shrink:0}
240
+ .auth-dropdown-item:hover{background:linear-gradient(90deg, rgba(124,58,237,0.16), rgba(255,255,255,0.03));border-color:transparent}
241
+ .auth-dropdown-item.danger{color:#fda4af}
242
+ .auth-dropdown-item.danger svg{color:#fda4af}
243
+ .auth-dropdown-item.danger:hover{background:rgba(225,29,72,0.12)}
244
+
245
+ /* AUTH MODAL */
246
+ .auth-overlay{position:fixed;inset:0;z-index:200;background:rgba(5,5,10,0.72);backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;padding:20px;animation:fadeIn .2s ease both}
247
+ .auth-modal{position:relative;width:100%;max-width:400px;background:var(--bg-surface);border:1px solid var(--border-bright);border-radius:var(--radius-lg);padding:30px 28px 26px;box-shadow:0 30px 90px rgba(0,0,0,0.7);animation:fadeUp .22s ease both}
248
+ .auth-modal-close{position:absolute;top:14px;right:14px;width:30px;height:30px;border-radius:50%;background:rgba(255,255,255,0.06);border:1px solid var(--border);color:var(--text-secondary);font-size:0.85rem;display:flex;align-items:center;justify-content:center;transition:var(--transition);cursor:pointer}
249
+ .auth-modal-close:hover{background:rgba(255,255,255,0.12);color:#fff}
250
+ .auth-modal-logo{font-family:'Bebas Neue','Outfit',sans-serif;font-size:1.9rem;letter-spacing:0.05em;text-align:center;margin-bottom:20px;color:#fff}
251
+ .auth-tabs{display:flex;background:rgba(255,255,255,0.05);border:1px solid var(--border);border-radius:var(--radius-pill);padding:4px;margin-bottom:20px}
252
+ .auth-tab{flex:1;padding:8px 0;border-radius:var(--radius-pill);font-size:0.84rem;font-weight:700;color:var(--text-secondary);transition:var(--transition);cursor:pointer;background:none;border:none}
253
+ .auth-tab.active{background:var(--grad-accent);color:#fff;box-shadow:0 0 16px rgba(124,58,237,0.4)}
254
+ .auth-form{display:flex;flex-direction:column;gap:14px}
255
+ .auth-field{display:flex;flex-direction:column;gap:6px}
256
+ .auth-field span{font-size:0.72rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--text-muted)}
257
+ .auth-field input{padding:11px 14px;border-radius:var(--radius-md);background:rgba(255,255,255,0.05);border:1px solid var(--border);color:var(--text-primary);font-size:0.9rem;outline:none;transition:var(--transition)}
258
+ .auth-field input:focus{background:rgba(255,255,255,0.08);border-color:var(--accent-v);box-shadow:0 0 0 3px rgba(139,92,246,0.12)}
259
+ .auth-field input::placeholder{color:var(--text-muted)}
260
+ .auth-error{font-size:0.8rem;font-weight:600;color:#fda4af;background:rgba(225,29,72,0.1);border:1px solid rgba(225,29,72,0.25);border-radius:var(--radius-sm);padding:8px 12px}
261
+ .auth-submit{width:100%;justify-content:center;margin-top:4px}
262
+ .auth-submit:disabled{opacity:0.5;cursor:not-allowed}
263
+ .auth-divider{display:flex;align-items:center;gap:12px;margin:18px 0 14px;color:var(--text-muted);font-size:0.72rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase}
264
+ .auth-divider::before,.auth-divider::after{content:'';flex:1;height:1px;background:var(--border)}
265
+ .auth-anilist-btn{width:100%;display:flex;align-items:center;justify-content:center;gap:9px;padding:11px;border-radius:var(--radius-md);background:rgba(6,182,212,0.12);border:1px solid rgba(6,182,212,0.35);color:#67e8f9;font-size:0.86rem;font-weight:700;transition:var(--transition);cursor:pointer}
266
+ .auth-anilist-btn:hover{background:rgba(6,182,212,0.22);border-color:rgba(6,182,212,0.6)}
267
+
268
+ /* ENHANCED AUTH STYLES */
269
+ .auth-modal-logo-text{font-size:1.4rem;font-weight:900;letter-spacing:.15em;background:linear-gradient(90deg,#fff 60%,#9d5bf0);-webkit-background-clip:text;background-clip:text;color:transparent}
270
+ .auth-input.error{border-color:rgba(248,113,113,.6)}
271
+ .auth-pw-bar{height:3px;border-radius:2px;background:#1e293b;overflow:hidden;margin-top:4px}
272
+ .auth-pw-fill{height:100%;width:0;border-radius:2px;transition:width .3s,background .3s}
273
+ .auth-error{display:none}
274
+ .auth-error.show{display:block}
275
+ .auth-submit-spinner{display:none;width:18px;height:18px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;margin:0 auto}
276
+ .auth-submit.loading .auth-submit-text{display:none}
277
+ .auth-submit.loading .auth-submit-spinner{display:block}
278
+ .auth-switch{text-align:center;font-size:0.8rem;color:#475569;margin-top:4px}
279
+ .auth-switch a{color:#9d5bf0;font-weight:600;cursor:pointer;text-decoration:none}
280
+ .auth-switch a:hover{text-decoration:underline}
281
+ .auth-toast{position:fixed;bottom:24px;right:24px;z-index:99999;background:#0f172a;border:1px solid rgba(74,222,128,.3);border-radius:12px;padding:14px 18px;display:flex;align-items:center;gap:10px;color:#86efac;font-size:0.85rem;font-weight:600;box-shadow:0 10px 40px rgba(0,0,0,.5);animation:slideUp .3s ease}
282
+ @keyframes slideUp{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
283
+ .auth-recaptcha-terms{margin-top:24px;text-align:center;font-size:0.65rem;color:#475569;line-height:1.4}
284
+ .auth-recaptcha-terms a{color:#64748b;text-decoration:underline;transition:color .2s}
285
+ .auth-recaptcha-terms a:hover{color:#9d5bf0}
286
+ .grecaptcha-badge{visibility:hidden!important}
287
+
288
+ /* BUTTONS */
289
+ .btn{display:inline-flex;align-items:center;gap:8px;padding:11px 24px;border-radius:var(--radius-pill);font-size:0.9rem;font-weight:600;transition:var(--transition);white-space:nowrap;cursor:pointer;border:none}
290
+ .btn-primary{background:var(--grad-accent);color:#fff;box-shadow:0 0 22px rgba(124,58,237,0.35)}
291
+ .btn-primary:hover{transform:translateY(-2px);box-shadow:0 4px 30px rgba(124,58,237,0.55)}
292
+ .btn-primary:disabled{opacity:0.4;cursor:not-allowed;transform:none}
293
+ .btn-ghost{background:rgba(255,255,255,0.07);color:var(--text-primary);border:1px solid var(--border)}
294
+ .btn-ghost:hover{background:rgba(255,255,255,0.12);border-color:var(--border-bright)}
295
+ .btn-ghost:disabled{opacity:0.4;cursor:not-allowed}
296
+ .btn-sm{padding:7px 16px;font-size:0.82rem}
297
+
298
+ /* BADGES */
299
+ .badge{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:var(--radius-pill);font-size:0.72rem;font-weight:600;letter-spacing:0.04em;text-transform:uppercase}
300
+ .badge-score{background:rgba(245,158,11,0.15);color:#fcd34d;border:1px solid rgba(245,158,11,0.3)}
301
+ .badge-green{background:rgba(34,197,94,0.15);color:#4ade80;border:1px solid rgba(34,197,94,0.3)}
302
+ .badge-dark{background:rgba(255,255,255,0.07);color:var(--text-secondary);border:1px solid var(--border)}
303
+
304
+ /* SECTION HEADERS */
305
+ .section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:22px}
306
+ .section-title{display:flex;align-items:center;gap:10px;font-size:1.2rem;font-weight:700}
307
+ .section-title::before{content:'';display:block;width:4px;height:22px;background:var(--grad-accent);border-radius:2px}
308
+ .section-link{font-size:0.84rem;font-weight:500;color:var(--accent-v2);display:flex;align-items:center;gap:4px;transition:var(--transition)}
309
+ .section-link:hover{color:var(--accent-c2);gap:8px}
310
+
311
+ /* ANIME CARD */
312
+ .anime-card{position:relative;flex-shrink:0;width:158px;border-radius:var(--radius-md);overflow:hidden;background:var(--bg-card);cursor:pointer;transition:var(--transition);border:1px solid var(--border)}
313
+ .anime-card:hover{transform:translateY(-6px) scale(1.02);border-color:var(--border-bright);box-shadow:var(--shadow-hover)}
314
+ .card-poster{position:relative;aspect-ratio:2/3;overflow:hidden}
315
+ .card-poster img{width:100%;height:100%;object-fit:cover;transition:transform 0.45s ease}
316
+ .anime-card:hover .card-poster img{transform:scale(1.05)}
317
+ .card-overlay{position:absolute;inset:0;background:var(--grad-card);opacity:0;transition:var(--transition)}
318
+ .anime-card:hover .card-overlay{opacity:1}
319
+ .card-quick{position:absolute;bottom:8px;left:8px;right:8px;opacity:0;transform:translateY(8px);transition:var(--transition)}
320
+ .anime-card:hover .card-quick{opacity:1;transform:translateY(0)}
321
+ .card-quick-btn{width:100%;padding:7px;border-radius:var(--radius-sm);background:var(--grad-accent);color:#fff;font-size:0.78rem;font-weight:600;text-align:center}
322
+ .card-body{padding:10px 10px 12px}
323
+ .card-title{font-size:0.84rem;font-weight:600;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-bottom:6px}
324
+ .card-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
325
+ .card-score{font-size:0.72rem;font-weight:700;color:#6ee7b7}
326
+ .card-meta-item{font-size:0.7rem;color:var(--text-muted)}
327
+
328
+ /* SCROLL ROW */
329
+ .scroll-row{display:flex;gap:14px;overflow-x:auto;scroll-snap-type:x mandatory;padding:12px 0;scrollbar-width:thin;scrollbar-color:var(--accent-v) transparent}
330
+ .scroll-row::-webkit-scrollbar{height:4px;display:block}
331
+ .scroll-row::-webkit-scrollbar-thumb{background:var(--accent-v);border-radius:99px}
332
+ .scroll-row .anime-card{scroll-snap-align:start}
333
+
334
+ /* SKELETON */
335
+ .skeleton{background:linear-gradient(90deg,var(--bg-elevated) 25%,var(--bg-card-hover) 50%,var(--bg-elevated) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:var(--radius-md)}
336
+ .skel-card{width:158px;flex-shrink:0}
337
+ .skel-poster{width:100%;aspect-ratio:2/3;border-radius:var(--radius-md) var(--radius-md) 0 0}
338
+ .skel-line{height:11px;margin:8px 10px;border-radius:6px}
339
+ .skel-line-sm{width:55%}
340
+ @keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
341
+ @keyframes fadeUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
342
+ @keyframes fadeIn{from{opacity:0}to{opacity:1}}
343
+ @keyframes spin{to{transform:rotate(360deg)}}
344
+ @keyframes marquee{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
345
+ @keyframes bounce{0%,100%{transform:translateX(-50%) translateY(0)}50%{transform:translateX(-50%) translateY(6px)}}
346
+ .spinner{width:34px;height:34px;border:3px solid var(--border);border-top-color:var(--accent-v);border-radius:50%;animation:spin 0.7s linear infinite}
347
+ .loader{display:flex;justify-content:center;align-items:center;padding:60px;flex-direction:column;gap:12px;color:var(--text-muted)}
348
+
349
+ /* HOME HERO */
350
+ .home-hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:calc(var(--header-h) + 40px) 20px 60px;text-align:center;background:radial-gradient(circle at 20% 15%, rgba(124,58,237,0.26), transparent 22%), radial-gradient(circle at 80% 18%, rgba(139,92,246,0.18), transparent 18%);}
351
+ .hero-glow{position:absolute;inset:0;background:radial-gradient(ellipse 70% 70% at 15% 50%,rgba(88,28,235,0.35) 0%,transparent 60%),radial-gradient(ellipse 40% 40% at 85% 50%,rgba(88,28,235,0.15) 0%,transparent 60%);pointer-events:none}
352
+ .hero-badge{display:inline-flex;align-items:center;gap:8px;padding:6px 16px;border-radius:var(--radius-pill);background:rgba(124,58,237,0.12);border:1px solid rgba(124,58,237,0.3);font-size:0.75rem;font-weight:700;letter-spacing:0.1em;color:var(--accent-v2);margin-bottom:32px;animation:fadeUp 0.5s ease both;box-shadow:0 14px 32px rgba(124,58,237,0.12)}
353
+ .hero-badge-dot{width:6px;height:6px;border-radius:50%;background:var(--accent-v)}
354
+ .hero-logo-text{font-family:'Rubik Dirt','Bebas Neue',sans-serif;font-size:clamp(5rem,16vw,14rem);font-weight:400;line-height:0.9;margin-bottom:16px;background:linear-gradient(180deg,#fff 40%,rgba(200,180,255,0.72) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;animation:fadeUp 0.6s ease 0.1s both;text-shadow:0 0 26px rgba(124,58,237,0.18)}
355
+ .hero-logo-e{display:inline-block}
356
+ .hero-subtitle{font-size:clamp(1.4rem,3.5vw,2.4rem);font-weight:700;color:var(--text-secondary);margin-bottom:14px;animation:fadeUp 0.6s ease 0.2s both}
357
+ .hero-desc{font-size:1rem;color:var(--text-secondary);max-width:500px;margin:0 auto 32px;line-height:1.7;animation:fadeUp 0.6s ease 0.25s both}
358
+ .hero-desc strong{color:var(--text-primary)}
359
+ .hero-filters{display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:24px;animation:fadeUp 0.6s ease 0.3s both;flex-wrap:wrap}
360
+ .hero-filter-pill{padding:7px 20px;border-radius:var(--radius-pill);background:rgba(255,255,255,0.06);border:1px solid var(--border);font-size:0.86rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);cursor:pointer}
361
+ .hero-filter-pill.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff}
362
+ .hero-filter-pill:hover:not(.active){border-color:var(--border-bright);color:var(--text-primary)}
363
+ .hero-search-wrap{position:relative;width:min(620px,100%);margin:0 auto 28px;animation:fadeUp 0.6s ease 0.35s both}
364
+ .hero-search-input{width:100%;padding:16px 60px 16px 22px;border-radius:var(--radius-pill);background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.1);color:var(--text-primary);font-size:0.95rem;outline:none;transition:var(--transition)}
365
+ .hero-search-input::placeholder{color:var(--text-muted)}
366
+ .hero-search-input:focus{background:rgba(255,255,255,0.1);border-color:var(--accent-v);box-shadow:0 0 0 4px rgba(124,58,237,0.15)}
367
+ .hero-search-btn{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:42px;height:42px;border-radius:50%;background:var(--accent-v);color:#fff;display:flex;align-items:center;justify-content:center;transition:var(--transition)}
368
+ .hero-search-btn:hover{background:var(--accent-v2);transform:translateY(-50%) scale(1.08)}
369
+ .hero-search-btn svg{width:18px;height:18px}
370
+ .hero-popular{margin-bottom:36px;animation:fadeUp 0.6s ease 0.4s both}
371
+ .hero-popular-label{font-size:0.72rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);margin-bottom:12px}
372
+ .hero-popular-tags{display:flex;justify-content:center;flex-wrap:wrap;gap:8px}
373
+ .hero-popular-tag{padding:6px 16px;border-radius:var(--radius-pill);background:rgba(255,255,255,0.05);border:1px solid var(--border);font-size:0.82rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);cursor:pointer}
374
+ .hero-popular-tag:hover{background:rgba(124,58,237,0.12);border-color:var(--border-bright);color:var(--accent-v2)}
375
+ .hero-ctas{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:12px;margin-bottom:52px;animation:fadeUp 0.6s ease 0.45s both}
376
+ .hero-cta{display:inline-flex;align-items:center;gap:10px;padding:13px 26px;border-radius:var(--radius-pill);font-size:0.9rem;font-weight:700;transition:var(--transition);cursor:pointer;border:1px solid transparent}
377
+ .hero-cta-anime{background:var(--grad-accent);color:#fff;box-shadow:0 0 28px rgba(124,58,237,0.4)}
378
+ .hero-cta-anime:hover{transform:translateY(-2px);box-shadow:0 6px 36px rgba(124,58,237,0.55)}
379
+ .hero-cta-manga{background:rgba(255,255,255,0.07);border-color:var(--border);color:var(--text-primary)}
380
+ .hero-cta-manga:hover{background:rgba(255,255,255,0.12);border-color:var(--border-bright)}
381
+ .hero-cta-movies{background:rgba(6,182,212,0.15);border-color:rgba(6,182,212,0.4);color:#67e8f9}
382
+ .hero-cta-movies:hover{background:rgba(6,182,212,0.25);transform:translateY(-2px)}
383
+ .hero-cta-music{background:rgba(139,92,246,0.18);border-color:rgba(139,92,246,0.4);color:#a78bfa}
384
+ .hero-cta-music:hover{background:rgba(139,92,246,0.28);transform:translateY(-2px)}
385
+ .hero-stats{display:flex;align-items:center;justify-content:center;animation:fadeUp 0.6s ease 0.5s both}
386
+ .hero-stat{padding:0 36px;text-align:center;position:relative}
387
+ .hero-stat+.hero-stat::before{content:'';position:absolute;left:0;top:15%;bottom:15%;width:1px;background:var(--border)}
388
+ .hero-stat-val{font-size:1.6rem;font-weight:800;letter-spacing:-0.02em;margin-bottom:2px}
389
+ .hero-stat-label{font-size:0.68rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted)}
390
+ .scroll-indicator{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);color:var(--text-muted);animation:bounce 2s ease infinite}
391
+
392
+ /* LIBRARY MARQUEE */
393
+ .library-section{padding:60px 0;overflow:hidden;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
394
+ .library-header{display:flex;align-items:center;justify-content:center;gap:20px;margin-bottom:28px}
395
+ .library-header-line{flex:1;height:1px;background:linear-gradient(to right,transparent,var(--border-bright));max-width:200px}
396
+ .library-header-line.right{background:linear-gradient(to left,transparent,var(--border-bright))}
397
+ .library-header-text{font-size:0.7rem;font-weight:700;letter-spacing:0.15em;text-transform:uppercase;color:var(--text-muted)}
398
+ .marquee-track{overflow:hidden}
399
+ .marquee-row{display:flex;gap:10px;width:max-content;animation:marquee 30s linear infinite;margin-bottom:10px}
400
+ .marquee-row.reverse{animation:marquee 35s linear infinite reverse}
401
+ .marquee-row:last-child{margin-bottom:0}
402
+ .marquee-poster{width:100px;height:140px;flex-shrink:0;border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--border);transition:var(--transition);cursor:pointer}
403
+ .marquee-poster:hover{border-color:var(--border-bright);transform:scale(1.04)}
404
+ .marquee-poster img{width:100%;height:100%;object-fit:cover}
405
+
406
+ /* FEATURE SECTION */
407
+ .feature-section{padding:80px 0}
408
+ .feature-eyebrow{font-size:0.75rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--accent-v2);margin-bottom:16px}
409
+ .feature-heading{font-size:clamp(2rem,4vw,3.2rem);font-weight:800;line-height:1.15;margin-bottom:20px}
410
+ .feature-heading .accent{color:var(--accent-v2)}
411
+ .feature-desc{font-size:1rem;color:var(--text-secondary);line-height:1.75;max-width:480px;margin-bottom:40px}
412
+ .feature-card{background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;max-width:560px}
413
+ .feature-card-icon{width:44px;height:44px;border-radius:var(--radius-md);background:rgba(124,58,237,0.15);border:1px solid rgba(124,58,237,0.3);display:flex;align-items:center;justify-content:center;color:var(--accent-v2);margin-bottom:16px}
414
+ .feature-card-eyebrow{font-size:0.72rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--accent-v2);margin-bottom:6px}
415
+ .feature-card-title{font-size:1.1rem;font-weight:800;margin-bottom:8px}
416
+ .feature-card-desc{font-size:0.88rem;color:var(--text-secondary);line-height:1.7;margin-bottom:16px}
417
+ .feature-track-item{display:flex;align-items:center;gap:12px;padding:12px 14px;background:var(--bg-elevated);border-radius:var(--radius-md);margin-bottom:8px}
418
+ .feature-track-name{font-size:0.88rem;font-weight:600}
419
+ .feature-track-sub{font-size:0.75rem;color:var(--text-muted)}
420
+ .feature-track-badge{margin-left:auto;font-size:0.72rem;font-weight:700;color:var(--accent-green)}
421
+ .content-section{padding:40px 0}
422
+ /* Pages whose first section sits directly under the fixed header (Home) */
423
+ .content-section:first-child{padding-top:calc(var(--header-h) + 26px)}
424
+ .section-sub-label{font-size:0.7rem;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:var(--text-muted)}
425
+
426
+ /* DETAILS PAGE */
427
+ .info-hero{position:relative;min-height:520px;overflow:hidden;display:flex;align-items:flex-end;padding-top:var(--header-h);background:#0a0a0a}
428
+ .info-hero::before,
429
+ .info-hero::after{content:'';position:absolute;inset:0;pointer-events:none}
430
+ .info-hero::before{background:radial-gradient(120% 80% at 30% 30%, rgba(0,0,0,0.25), rgba(0,0,0,0.85) 70%, #0a0a0a 100%)}
431
+ .info-hero::after{background:linear-gradient(180deg, rgba(10,10,10,0) 40%, rgba(10,10,10,0.85) 80%, var(--bg-base) 100%)}
432
+ .info-hero-bg{position:absolute;inset:0;background-size:cover;background-position:center;background-repeat:no-repeat;transform:scale(1.05);filter:brightness(0.55)}
433
+ .info-hero-inner{position:relative;z-index:2;display:flex;gap:36px;padding:96px 28px 56px;align-items:flex-end}
434
+ .info-poster{flex:0 0 240px;width:240px;aspect-ratio:2/3;border-radius:14px;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,0.6),0 0 0 1px rgba(255,255,255,0.06);background:#111;border:1px solid rgba(255,255,255,0.06)}
435
+ .info-poster img{width:100%;height:100%;object-fit:cover;display:block}
436
+ .info-head{flex:1;min-width:0;display:flex;flex-direction:column;gap:18px}
437
+ .info-eyebrow{display:inline-flex;align-items:center;gap:10px;color:rgba(255,255,255,0.7);font-size:0.78rem;font-weight:600;letter-spacing:0.18em;text-transform:uppercase}
438
+ .info-eyebrow .dot{width:6px;height:6px;border-radius:50%;background:var(--accent-v);box-shadow:0 0 12px var(--accent-v)}
439
+ .info-title{font-family:'Poppins','Inter',sans-serif;font-size:clamp(1.8rem,3.6vw,3.2rem);font-weight:900;line-height:1.05;color:#fff;margin:0;text-shadow:0 6px 28px rgba(0,0,0,0.6)}
440
+ .info-subtitle{font-size:0.95rem;color:rgba(255,255,255,0.65);font-weight:500;margin-top:-6px}
441
+ .info-meta-row{display:flex;flex-wrap:wrap;gap:16px;align-items:center;color:rgba(255,255,255,0.85);font-size:0.85rem;font-weight:600}
442
+ .meta-pill{background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.08);padding:6px 12px;border-radius:999px;backdrop-filter:blur(6px)}
443
+ .meta-score{color:#fbbf24}
444
+ .meta-status-airing{color:#22c55e}
445
+ .info-genres{display:flex;gap:8px;flex-wrap:wrap}
446
+ .info-genre-tag{background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);padding:5px 12px;border-radius:999px;font-size:0.72rem;font-weight:600;color:rgba(255,255,255,0.85);cursor:pointer;transition:all 0.2s ease}
447
+ .info-genre-tag:hover{background:rgba(255,255,255,0.12);transform:translateY(-1px)}
448
+ .info-cta{display:flex;gap:12px;margin-top:4px;flex-wrap:wrap}
449
+ .btn-watch{display:inline-flex;align-items:center;gap:10px;padding:14px 28px;border-radius:12px;background:linear-gradient(135deg, var(--accent-v) 0%, var(--accent-v2) 100%);color:#fff;font-weight:800;font-size:0.95rem;letter-spacing:0.04em;text-decoration:none;border:none;cursor:pointer;transition:transform 0.2s ease, box-shadow 0.2s ease;box-shadow:0 12px 30px rgba(124,58,237,0.35)}
450
+ .btn-watch:hover{transform:translateY(-2px);box-shadow:0 18px 40px rgba(124,58,237,0.5)}
451
+ .btn-secondary{display:inline-flex;align-items:center;gap:8px;padding:14px 22px;border-radius:12px;background:rgba(255,255,255,0.08);color:#fff;font-weight:700;font-size:0.9rem;text-decoration:none;border:1px solid rgba(255,255,255,0.1);cursor:pointer;transition:background 0.2s ease}
452
+ .btn-secondary:hover{background:rgba(255,255,255,0.14)}
453
+ .info-body{padding:40px 28px 80px}
454
+ .info-section{margin-bottom:48px}
455
+ .info-section-title{display:flex;align-items:center;gap:12px;font-family:'Poppins','Inter',sans-serif;font-size:1.3rem;font-weight:800;color:#fff;margin:0 0 18px}
456
+ .info-section-title::before{content:'';width:4px;height:22px;border-radius:4px;background:var(--accent-v)}
457
+ .info-grid-2{display:grid;grid-template-columns:minmax(0, 2fr) minmax(0, 1fr);gap:32px;align-items:flex-start}
458
+ .info-desc{font-size:0.96rem;line-height:1.75;color:rgba(255,255,255,0.78);margin:0;position:relative;max-height:22em;overflow:hidden;transition:max-height .3s ease}
459
+ .info-desc.expanded{max-height:200em}
460
+ .info-desc::after{content:'';position:absolute;inset:auto 0 0 0;height:3em;background:linear-gradient(180deg, rgba(10, 10, 10, 0) 0%, var(--bg-base) 100%);pointer-events:none;transition:opacity 0.3s ease}
461
+ .info-desc.expanded::after{opacity:0}
462
+ .info-desc-toggle{display:inline-block;margin-top:8px;color:var(--accent-v2);font-weight:700;font-size:0.85rem;cursor:pointer;background:none;border:none;padding:0}
463
+ .info-stats{display:grid;grid-template-columns:1fr 1fr;gap:14px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:14px;padding:22px}
464
+ .info-stat-row{display:flex;flex-direction:column;gap:4px}
465
+ .info-stat-row .label{font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.12em;color:rgba(255,255,255,0.45)}
466
+ .info-stat-row .value{font-size:0.95rem;font-weight:700;color:#fff}
467
+ .info-tags{display:flex;flex-wrap:wrap;gap:6px}
468
+ .info-tag{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.06);padding:4px 10px;border-radius:6px;font-size:0.72rem;font-weight:600;color:rgba(255,255,255,0.7)}
469
+ @media (max-width: 880px) {
470
+ .info-hero-inner{flex-direction:column;align-items:center;text-align:center;padding:80px 22px 40px;gap:24px}
471
+ .info-poster{flex:0 0 180px;width:180px}
472
+ .info-meta-row,.info-genres,.info-cta{justify-content:center}
473
+ .info-grid-2{grid-template-columns:1fr}
474
+ .info-hero{min-height:auto}
475
+ .info-body{padding:32px 22px 80px}
476
+ }
477
+ @media (max-width: 540px) {
478
+ .info-hero-inner{padding:72px 20px 36px}
479
+ .info-poster{flex:0 0 150px;width:150px}
480
+ .btn-watch{padding:12px 22px;font-size:0.88rem}
481
+ .info-body{padding:28px 20px 80px}
482
+ }
483
+
484
+ /* NEXT-EPISODE COUNTDOWN */
485
+ .countdown-wrap{display:inline-flex;flex-direction:column;gap:10px;padding:14px 18px;margin-bottom:22px;border-radius:var(--radius-md);background:rgba(124,58,237,0.08);border:1px solid rgba(124,58,237,0.3);backdrop-filter:blur(8px);width:fit-content}
486
+ .countdown-label{font-size:0.72rem;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--accent-v2)}
487
+ .countdown-cards{display:flex;gap:8px}
488
+ .countdown-cell{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:58px;padding:8px 6px;border-radius:var(--radius-sm);background:rgba(8,8,8,0.6);border:1px solid var(--border-bright)}
489
+ .countdown-num{font-size:1.35rem;font-weight:800;font-variant-numeric:tabular-nums;line-height:1;color:#fff}
490
+ .countdown-unit{font-size:0.6rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted)}
491
+ .info-genre-pill{padding:5px 14px;background:rgba(255,255,255,0.07);border:1px solid var(--border);border-radius:var(--radius-pill);font-size:0.8rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);cursor:pointer}
492
+ .info-genre-pill:hover{border-color:var(--border-bright);color:var(--accent-v2)}
493
+ .info-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
494
+ .info-btn-watch{background:var(--grad-accent);color:#fff;box-shadow:0 0 24px rgba(124,58,237,0.35)}
495
+ .info-btn-watch:hover{transform:translateY(-2px);box-shadow:0 6px 32px rgba(124,58,237,0.55)}
496
+ .info-btn-ghost{background:rgba(255,255,255,0.08);color:var(--text-primary);border:1px solid var(--border)}
497
+ .info-btn-ghost:hover{background:rgba(255,255,255,0.14);border-color:var(--border-bright)}
498
+ .info-body{padding:44px 0 80px}
499
+ .info-layout{display:grid;grid-template-columns:1fr 300px;gap:48px;align-items:start}
500
+ .info-section-label{font-size:1.15rem;font-weight:700;display:flex;align-items:center;gap:10px;margin-bottom:16px}
501
+ .info-section-label::before{content:'';width:4px;height:20px;background:var(--grad-accent);border-radius:2px}
502
+ .info-synopsis{font-size:0.93rem;color:var(--text-secondary);line-height:1.8;margin-bottom:8px}
503
+ .info-synopsis.clamped{display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}
504
+ .show-more-btn{color:var(--accent-v2);font-size:0.88rem;font-weight:600;display:inline-flex;align-items:center;gap:4px;transition:var(--transition);margin-top:8px;cursor:pointer;background:none;border:none}
505
+ .show-more-btn:hover{color:var(--accent-c2)}
506
+ .info-tags{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0 32px}
507
+ .info-tag{padding:5px 14px;background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-pill);font-size:0.78rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);cursor:pointer}
508
+ .info-tag:hover{border-color:var(--border-bright);color:var(--accent-v2)}
509
+ .info-stats-grid{background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;display:grid;grid-template-columns:1fr 1fr}
510
+ .info-stat-cell{padding:18px 20px;border-right:1px solid var(--border);border-bottom:1px solid var(--border)}
511
+ .info-stat-cell:nth-child(2n){border-right:none}
512
+ .info-stat-cell:nth-last-child(-n+2){border-bottom:none}
513
+ .info-stat-label{font-size:0.68rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--text-muted);margin-bottom:5px}
514
+ .info-stat-value{font-size:0.95rem;font-weight:700}
515
+ .char-row{display:flex;gap:16px;overflow-x:auto;padding:4px 0 12px}
516
+ .char-card{display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0;cursor:pointer}
517
+ .char-avatar{width:80px;height:80px;border-radius:50%;overflow:hidden;border:2px solid var(--border);transition:var(--transition)}
518
+ .char-avatar img{width:100%;height:100%;object-fit:cover}
519
+ .char-card:hover .char-avatar{border-color:var(--border-bright);transform:scale(1.06)}
520
+ .char-name{font-size:0.78rem;font-weight:600;text-align:center;max-width:84px;line-height:1.3}
521
+ .char-role{font-size:0.68rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em}
522
+
523
+ /* WATCH PAGE */
524
+ .watch-page{padding-top:var(--header-h)}
525
+ .watch-container{padding:0 28px 80px;max-width:var(--max-w);margin:0 auto}
526
+ .watch-layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:18px;align-items:start;padding-top:12px}
527
+ .watch-main{min-width:0}
528
+ .watch-sidebar-col{min-width:0}
529
+ .player-wrapper{position:relative;width:100%;aspect-ratio:16/9;background:#080a12;border-radius:14px;overflow:hidden;border:1px solid var(--border)}
530
+ .player-wrapper iframe,.player-wrapper video,.player-wrapper .custom-player{width:100%;height:100%;border:none;display:block}
531
+ .player-placeholder{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;color:var(--text-muted);background:rgba(7,10,18,0.95)}
532
+ .player-wrap.theater-mode{position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:#000;display:flex;align-items:center;justify-content:center}
533
+ .player-wrap.theater-mode .custom-player{width:100%;height:100%;max-width:100%;max-height:100%;border-radius:0}
534
+ @media(max-width:680px){.auth-nav{display:none}}
535
+ .player-placeholder svg{width:52px;height:52px;opacity:0.3}
536
+ .player-placeholder span{font-size:0.9rem;font-weight:500}
537
+ .player-error-box{color:#f43f5e}
538
+ .server-warning{display:flex;align-items:center;gap:10px;padding:10px 16px;background:rgba(245,158,11,0.07);border:1px solid rgba(245,158,11,0.2);border-radius:var(--radius-md);margin:10px 0;font-size:0.83rem;color:#fcd34d}
539
+ .server-warning-close{margin-left:auto;opacity:0.5;transition:var(--transition);font-size:1.1rem;line-height:1;cursor:pointer;border:none;background:none;color:inherit}
540
+ .server-warning-close:hover{opacity:1}
541
+ .watch-warn-banner{display:flex;align-items:center;gap:10px;padding:12px 14px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.2);border-radius:12px;margin:10px 0 14px;font-size:0.83rem;color:#fcd34d}
542
+ .wwb-icon{width:16px;height:16px;flex-shrink:0}
543
+ .wwb-text{display:block;flex:1}
544
+ .wwb-close{margin-left:auto;border:none;background:none;color:inherit;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0}
545
+ .wwb-close svg{width:16px;height:16px}
546
+ .kv-prefs-bar{display:flex;align-items:center;gap:18px;padding:11px 14px;background:var(--bg-surface);border:1px solid var(--border);border-radius:12px;margin:10px 0;flex-wrap:wrap}
547
+ .kv-pref-btn{display:flex;align-items:center;gap:8px;padding:0;background:none;border:none;color:var(--text-secondary);font-size:0.82rem;font-weight:600;cursor:pointer}
548
+ .kv-pref-btn svg{opacity:0.8}
549
+ .kv-pref-pill{position:relative;display:inline-flex;align-items:center;width:36px;height:20px;border-radius:999px;background:rgba(255,255,255,0.08);border:1px solid var(--border);margin-left:4px}
550
+ .kv-pref-knob{position:absolute;left:3px;width:12px;height:12px;border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,0.4)}
551
+ .kv-pref-btn.on{color:var(--text-primary)}.kv-pref-btn.on svg{color:var(--accent-v2)}.kv-pref-pill.on{background:var(--accent-v);border-color:var(--accent-v)}.kv-pref-pill.on .kv-pref-knob{left:19px}.kv-pref-knob{transition:left .18s ease}.action-btn.is-active{color:var(--accent-v2);border-color:rgba(124,58,237,.45);background:rgba(124,58,237,.12)}
552
+ .kv-prefs-sep{width:1px;height:16px;background:rgba(255,255,255,0.08)}
553
+ .watch-player-info{padding-top:14px}
554
+ .watch-ep-label{font-size:0.72rem;font-weight:800;color:var(--accent-v2);letter-spacing:0.12em;text-transform:uppercase;margin:0 0 8px}
555
+ .watch-ep-title{font-size:1.45rem;font-weight:800;line-height:1.25;margin:0 0 8px}
556
+ .watch-filler-warn{color:#facc15;font-size:0.82rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;margin-top:8px}
557
+ .action-bar{display:flex;align-items:center;gap:12px;padding:14px 0 18px;border-bottom:1px solid var(--border);margin-bottom:18px}
558
+ .action-anime{display:flex;align-items:center;gap:12px;min-width:0;flex:1;text-decoration:none;color:inherit}
559
+ .aa-poster{width:52px;height:52px;border-radius:10px;overflow:hidden;flex-shrink:0;border:1px solid rgba(255,255,255,0.08)}
560
+ .aa-poster img{width:100%;height:100%;object-fit:cover;display:block}
561
+ .aa-info{min-width:0;display:flex;flex-direction:column;gap:4px}
562
+ .aa-name{font-size:0.94rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
563
+ .aa-meta{font-size:0.72rem;color:var(--text-muted)}
564
+ .action-buttons{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto}
565
+ .audio-toggle{display:flex;align-items:center;background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:999px;padding:4px;gap:4px}
566
+ .audio-btn{padding:6px 12px;border-radius:999px;font-size:0.78rem;font-weight:700;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;transition:var(--transition)}
567
+ .audio-btn.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff}
568
+ .audio-btn:disabled{opacity:0.5;cursor:not-allowed}
569
+ .action-btn{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;background:rgba(255,255,255,0.06);border:1px solid var(--border);color:var(--text-primary);font-weight:700;font-size:0.8rem;cursor:pointer;transition:var(--transition)}
570
+ .action-btn svg{width:15px;height:15px}
571
+ .action-btn:hover,.audio-btn:not(.active):hover{border-color:var(--border-bright)}
572
+ .audio-lang-selected{font-size:0.78rem;color:var(--text-muted)}
573
+ .server-panel{background:var(--bg-surface);border:1px solid var(--border);border-radius:14px;padding:14px;margin:10px 0 0;box-shadow:0 20px 40px rgba(0,0,0,0.24)}
574
+ .server-panel.is-hidden{display:none}
575
+ .server-panel-row{display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap}
576
+ .server-group{display:flex;flex-direction:column;gap:10px;flex:1;min-width:180px}
577
+ .server-group-toggle{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:10px;background:rgba(255,255,255,0.04);border:1px solid var(--border);color:var(--text-primary);font-weight:700;cursor:pointer}
578
+ .sg-icon{flex-shrink:0}
579
+ .sg-label{flex:1;text-align:left}
580
+ .sg-count{font-size:0.72rem;padding:2px 7px;border-radius:999px;background:rgba(255,255,255,0.08);color:var(--text-muted)}
581
+ .server-btn-row{display:flex;gap:8px;flex-wrap:wrap;padding-left:2px}
582
+ .server-option{padding:7px 12px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,0.03);color:var(--text-secondary);font-size:0.78rem;font-weight:600;cursor:pointer}
583
+ .server-option.selected{background:rgba(124,58,237,0.16);border-color:rgba(124,58,237,0.42);color:var(--text-primary)}
584
+ .server-quality-wrap{margin-left:auto}
585
+ .server-quality-select{padding:8px 10px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,0.04);color:var(--text-primary)}
586
+ .server-loading,.server-error{display:flex;align-items:center;gap:8px;padding-top:12px;font-size:0.8rem;color:var(--text-muted)}
587
+ .server-loading-dot{width:8px;height:8px;border-radius:50%;background:var(--accent-v);display:inline-block;animation:pulse 1.3s ease-in-out infinite}
588
+ @keyframes pulse{0%,100%{opacity:0.45;transform:scale(0.9)}50%{opacity:1;transform:scale(1.1)}}
589
+ .watch-desc-card{background:var(--bg-surface);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin-top:18px}
590
+ .watch-sidebar{background:var(--bg-surface);border:1px solid var(--border);border-radius:14px;overflow:hidden;position:sticky;top:calc(var(--header-h) + 12px);max-height:calc(100vh - var(--header-h) - 24px);display:flex;flex-direction:column}
591
+ .up-next-header{padding:14px 14px 0}
592
+ .un-info{display:flex;flex-direction:column;gap:3px}
593
+ .un-title{font-size:1rem;font-weight:800}
594
+ .un-subtitle{font-size:0.76rem;color:var(--accent-v2)}
595
+ .next-ep-strip{padding:12px 14px 0}
596
+ .next-ep-pill{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border-radius:12px;background:rgba(124,58,237,0.08);border:1px solid rgba(124,58,237,0.22);color:var(--text-primary)}
597
+ .nep-bell{width:15px;height:15px}
598
+ .nep-label{font-size:0.72rem;font-weight:700;color:var(--text-muted);letter-spacing:0.08em;text-transform:uppercase}
599
+ .nep-units{display:flex;align-items:flex-end;gap:8px}
600
+ .nec-unit{display:flex;align-items:flex-end;gap:2px;font-weight:800;color:#fff}
601
+ .nec-num{font-size:0.9rem}
602
+ .nec-u{font-size:0.6rem;color:var(--text-muted)}
603
+ .ep-search-wrap{padding:12px 14px 0}
604
+ .ep-search-input{width:100%;padding:9px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;color:var(--text-primary);font-size:0.82rem;outline:none}
605
+ .ep-search-input::placeholder{color:var(--text-muted)}
606
+ .ep-view-bar{display:flex;align-items:center;justify-content:space-between;padding:12px 14px 8px}
607
+ .ep-view-bar-label{font-size:0.68rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--text-muted)}
608
+ .ep-view-toggle-btn{padding:5px 9px;border:1px solid var(--border);border-radius:8px;color:var(--text-secondary);font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.ep-view-toggle-btn:hover{color:var(--text-primary);border-color:var(--border-bright)}
609
+ .ep-list{flex:1;overflow-y:auto;padding:0 8px 12px;scrollbar-width:thin;scrollbar-color:var(--accent-v) transparent}
610
+ .ep-list::-webkit-scrollbar{width:3px;display:block}
611
+ .ep-list::-webkit-scrollbar-thumb{background:var(--accent-v);border-radius:99px}
612
+ .ep-list.compact{display:grid;grid-template-columns:repeat(auto-fill,minmax(54px,1fr));gap:7px;align-content:start}.ep-list.compact .ep-item{justify-content:center;margin:0;padding:9px 5px}.ep-list.compact .ep-thumb{width:auto;height:auto;background:transparent;overflow:visible}.ep-list.compact .ep-thumb img,.ep-list.compact .ep-info{display:none}.ep-list.compact .ep-thumb-label{position:static;background:transparent;padding:0;color:var(--text-secondary);font-size:.76rem}.ep-list.compact .ep-item.active .ep-thumb-label{color:var(--accent-v2)}
613
+ .ep-item{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:10px;cursor:pointer;transition:var(--transition);margin-bottom:2px;border:1px solid transparent}
614
+ .ep-item:hover{background:rgba(255,255,255,0.035)}
615
+ .ep-item.active{background:rgba(124,58,237,0.12);border-color:rgba(124,58,237,0.25)}
616
+ .ep-thumb{width:72px;height:46px;flex-shrink:0;background:var(--bg-elevated);border-radius:8px;overflow:hidden;display:flex;align-items:center;justify-content:center;font-size:0.68rem;font-weight:700;color:var(--text-muted);position:relative}
617
+ .ep-thumb img{width:100%;height:100%;object-fit:cover}
618
+ .ep-thumb-label{position:absolute;bottom:3px;left:3px;background:rgba(0,0,0,0.7);color:#fff;font-size:0.62rem;font-weight:700;padding:1px 5px;border-radius:4px}
619
+ .ep-info{flex:1;min-width:0}
620
+ .ep-info-title{font-size:0.8rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text-primary)}
621
+ .ep-info-sub{font-size:0.7rem;color:var(--text-muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
622
+ .ep-watched-badge{position:absolute;top:3px;right:3px;width:18px;height:18px;border-radius:50%;background:#22c55e;color:#fff;font-size:0.65rem;font-weight:700;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,0.4)}
623
+ .ep-item.watched{opacity:0.7}
624
+ .ep-item.watched:hover{opacity:1}
625
+ .season-selector{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border)}
626
+ .season-label{font-size:0.68rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--text-muted)}
627
+ .season-options{display:flex;gap:6px;flex-wrap:wrap}
628
+ .season-btn{padding:6px 12px;border-radius:8px;background:rgba(255,255,255,0.04);border:1px solid var(--border);color:var(--text-secondary);font-size:0.76rem;font-weight:600;cursor:pointer;transition:var(--transition)}
629
+ .season-btn:hover{border-color:var(--border-bright);color:var(--text-primary)}
630
+ .season-btn.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff;box-shadow:0 0 12px rgba(124,58,237,0.35)}
631
+ .movie-quality-bar{display:flex;align-items:center;gap:12px;padding:12px 0;margin-top:8px}
632
+ .movie-quality-label{font-size:0.68rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--text-muted)}
633
+ .movie-quality-options{display:flex;gap:8px;flex-wrap:wrap}
634
+ .movie-quality-btn{padding:6px 14px;border-radius:8px;background:rgba(255,255,255,0.04);border:1px solid var(--border);color:var(--text-secondary);font-size:0.76rem;font-weight:600;cursor:pointer;transition:var(--transition)}
635
+ .movie-quality-btn:hover{border-color:var(--border-bright);color:var(--text-primary)}
636
+ .movie-quality-btn.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff;box-shadow:0 0 12px rgba(124,58,237,0.35)}
637
+ .watch-anime-row{display:flex;align-items:center;gap:16px;padding:16px;background:var(--bg-surface);border:1px solid var(--border);border-radius:12px;margin:16px 0}
638
+ .watch-anime-thumb{width:48px;height:48px;border-radius:8px;overflow:hidden;flex-shrink:0;background:var(--bg-elevated)}
639
+ .watch-anime-thumb img{width:100%;height:100%;object-fit:cover}
640
+ .watch-anime-name{font-size:0.9rem;font-weight:700;color:var(--text-primary)}
641
+ .watch-anime-sub-text{font-size:0.75rem;color:var(--text-muted);margin-top:2px}
642
+ .watch-audio-btns{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}
643
+ .audio-notice{display:flex;align-items:center;gap:10px;padding:9px 14px;background:rgba(124,58,237,0.08);border:1px solid rgba(124,58,237,0.28);border-radius:var(--radius-md);margin:12px 0;font-size:0.83rem;font-weight:500;color:var(--accent-c2);animation:fadeUp .2s ease both}
644
+ .audio-notice svg{flex-shrink:0;color:var(--accent-v2)}
645
+ .audio-notice .server-warning-close{margin-left:auto;color:var(--text-muted)}
646
+
647
+ @media (max-width: 1024px){
648
+ .watch-layout{grid-template-columns:1fr}
649
+ .watch-sidebar{position:static;max-height:540px}
650
+ }
651
+ @media (max-width: 680px){
652
+ .watch-container{padding:0 12px 60px}
653
+ .watch-layout{gap:12px;padding-top:8px}
654
+ .watch-warn-banner{padding:10px 12px}
655
+ .action-bar{flex-wrap:wrap;border-bottom:none;padding-bottom:8px}
656
+ .action-anime{width:100%}
657
+ .action-buttons{width:100%;justify-content:flex-start}
658
+ .watch-ep-title{font-size:1.15rem}
659
+ .watch-desc-card{padding:14px 16px}
660
+ .watch-sidebar{position:static;max-height:420px}
661
+ .ep-thumb{width:64px;height:40px}
662
+ .aa-poster{width:44px;height:44px}
663
+ }
664
+
665
+ /* MOVIES & TV */
666
+ .movie-search-wrap{position:relative}
667
+ .movie-tabs{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}
668
+ .movie-tabs button{padding:8px 20px;border-radius:var(--radius-pill);background:rgba(255,255,255,0.06);border:1px solid var(--border);font-size:0.86rem;font-weight:600;color:var(--text-secondary);transition:var(--transition);cursor:pointer}
669
+ .movie-tabs button:hover{border-color:var(--border-bright);color:var(--text-primary)}
670
+ .movie-tabs button.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff;box-shadow:0 0 18px rgba(124,58,237,0.35)}
671
+ .movie-sort{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:0 0 24px}
672
+ .movie-sort-label{font-size:0.7rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);margin-right:6px}
673
+ .movie-sort button{padding:5px 13px;border-radius:var(--radius-pill);background:rgba(255,255,255,0.04);border:1px solid var(--border);font-size:0.76rem;font-weight:600;color:var(--text-secondary);transition:var(--transition);cursor:pointer}
674
+ .movie-sort button:hover{border-color:var(--border-bright);color:var(--text-primary)}
675
+ .movie-sort button.active{background:rgba(124,58,237,0.18);border-color:rgba(124,58,237,0.5);color:var(--accent-c2)}
676
+ .movie-suggest{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:60;background:var(--bg-elevated);border:1px solid var(--border-bright);border-radius:var(--radius-md);box-shadow:0 18px 50px rgba(0,0,0,0.6);overflow:hidden;max-height:320px;overflow-y:auto}
677
+ .movie-suggest-item{display:flex;align-items:center;gap:10px;width:100%;padding:11px 16px;font-size:0.88rem;color:var(--text-secondary);transition:var(--transition);cursor:pointer;text-align:left;background:none;border:none;border-bottom:1px solid var(--border)}
678
+ .movie-suggest-item:last-child{border-bottom:none}
679
+ .movie-suggest-item:hover{background:rgba(124,58,237,0.12);color:var(--text-primary)}
680
+ .movie-suggest-item svg{color:var(--accent-v2);flex-shrink:0}
681
+ .load-more-wrap{display:flex;justify-content:center;margin-top:36px}
682
+ .load-more-btn{min-width:220px;justify-content:center}
683
+
684
+ /* MOVIE DETAILS */
685
+ .cast-row{display:flex;gap:16px;overflow-x:auto;padding:8px 0 20px;scrollbar-width:thin;scrollbar-color:var(--accent-v) transparent}
686
+ .cast-row::-webkit-scrollbar{height:4px}
687
+ .cast-row::-webkit-scrollbar-thumb{background:var(--accent-v);border-radius:99px}
688
+ .cast-chip{flex-shrink:0;width:110px;text-align:center;cursor:default;transition:var(--transition)}
689
+ .cast-chip:hover{transform:translateY(-4px)}
690
+ .cast-chip img{width:80px;height:80px;border-radius:50%;object-fit:cover;margin:0 auto 10px;border:2px solid var(--border);transition:var(--transition);box-shadow:0 4px 12px rgba(0,0,0,0.3)}
691
+ .cast-chip:hover img{border-color:var(--accent-v);box-shadow:0 6px 20px rgba(124,58,237,0.4)}
692
+ .cast-avatar-fallback{width:80px;height:80px;border-radius:50%;margin:0 auto 10px;background:var(--grad-accent);display:flex;align-items:center;justify-content:center;font-size:1.6rem;font-weight:800;color:#fff;box-shadow:0 4px 12px rgba(0,0,0,0.3)}
693
+ .cast-chip-name{font-size:0.82rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text-primary)}
694
+ .cast-chip-role{font-size:0.7rem;color:var(--text-muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2}
695
+ .ep-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:12px}
696
+ .ep-grid-btn{position:relative;display:flex;flex-direction:column;align-items:center;gap:8px;padding:16px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:12px;transition:var(--transition);cursor:pointer;color:var(--text-secondary);box-shadow:0 2px 8px rgba(0,0,0,0.15)}
697
+ .ep-grid-btn:hover{background:rgba(124,58,237,0.15);border-color:var(--accent-v);color:var(--text-primary);transform:translateY(-3px);box-shadow:0 6px 20px rgba(124,58,237,0.3)}
698
+ .ep-grid-btn.watched{background:rgba(34,197,94,0.08);border-color:rgba(34,197,94,0.25)}
699
+ .ep-grid-btn.watched:hover{background:rgba(34,197,94,0.15);border-color:rgba(34,197,94,0.4)}
700
+ .ep-grid-num{font-size:1rem;font-weight:700;color:var(--text-primary)}
701
+ .ep-grid-watched{position:absolute;top:8px;right:8px;width:20px;height:20px;border-radius:50%;background:#22c55e;color:#fff;font-size:0.7rem;font-weight:800;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3)}
702
+ .ep-grid-play{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;background:rgba(124,58,237,0.15);color:var(--accent-v2);transition:var(--transition)}
703
+ .ep-grid-btn:hover .ep-grid-play{background:var(--accent-v);color:#fff}
704
+ .ep-grid-btn.watched .ep-grid-play{background:rgba(34,197,94,0.15);color:#22c55e}
705
+ .ep-grid-btn.watched:hover .ep-grid-play{background:#22c55e;color:#fff}
706
+
707
+ /* MOVIE PLAYER */
708
+ .player-error{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--text-muted);padding:20px;text-align:center}
709
+ .player-error .big{font-size:2.6rem}
710
+ .player-error p{font-size:0.9rem;max-width:420px}
711
+
712
+ /* CUSTOM PLAYER */
713
+ .custom-player{position:relative;width:100%;height:100%;background:#000;outline:none;overflow:hidden;cursor:default}
714
+ .custom-player video{width:100%;height:100%;object-fit:contain;background:#000}
715
+ .custom-player video::-webkit-media-controls{display:none !important}
716
+ .cp-fade{position:absolute;left:0;right:0;bottom:0;height:130px;background:linear-gradient(to top,rgba(0,0,0,0.88),transparent);pointer-events:none;transition:opacity .25s ease;z-index:3}
717
+ .cp-controls{position:absolute;left:0;right:0;bottom:0;padding:14px 16px 12px;display:flex;flex-direction:column;gap:8px;transition:opacity .25s ease,transform .25s ease;z-index:5}
718
+ .cp-controls.hide,.cp-fade.hide{opacity:0;pointer-events:none;transform:translateY(10px)}
719
+ .cp-seek{position:relative;height:18px;display:flex;align-items:center;cursor:pointer;touch-action:none}
720
+ .cp-seek-track{position:absolute;left:0;right:0;height:4px;border-radius:99px;background:rgba(255,255,255,0.22);transition:height .15s ease}
721
+ .cp-seek:hover .cp-seek-track{height:6px}
722
+ .cp-buffered{position:absolute;left:0;height:4px;border-radius:99px;background:rgba(255,255,255,0.32);transition:height .15s ease}
723
+ .cp-seek:hover .cp-buffered{height:6px}
724
+ .cp-progress{position:absolute;left:0;height:4px;border-radius:99px;background:var(--accent-v);box-shadow:0 0 12px rgba(124,58,237,0.65);transition:height .15s ease}
725
+ .cp-seek:hover .cp-progress{height:6px}
726
+ .cp-thumb{position:absolute;top:50%;width:13px;height:13px;border-radius:50%;background:#fff;transform:translate(-50%,-50%);opacity:0;transition:opacity .15s ease;box-shadow:0 1px 6px rgba(0,0,0,0.5);pointer-events:none}
727
+ .cp-seek:hover .cp-thumb{opacity:1}
728
+ .cp-hover-time{position:absolute;top:-28px;transform:translateX(-50%);background:rgba(10,10,18,0.92);border:1px solid var(--border-bright);color:#fff;font-size:0.72rem;font-weight:600;padding:2px 8px;border-radius:6px;pointer-events:none;white-space:nowrap}
729
+ .cp-row{display:flex;align-items:center;gap:4px}
730
+ .cp-btn{width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.92);transition:var(--transition);background:transparent;flex-shrink:0}
731
+ .cp-btn:hover{background:rgba(255,255,255,0.12);color:#fff}
732
+ .cp-btn.active{color:var(--accent-v2);background:rgba(124,58,237,0.22)}
733
+ @media(max-width:680px){.cp-row{gap:2px}.cp-btn{width:34px;height:34px}.cp-vol-slider{width:60px}.cp-btn:nth-child(2),.cp-btn:nth-child(3),.cp-btn:nth-child(11),.cp-btn:nth-child(12),.cp-btn:nth-child(13){display:none}.cp-time{display:none}}
734
+ .cp-time{font-size:0.8rem;font-weight:600;color:rgba(255,255,255,0.85);font-variant-numeric:tabular-nums;margin-left:6px;white-space:nowrap}
735
+ .cp-time em{font-style:normal;color:rgba(255,255,255,0.4);margin:0 4px}
736
+ .cp-spacer{flex:1}
737
+ .cp-vol-slider{width:74px;accent-color:var(--accent-v);cursor:pointer;flex-shrink:0}
738
+ .cp-menu{position:absolute;bottom:56px;right:12px;background:rgba(12,12,20,0.95);backdrop-filter:blur(14px);border:1px solid var(--border-bright);border-radius:var(--radius-md);padding:8px 10px 10px;min-width:172px;box-shadow:0 16px 44px rgba(0,0,0,0.6);z-index:10;animation:fadeUp .18s ease both;max-height:min(340px,60vh);overflow-y:auto}
739
+ .cp-menu-title{font-size:0.64rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);padding:8px 10px 4px}
740
+ .cp-menu-title:first-child{padding-top:2px}
741
+ .cp-menu-item{display:block;width:100%;text-align:left;padding:7px 10px;border-radius:var(--radius-sm);font-size:0.82rem;font-weight:600;color:var(--text-secondary);transition:var(--transition);cursor:pointer;background:none;border:none}
742
+ .cp-menu-item:hover{background:rgba(255,255,255,0.06);color:var(--text-primary)}
743
+ .cp-menu-item.active{background:var(--accent-v);color:#fff}
744
+ .cp-menu-note{font-size:0.7rem;color:var(--text-muted);padding:8px 10px 2px}
745
+ .cp-center-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:76px;height:76px;border-radius:50%;background:rgba(10,10,18,0.6);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,0.18);color:#fff;display:flex;align-items:center;justify-content:center;transition:var(--transition);z-index:4}
746
+ .cp-center-play:hover{background:var(--accent-v);border-color:var(--accent-v);transform:translate(-50%,-50%) scale(1.08);box-shadow:0 0 34px rgba(124,58,237,0.6)}
747
+ .skip-btn{position:absolute;right:18px;bottom:78px;z-index:6;padding:9px 18px;border-radius:var(--radius-pill);background:rgba(12,12,20,0.85);backdrop-filter:blur(8px);border:1px solid var(--border-bright);color:#fff;font-size:0.84rem;font-weight:700;opacity:0;transform:translateY(10px);pointer-events:none;transition:var(--transition);display:flex;align-items:center;gap:6px}
748
+ .skip-btn.show{opacity:1;transform:translateY(0);pointer-events:auto}
749
+ .skip-btn:hover{background:var(--accent-v);border-color:var(--accent-v)}
750
+ .movie-quality-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:12px 16px;background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-md);margin:10px 0}
751
+ .movie-quality-label{font-size:0.7rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);margin-right:4px}
752
+ .movie-quality-bar button{padding:5px 14px;border-radius:var(--radius-pill);font-size:0.78rem;font-weight:700;background:rgba(255,255,255,0.06);border:1px solid var(--border);color:var(--text-secondary);transition:var(--transition);cursor:pointer}
753
+ .movie-quality-bar button:hover{border-color:var(--border-bright);color:var(--text-primary)}
754
+ .movie-quality-bar button.active{background:var(--accent-v);border-color:var(--accent-v);color:#fff}
755
+
756
+ /* SCROLL ROW NAV */
757
+ .genre-nav-wrap{position:relative}
758
+ .row-nav-btn{position:absolute;top:50%;transform:translateY(-50%);z-index:6;width:38px;height:38px;border-radius:50%;background:rgba(10,10,18,0.85);backdrop-filter:blur(8px);border:1px solid var(--border-bright);color:var(--text-primary);display:flex;align-items:center;justify-content:center;opacity:0;transition:var(--transition);cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,0.5)}
759
+ .genre-nav-wrap:hover .row-nav-btn{opacity:1}
760
+ .row-nav-btn:hover{background:var(--accent-v);border-color:var(--accent-v);transform:translateY(-50%) scale(1.08)}
761
+ .row-nav-btn.prev{left:-14px}
762
+ .row-nav-btn.next{right:-14px}
763
+
764
+ /* CONTINUE WATCHING */
765
+ .cw-subtitle{font-size:0.78rem;font-weight:500;color:var(--text-muted)}
766
+ .cw-card{position:relative;flex-shrink:0;width:250px;border-radius:var(--radius-md);overflow:hidden;background:var(--bg-card);cursor:pointer;transition:var(--transition);border:1px solid var(--border)}
767
+ .cw-card:hover{transform:translateY(-4px);border-color:var(--border-bright);box-shadow:var(--shadow-hover)}
768
+ .cw-poster{position:relative;aspect-ratio:16/9;overflow:hidden;background:var(--bg-elevated)}
769
+ .cw-poster img{width:100%;height:100%;object-fit:cover;transition:transform .45s ease}
770
+ .cw-card:hover .cw-poster img{transform:scale(1.06)}
771
+ .cw-overlay{position:absolute;inset:0;background:rgba(8,8,12,0.55);backdrop-filter:blur(2px);opacity:0;transition:var(--transition);display:flex;align-items:center;justify-content:center;gap:8px;color:#fff;font-size:0.86rem;font-weight:700}
772
+ .cw-card:hover .cw-overlay{opacity:1}
773
+ .cw-overlay svg{width:20px;height:20px}
774
+ .cw-badge{position:absolute;top:8px;left:8px;padding:2px 8px;border-radius:var(--radius-pill);background:rgba(8,8,8,0.78);backdrop-filter:blur(6px);border:1px solid var(--border);color:#fff;font-size:0.64rem;font-weight:700;letter-spacing:0.04em}
775
+ .cw-progress{position:absolute;left:0;right:0;bottom:0;height:4px;background:rgba(255,255,255,0.16)}
776
+ .cw-progress-fill{height:100%;background:var(--grad-accent);box-shadow:0 0 8px rgba(124,58,237,0.6)}
777
+ .cw-remove{position:absolute;top:6px;right:6px;width:22px;height:22px;border-radius:50%;background:rgba(8,8,8,0.8);border:1px solid var(--border);color:var(--text-secondary);font-size:0.9rem;line-height:1;display:flex;align-items:center;justify-content:center;opacity:0;transition:var(--transition);cursor:pointer;padding:0}
778
+ .cw-card:hover .cw-remove{opacity:1}
779
+ .cw-remove:hover{background:#e11d48;border-color:#e11d48;color:#fff}
780
+ .cw-body{padding:10px 12px 12px}
781
+ .cw-body h3{font-size:0.88rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}
782
+ .cw-meta{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:0.7rem;color:var(--text-muted)}
783
+ .cw-meta-left{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
784
+ .cw-left{color:var(--accent-v2);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
785
+
786
+ /* TOP 10 SPOTLIGHT — full-width slideshow */
787
+ .spotlight-featured{position:relative;width:100%;min-height:500px;border-radius:16px;overflow:hidden;background:var(--bg-elevated) center/cover no-repeat;border:1px solid var(--border);cursor:pointer;transition:var(--transition)}
788
+ .spotlight-featured:hover{transform:translateY(-4px);border-color:var(--border-bright);box-shadow:var(--shadow-hover)}
789
+ .spotlight-featured-grad{position:absolute;inset:0;background:linear-gradient(180deg, rgba(8,8,12,0.08) 0%, rgba(8,8,12,0.24) 34%, rgba(8,8,12,0.72) 75%, rgba(8,8,12,0.9) 100%)}
790
+ .spotlight-featured-body{position:absolute;left:0;right:0;bottom:0;padding:26px 26px 24px;z-index:2}
791
+ .spotlight-featured-meta-row{display:flex;align-items:center;gap:10px;font-size:0.72rem;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#f8d6d6;opacity:0.9;margin-bottom:12px}
792
+ .spotlight-featured-bubble{display:inline-flex;align-items:center;justify-content:center;margin-bottom:12px}
793
+ .spotlight-featured-bubble-inner{display:flex;flex-direction:column;align-items:flex-start;padding:11px 16px;border-radius:999px;background:rgba(18, 58, 130, 0.38);border:1px solid rgba(255,255,255,0.18);backdrop-filter:blur(8px)}
794
+ .spotlight-featured-bubble-kicker{font-size:0.68rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#dbeafe}
795
+ .spotlight-featured-bubble-title{font-size:1rem;font-weight:800;color:#f2fbff;line-height:1.15}
796
+ .spotlight-featured-title{font-family:'Bebas Neue','Outfit',sans-serif;font-size:clamp(2.2rem,4.6vw,4.4rem);font-weight:400;line-height:0.9;margin:0 0 10px;text-shadow:0 8px 30px rgba(0,0,0,0.7);letter-spacing:0.02em;max-width:52%}
797
+ .spotlight-featured-caption{max-width:48%;font-size:0.9rem;color:rgba(255,255,255,0.8);line-height:1.55;margin-bottom:18px}
798
+ .spotlight-featured-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
799
+ .spotlight-featured-btn{position:relative;z-index:2}
800
+ .spotlight-featured-btn.secondary{background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);color:#fff}
801
+ .spotlight-slide{animation:spotFade .5s ease both}
802
+ @keyframes spotFade{from{opacity:0;transform:scale(1.03)}to{opacity:1;transform:scale(1)}}
803
+ .spotlight-nav-btn{position:absolute;top:50%;transform:translateY(-50%);z-index:3;width:36px;height:36px;border-radius:50%;background:rgba(8,8,8,0.62);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,0.16);color:#fff;display:flex;align-items:center;justify-content:center;opacity:0.7;transition:var(--transition);cursor:pointer}
804
+ .spotlight-featured:hover .spotlight-nav-btn{opacity:1}
805
+ .spotlight-nav-btn:hover{background:var(--accent-v);border-color:var(--accent-v);transform:translateY(-50%) scale(1.08)}
806
+ .spotlight-nav-btn.prev{left:12px}
807
+ .spotlight-nav-btn.next{right:12px}
808
+ .spotlight-dots{position:absolute;bottom:14px;left:0;right:0;display:flex;justify-content:center;gap:6px;z-index:3}
809
+ .spotlight-dot{width:7px;height:7px;border-radius:50%;background:rgba(255,255,255,0.35);transition:var(--transition);cursor:pointer;padding:0}
810
+ .spotlight-dot.active{background:var(--accent-v2);width:20px;border-radius:99px;box-shadow:0 0 10px rgba(124,58,237,0.6)}
811
+
812
+ .genre-section{padding-top:8px;padding-bottom:12px}
813
+ .genre-row-wrap{position:relative;display:flex;align-items:center;gap:12px}
814
+ .genre-row{display:flex;align-items:center;gap:10px;overflow-x:auto;scrollbar-width:none;padding:6px 0}
815
+ .genre-row::-webkit-scrollbar{display:none}
816
+ .genre-pill{padding:8px 16px;border-radius:var(--radius-pill);background:rgba(255,255,255,0.035);border:1px solid var(--border);color:var(--text-secondary);font-size:0.82rem;font-weight:600;transition:var(--transition);cursor:pointer;white-space:nowrap}
817
+ .genre-pill:hover{background:rgba(124,58,237,0.12);border-color:var(--border-bright);color:var(--text-primary)}
818
+
819
+ .home-dashboard{padding-top:4px}
820
+ .home-layout{display:grid;grid-template-columns:minmax(0, 1.9fr) 300px;gap:22px;align-items:start}
821
+ .home-main-column{display:flex;flex-direction:column;gap:10px}
822
+ .home-sidebar{display:flex;flex-direction:column;gap:18px;position:sticky;top:88px}
823
+ .sidebar-panel{background:rgba(255,255,255,0.02);border:1px solid var(--border);border-radius:12px;padding:18px 14px}
824
+ .sidebar-panel h3{font-size:0.96rem;font-weight:800;letter-spacing:0.02em;margin:0 0 14px}
825
+ .sidebar-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
826
+ .sidebar-list li{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.04);cursor:pointer}
827
+ .sidebar-list li:last-child{border-bottom:none}
828
+ .sidebar-rank{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:8px;background:var(--grad-accent);font-size:0.72rem;font-weight:800;color:#fff;flex-shrink:0}
829
+ .sidebar-rank.alt{background:linear-gradient(135deg, rgba(14,165,233,0.9), rgba(124,58,237,0.9))}
830
+ .sidebar-info{display:flex;flex-direction:column;min-width:0}
831
+ .sidebar-info strong{font-size:0.8rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
832
+ .sidebar-info small{font-size:0.67rem;color:var(--text-muted);margin-top:2px}
833
+
834
+ /* LANDING ENTER CTA */
835
+ .landing-enter{position:relative;padding:90px 24px;overflow:hidden;text-align:center;border-top:1px solid var(--border)}
836
+ .landing-enter-glow{position:absolute;inset:0;background:radial-gradient(ellipse 55% 80% at 50% 110%,rgba(124,58,237,0.3) 0%,transparent 65%);pointer-events:none}
837
+ .landing-enter-inner{position:relative;max-width:560px;margin:0 auto}
838
+ .landing-enter-label{font-size:0.72rem;font-weight:700;letter-spacing:0.16em;text-transform:uppercase;color:var(--accent-v2);margin-bottom:14px}
839
+ .landing-enter-title{font-family:'Bebas Neue','Outfit',sans-serif;font-size:clamp(2rem,4vw,3.2rem);font-weight:400;line-height:1.05;margin-bottom:12px}
840
+ .landing-enter-desc{font-size:0.95rem;color:var(--text-secondary);line-height:1.7;margin-bottom:26px}
841
+
842
+ /* SHARED EMPTY STATE + CARD EXTRAS */
843
+ .empty{padding:40px;text-align:center;color:var(--text-muted)}
844
+ .empty .big{font-size:2.6rem;margin-bottom:12px}
845
+ .empty p{font-size:0.9rem}
846
+ .card-rank{position:absolute;top:8px;left:8px;z-index:2;width:26px;height:26px;border-radius:8px;background:var(--grad-accent);color:#fff;font-size:0.78rem;font-weight:800;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px rgba(0,0,0,0.5)}
847
+ .card-type-badge{position:absolute;top:8px;right:8px;z-index:2;padding:3px 9px;border-radius:var(--radius-pill);background:rgba(8,8,8,0.72);backdrop-filter:blur(6px);border:1px solid var(--border-bright);color:var(--accent-c2);font-size:0.62rem;font-weight:700;letter-spacing:0.04em;text-transform:uppercase}
848
+ .card-type-badge.coming-soon{background:rgba(245,158,11,0.18);border-color:rgba(245,158,11,0.55);color:#fcd34d}
849
+ .card-quick-btn.coming-soon{background:rgba(245,158,11,0.9);color:#1a1205}
850
+ .card-ep-badge{position:absolute;top:8px;left:8px;z-index:2;padding:3px 9px;border-radius:var(--radius-pill);background:rgba(8,8,8,0.72);backdrop-filter:blur(6px);border:1px solid var(--border);color:#fff;font-size:0.62rem;font-weight:700;letter-spacing:0.04em}
851
+
852
+ /* MOBILE BOTTOM NAV (tab bar with every section) */
853
+ #root .mobile-nav{display:flex;position:fixed;left:0;right:0;bottom:0;z-index:120;height:calc(60px + env(safe-area-inset-bottom));padding-bottom:env(safe-area-inset-bottom);background:rgba(10,10,18,0.96);backdrop-filter:blur(18px);border-top:1px solid var(--border);justify-content:space-around;align-items:stretch;box-shadow:0 -16px 34px rgba(10,10,14,0.65)}
854
+ #root .mobile-nav-item{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;color:var(--text-muted);font-size:0.6rem;font-weight:600;letter-spacing:0.02em;transition:var(--transition)}
855
+ #root .mobile-nav-item.active{color:var(--accent-v2);background:linear-gradient(180deg, rgba(124,58,237,0.12), rgba(124,58,237,0.02));}
856
+ #root .mobile-nav-item.active svg{filter:drop-shadow(0 0 8px rgba(124,58,237,0.6))}
857
+ #root .mobile-nav-item:active{transform:scale(0.94)}
858
+ @media(max-width:1024px){
859
+ .search-wrap{max-width:260px}
860
+ nav{gap:0}
861
+ nav a{padding:6px 10px;font-size:0.8rem}
862
+ .container{padding:0 20px}
863
+ .page-shell{padding-top:calc(var(--header-h) + 24px);padding-bottom:64px}
864
+ .catalog-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px 14px}
865
+ .watch-grid,.info-layout{grid-template-columns:1fr}.watch-sidebar{position:static;max-height:500px}.info-hero-body{flex-direction:column;align-items:flex-start}.spotlight-featured{min-height:340px}
866
+ }
867
+ @media(max-width:900px){
868
+ .main-content{padding-bottom:calc(66px + env(safe-area-inset-bottom))}
869
+ .home-hero{padding-bottom:110px}
870
+ .scroll-indicator{bottom:calc(80px + env(safe-area-inset-bottom))}
871
+ .landing-enter{padding-bottom:120px}
872
+ }
873
+
874
+ /* ============ DESKTOP (>=681px) - Hide mobile nav ============ */
875
+ @media(min-width:681px){
876
+ .mobile-nav{
877
+ display: none !important;
878
+ }
879
+ }
880
+
881
+ /* ============ MOBILE (<=680px) ============ */
882
+ @media(max-width:680px){
883
+ .container{padding:0 16px}
884
+ nav{display:none}
885
+ .page-shell{padding-top:calc(var(--header-h) + 18px);padding-bottom:60px}
886
+ .catalog-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:16px 12px}
887
+ .search-shell{gap:12px}
888
+
889
+ /* Header: compact but still polished */
890
+ #header .container{gap:10px}
891
+ .logo-wordmark .l{font-size:1.6rem}
892
+ .search-wrap{display:none}
893
+ .auth-avatar-btn{padding:5px 7px;gap:0;border-radius:14px}
894
+ .auth-avatar-btn span,.auth-avatar-btn>svg:last-child{display:none}
895
+ .auth-avatar-circle{width:30px;height:30px}
896
+ .auth-dropdown{min-width:220px;max-width:calc(100vw - 24px)}
897
+
898
+ /* Home */
899
+ .content-section{padding:26px 0}
900
+ .section-header{margin-bottom:14px}
901
+ .section-title{font-size:1.02rem}
902
+ .section-title::before{height:19px}
903
+ .scroll-row{gap:10px}
904
+ .anime-card{width:138px;border-radius:var(--radius-sm)}
905
+ .skel-card{width:138px}
906
+ .card-body{padding:8px 9px 10px}
907
+ .card-title{font-size:0.78rem}
908
+ .cw-card{width:198px}
909
+
910
+ /* Spotlight slideshow */
911
+ .spotlight-featured{min-height:262px;border-radius:14px}
912
+ .spotlight-featured-body{padding:12px 12px 38px}
913
+ .spotlight-featured-score{font-size:0.68rem;margin-bottom:4px}
914
+ .spotlight-featured-title{font-size:1.28rem;margin-bottom:4px}
915
+ .spotlight-featured-meta{font-size:0.7rem;margin-bottom:10px}
916
+ .spotlight-featured-btn{padding:8px 15px;font-size:0.76rem}
917
+ .spotlight-nav-btn{opacity:1;width:30px;height:30px}
918
+ .spotlight-nav-btn.prev{left:8px}
919
+ .spotlight-nav-btn.next{right:8px}
920
+ .spotlight-dots{bottom:9px;gap:5px}
921
+ .spotlight-dot{width:6px;height:6px}
922
+ .spotlight-dot.active{width:16px}
923
+
924
+ .genre-row-wrap{gap:8px}
925
+ .genre-row{gap:8px}
926
+ .genre-pill{padding:7px 12px;font-size:0.74rem}
927
+ .home-layout{grid-template-columns:1fr;gap:14px}
928
+ .home-sidebar{position:static}
929
+ .sidebar-panel{padding:14px 12px}
930
+
931
+ /* Buttons + shared */
932
+ .btn{padding:10px 18px;font-size:0.85rem}
933
+ .btn-sm{padding:6px 13px;font-size:0.78rem}
934
+ .section-link{font-size:0.78rem}
935
+
936
+ /* Landing hero */
937
+ .home-hero{padding:calc(var(--header-h) + 30px) 14px 0}
938
+ .home-hero{padding-bottom:110px}
939
+ .hero-badge{padding:5px 13px;font-size:0.66rem;margin-bottom:22px}
940
+ .hero-logo-text{font-size:clamp(3.2rem,17vw,5.6rem);margin-bottom:12px}
941
+ .hero-subtitle{font-size:clamp(1.05rem,3.6vw,1.45rem);margin-bottom:10px}
942
+ .hero-desc{font-size:0.88rem;margin-bottom:22px}
943
+ .hero-filters{gap:6px;margin-bottom:18px}
944
+ .hero-filter-pill{padding:6px 14px;font-size:0.8rem}
945
+ .hero-search-input{padding:12px 52px 12px 16px;font-size:0.9rem}
946
+ .hero-search-btn{width:38px;height:38px}
947
+ .hero-ctas{gap:9px;margin-bottom:32px}
948
+ .hero-cta{padding:11px 17px;font-size:0.84rem;gap:7px}
949
+ .hero-stat{padding:0 14px}
950
+ .hero-stat-val{font-size:1.15rem}
951
+ .hero-stat-label{font-size:0.6rem}
952
+
953
+ /* Library marquee */
954
+ .library-section{padding:44px 0}
955
+ .library-header{gap:12px;margin-bottom:20px}
956
+ .library-header-text{font-size:0.62rem}
957
+ .library-header-line{max-width:90px}
958
+ .marquee-poster{width:86px;height:120px}
959
+ .marquee-row{gap:8px}
960
+
961
+ /* Feature section */
962
+ .feature-section{padding:48px 0}
963
+ .feature-eyebrow{font-size:0.7rem;margin-bottom:12px}
964
+ .feature-heading{font-size:1.7rem;line-height:1.2;margin-bottom:14px}
965
+ .feature-desc{font-size:0.9rem;margin-bottom:26px}
966
+ .feature-card{padding:18px}
967
+ .landing-enter{padding:56px 18px 118px}
968
+ .landing-enter-title{font-size:1.9rem}
969
+ .landing-enter-desc{font-size:0.88rem;margin-bottom:20px}
970
+
971
+ /* Details pages */
972
+ .info-hero{min-height:320px}
973
+ .info-hero-content{padding:20px 0}
974
+ .info-hero-body{gap:16px}
975
+ .info-poster{width:108px}
976
+ .info-title{font-size:1.7rem;margin-bottom:10px}
977
+ .info-type-tag{font-size:0.72rem;margin-bottom:8px}
978
+ .info-badges{margin-bottom:12px}
979
+ .info-genres{gap:6px;margin-bottom:16px}
980
+ .info-genre-pill{padding:4px 11px;font-size:0.74rem}
981
+ .info-actions{gap:8px}
982
+ .countdown-wrap{padding:10px 14px;gap:8px;margin-bottom:16px}
983
+ .countdown-cell{min-width:50px;padding:6px 4px}
984
+ .countdown-num{font-size:1.05rem}
985
+ .countdown-unit{font-size:0.56rem}
986
+ .info-body{padding:26px 0 60px}
987
+ .info-layout{gap:30px}
988
+ .info-section-label{font-size:1.02rem}
989
+ .info-synopsis{font-size:0.88rem}
990
+ .info-stat-cell{padding:13px 14px}
991
+ .char-avatar{width:64px;height:64px}
992
+ .char-name{font-size:0.72rem;max-width:72px}
993
+ .info-btn-watch{padding:10px 20px}
994
+
995
+ /* Watch page */
996
+ .watch-container{padding:0 12px 60px}
997
+ .watch-grid{gap:12px;padding-top:8px}
998
+ .watch-anime-row{flex-wrap:wrap}
999
+ .watch-info-card{padding:14px 16px}
1000
+ .watch-info-title{font-size:0.95rem}
1001
+ .ep-title{font-size:1.1rem;margin-bottom:12px}
1002
+ .prefs-bar{padding:10px 12px;gap:12px}
1003
+ .pref-item{font-size:0.78rem}
1004
+ .audio-btn,.server-btn{padding:4px 10px;font-size:0.74rem}
1005
+ .audio-notice{margin:10px 0;font-size:0.78rem;padding:8px 12px}
1006
+ .watch-anime-name{font-size:0.85rem}
1007
+ .sidebar-upnext{font-size:0.92rem}
1008
+ .ep-thumb{width:64px;height:40px}
1009
+ .ep-item{padding:6px}
1010
+ .ep-info-title{font-size:0.76rem}
1011
+ .ep-grid{grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:8px}
1012
+ .movie-quality-bar{padding:10px 12px;gap:6px}
1013
+ .movie-quality-bar button{padding:4px 11px;font-size:0.72rem}
1014
+
1015
+ /* Movies & TV */
1016
+ .movie-tabs{flex-wrap:nowrap;overflow-x:auto;padding-bottom:4px;scrollbar-width:none}
1017
+ .movie-tabs::-webkit-scrollbar{display:none}
1018
+ .movie-tabs button{white-space:nowrap;padding:7px 16px;font-size:0.8rem}
1019
+ .movie-sort button{padding:5px 11px;font-size:0.72rem}
1020
+ .load-more-btn{min-width:170px}
1021
+
1022
+ /* Custom player */
1023
+ .cp-controls{padding:10px 8px 8px}
1024
+ .cp-btn{width:34px;height:34px}
1025
+ .cp-vol-slider{width:52px}
1026
+ .cp-time{font-size:0.72rem}
1027
+ .cp-menu{right:6px;bottom:52px;min-width:150px}
1028
+ .cp-center-play{width:60px;height:60px}
1029
+ .skip-btn{bottom:72px;right:12px;padding:8px 14px;font-size:0.8rem}
1030
+
1031
+ /* Auth modal -> bottom sheet */
1032
+ .auth-overlay{padding:0;align-items:flex-end}
1033
+ .auth-modal{max-width:100%;border-radius:20px 20px 0 0;padding:24px 20px calc(20px + env(safe-area-inset-bottom));animation:sheetUp .25s ease both}
1034
+ @keyframes sheetUp{from{transform:translateY(44px);opacity:0}to{transform:translateY(0);opacity:1}}
1035
+ }
1036
+
1037
+ /* ============ SMALL PHONES (<=420px) ============ */
1038
+ @media(max-width:420px){
1039
+ .anime-card{width:126px}
1040
+ .skel-card{width:126px}
1041
+ .cw-card{width:172px}
1042
+ .marquee-poster{width:74px;height:104px}
1043
+ .info-poster{width:94px}
1044
+ .hero-logo-text{font-size:3rem}
1045
+ .hero-ctas{flex-direction:column}
1046
+ .hero-cta{width:100%;justify-content:center}
1047
+ .spotlight-featured{min-height:240px}
1048
+ .auth-dropdown{left:12px;right:12px;min-width:0}
1049
+ }
1050
+
1051
+ /* React Home discovery view (migrated from the legacy project, deliberately scoped). */
1052
+ #hero{position:relative;height:56vh;min-height:600px;max-height:720px;overflow:hidden;background:var(--bg-base)}
1053
+ #hero .hero-slide{position:absolute;inset:0;opacity:0;transition:opacity .7s ease;pointer-events:none;will-change:opacity}#hero .hero-slide.active{opacity:1;pointer-events:auto}
1054
+ #hero .hero-bg{position:absolute;inset:0}#hero .hero-bg img{width:100%;height:100%;object-fit:cover;object-position:center top;transform:scale(1.025);transition:transform 8s ease;filter:saturate(.9) contrast(1.04)}#hero .hero-slide.active .hero-bg img{transform:scale(1)}
1055
+ #hero .hero-gradient{position:absolute;inset:0;background:linear-gradient(90deg,rgba(6,7,10,.96) 0%,rgba(6,7,10,.64) 40%,rgba(6,7,10,.1) 72%),linear-gradient(0deg,rgba(6,7,10,.98) 0%,transparent 52%)}
1056
+ #hero .hero-content{position:relative;z-index:2;height:100%;display:flex;align-items:flex-end;padding:calc(var(--header-h) + 20px) 0 72px}#hero .hero-content .container{width:100%}#hero .hero-layout{display:flex;align-items:center;justify-content:space-between;gap:40px;width:100%}#hero .hero-info{max-width:560px;animation:fadeUp .6s ease both}
1057
+ #hero .hero-title-block{display:flex;flex-direction:column;align-items:flex-start;gap:10px}#hero .hero-badges{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px}#hero .hero-title{font-size:clamp(2rem,4vw,3.35rem);font-weight:800;line-height:1.12;letter-spacing:-.035em;margin-bottom:14px;text-wrap:balance}
1058
+ #hero .hero-desc{font-size:.95rem;color:var(--text-secondary);line-height:1.7;margin:0 0 18px;max-width:470px;display:-webkit-box;-webkit-line-clamp:3;line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;text-align:left}#hero .hero-genres{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}#hero .hero-genre-tag{padding:4px 10px;border-radius:var(--radius-pill);background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.1);font-size:.75rem;font-weight:600;color:var(--text-secondary);transition:var(--transition)}#hero .hero-genre-tag:hover{background:rgba(124,58,237,.2);border-color:var(--accent-v);color:var(--accent-v2)}
1059
+ #hero .hero-actions{display:flex;gap:12px;flex-wrap:wrap}#hero .hero-stats{display:flex;gap:20px;margin-bottom:14px;flex-wrap:wrap}#hero .hero-stat{display:flex;align-items:center;gap:6px;font-size:.82rem;color:var(--text-secondary)}#hero .hero-stat svg{color:var(--accent-v2);flex-shrink:0}
1060
+ #hero .hero-cover-wrap{position:relative;flex-shrink:0;animation:fadeIn .8s ease .3s both}#hero .hero-cover{width:190px;aspect-ratio:2/3;object-fit:cover;border-radius:var(--radius-lg);box-shadow:0 8px 48px rgba(0,0,0,.6),0 0 0 1px var(--border);display:block}#hero .hero-cover-glow{position:absolute;inset:-14px;border-radius:32px;background:radial-gradient(ellipse at center,rgba(139,92,246,.28),transparent 70%);z-index:-1;animation:glow-pulse 3s ease infinite}
1061
+ #hero .hero-prev,#hero .hero-next{position:absolute;top:50%;z-index:5;width:48px;height:48px;border-radius:50%;background:rgba(10,10,18,.72);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;color:var(--text-primary);transition:var(--transition);backdrop-filter:blur(8px);opacity:0;pointer-events:none}#hero .hero-prev{left:40px;transform:translateY(-50%)}#hero .hero-next{right:40px;transform:translateY(-50%)}#hero:hover .hero-next,#hero:hover .hero-prev{opacity:1;pointer-events:auto}#hero .hero-prev:hover{background:rgba(124,58,237,.35);border-color:var(--accent-v);transform:translate(-4px,-50%)}#hero .hero-next:hover{background:rgba(124,58,237,.35);border-color:var(--accent-v);transform:translate(4px,-50%)}
1062
+ #hero .hero-indicators{position:absolute;bottom:25px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:5}#hero .hero-dot{width:8px;height:8px;border-radius:99px;background:rgba(255,255,255,.3);transition:var(--transition)}#hero .hero-dot.active{width:28px;background:var(--accent-v)}
1063
+ .genre-bar{display:flex;align-items:center;gap:8px;overflow-x:auto;padding:14px 0 12px;border-bottom:1px solid var(--border);scrollbar-width:none;margin-bottom:24px}.genre-bar::-webkit-scrollbar{display:none}.genre-pill{flex-shrink:0;padding:6px 14px;border-radius:var(--radius-pill);background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);font-size:.8rem;font-weight:500;color:var(--text-secondary);transition:var(--transition);white-space:nowrap}.genre-pill:hover{background:rgba(124,58,237,.18);border-color:var(--accent-v);color:var(--accent-v2);transform:translateY(-1px)}
1064
+ .home-grid{display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:28px;align-items:start}.home-sections{min-width:0}.home-grid .sidebar-widget{position:sticky;top:calc(var(--header-h) + 16px);background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-md);overflow:hidden}.home-grid .sidebar-tabs{display:flex;gap:16px;padding:14px 16px 0;border-bottom:1px solid var(--border);background:rgba(255,255,255,.02)}.home-grid .sidebar-tab{padding:0 0 10px;font-size:.82rem;font-weight:700;text-transform:uppercase;color:var(--text-secondary);border-bottom:2px solid transparent;transition:var(--transition)}.home-grid .sidebar-tab:hover{color:var(--text-primary)}.home-grid .sidebar-tab.active{color:var(--accent-v2);border-bottom-color:var(--accent-v)}.home-grid .sidebar-item{display:flex;align-items:center;gap:12px;width:100%;padding:12px 16px;transition:var(--transition);border-bottom:1px solid var(--border);color:inherit;text-align:left}.home-grid .sidebar-item:last-child{border-bottom:none}.home-grid .sidebar-item:hover{background:var(--bg-elevated)}.home-grid .sidebar-thumb{width:50px;height:68px;object-fit:cover;border-radius:6px;flex-shrink:0}.home-grid .sidebar-rank{font-size:.72rem;font-weight:800;color:var(--text-muted);width:18px;flex-shrink:0}.home-grid .sidebar-title{font-size:.86rem;font-weight:600;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.home-grid .sidebar-meta{font-size:.7rem;color:var(--text-muted);margin-top:4px}
1065
+ /* Legacy browse sizing: roomy grid cards, compact two-column cards on phones. */
1066
+ .catalog-grid{grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:22px}
1067
+ @media(max-width:1024px){.home-grid{grid-template-columns:1fr}.home-grid .sidebar-widget{position:static;margin-top:20px}}@media(max-width:680px){#hero{height:620px;min-height:0}#hero .hero-content{align-items:flex-end;padding:calc(var(--header-h) + 18px) 0 58px}#hero .hero-cover-wrap,#hero .hero-prev,#hero .hero-next{display:none}#hero .hero-title{font-size:clamp(1.8rem,9vw,2.5rem)}#hero .hero-desc{-webkit-line-clamp:4;line-clamp:4}#hero .hero-indicators{bottom:22px}.genre-bar{margin-bottom:10px}}
1068
+ @media(max-width:600px){.catalog-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.catalog-grid .anime-card{width:100%}}
1069
+ @media(min-width:601px) and (max-width:768px){.anime-card{width:130px}}@media(max-width:420px){.anime-card{width:126px}}
1070
+
1071
+ /* Watch-page anime information card: an intentional media summary instead of loose text. */
1072
+ .watch-desc-card{display:flex;gap:18px;align-items:flex-start;position:relative;isolation:isolate;overflow:hidden;background:linear-gradient(135deg,rgba(124,58,237,.16),rgba(13,13,20,.96) 38%,rgba(11,11,17,.98));border-color:rgba(139,92,246,.26);box-shadow:0 18px 42px rgba(0,0,0,.2)}.watch-desc-card::before{content:"";position:absolute;inset:0;background:radial-gradient(circle at 0 0,rgba(167,139,250,.15),transparent 42%);z-index:-1}.watch-info-poster{width:92px;aspect-ratio:2/3;object-fit:cover;border-radius:10px;box-shadow:0 10px 24px rgba(0,0,0,.42);flex-shrink:0}.watch-info-body{min-width:0;flex:1}.watch-info-kicker{font-size:.66rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--accent-v2);margin-bottom:5px}.watch-info-title{font-size:1.22rem;font-weight:800;line-height:1.22;letter-spacing:-.02em;margin-bottom:10px}.watch-info-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin-bottom:12px}.watch-meta-item,.watch-meta-score{display:inline-flex;align-items:center;min-height:23px;padding:3px 8px;border-radius:999px;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.075);font-size:.7rem;font-weight:700;color:var(--text-secondary)}.watch-meta-score{color:#fcd34d;background:rgba(245,158,11,.1);border-color:rgba(245,158,11,.2)}.watch-meta-dot{display:none}.watch-genres{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 11px}.watch-genre-pill{padding:3px 8px;border-radius:999px;background:rgba(124,58,237,.13);border:1px solid rgba(124,58,237,.22);color:#c4b5fd;font-size:.68rem;font-weight:700}.watch-synopsis{margin:0;color:var(--text-secondary);font-size:.86rem;line-height:1.62;display:-webkit-box;-webkit-line-clamp:3;line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}@media(max-width:600px){.watch-desc-card{gap:12px;padding:14px}.watch-info-poster{width:72px;border-radius:8px}.watch-info-title{font-size:1.04rem}.watch-synopsis{-webkit-line-clamp:4;line-clamp:4}}
frontend/src/main.jsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import { BrowserRouter } from "react-router-dom";
4
+ import App from "./App.jsx";
5
+ import "./index.css";
6
+
7
+ ReactDOM.createRoot(document.getElementById("root")).render(
8
+ <React.StrictMode>
9
+ <BrowserRouter>
10
+ <App />
11
+ </BrowserRouter>
12
+ </React.StrictMode>
13
+ );
frontend/src/pages/Anime.jsx ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { Link, useNavigate, useParams } from "react-router-dom";
3
+ import { api, titleOf } from "../api.js";
4
+ import AnimeRow from "../components/AnimeRow.jsx";
5
+
6
+ export default function Anime() {
7
+ const { id } = useParams();
8
+ const navigate = useNavigate();
9
+ const [media, setMedia] = useState(null);
10
+ const [epData, setEpData] = useState(null);
11
+ const [loading, setLoading] = useState(true);
12
+ const [showFullSynopsis, setShowFullSynopsis] = useState(false);
13
+ // Tick every second while the next episode's air time is in the future.
14
+ const [now, setNow] = useState(() => Date.now());
15
+
16
+ useEffect(() => {
17
+ let alive = true;
18
+ setLoading(true);
19
+ (async () => {
20
+ try {
21
+ const [m, eps] = await Promise.all([
22
+ api.details(id),
23
+ api.episodes(id).catch(() => null)
24
+ ]);
25
+ if (!alive) return;
26
+ setMedia(m);
27
+ setEpData(eps);
28
+ } catch (e) {
29
+ console.error(e);
30
+ } finally {
31
+ if (alive) setLoading(false);
32
+ }
33
+ })();
34
+ return () => { alive = false; };
35
+ }, [id]);
36
+
37
+ // Live countdown to the next airing episode (absolute timestamp from AniList).
38
+ const nextAiring = media?.nextAiringEpisode;
39
+ const airTargetMs = nextAiring?.airingAt ? nextAiring.airingAt * 1000 : 0;
40
+ useEffect(() => {
41
+ if (!airTargetMs) return;
42
+ setNow(Date.now());
43
+ const t = setInterval(() => {
44
+ // Stop ticking once the episode airs — no need to re-render forever.
45
+ if (airTargetMs - Date.now() <= 0) clearInterval(t);
46
+ setNow(Date.now());
47
+ }, 1000);
48
+ return () => clearInterval(t);
49
+ }, [airTargetMs]);
50
+
51
+ const remainingMs = airTargetMs ? Math.max(0, airTargetMs - now) : 0;
52
+ const showCountdown = airTargetMs > 0 && remainingMs > 0;
53
+ const totalSecs = Math.floor(remainingMs / 1000);
54
+ const cdDays = Math.floor(totalSecs / 86400);
55
+ const cdHours = Math.floor((totalSecs % 86400) / 3600);
56
+ const cdMins = Math.floor((totalSecs % 3600) / 60);
57
+ const cdSecs = totalSecs % 60;
58
+ const pad = (n) => String(n).padStart(2, "0");
59
+
60
+ if (loading || !media) {
61
+ return (
62
+ <div className="loader" style={{ minHeight: "80vh" }}>
63
+ <div className="spinner" />
64
+ <span>Loading anime details...</span>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ const title = titleOf(media);
70
+ const banner = media.bannerImage || media.coverImage;
71
+ const score = media.score ? (media.score > 10 ? (media.score / 10).toFixed(1) : media.score) : "N/A";
72
+
73
+ function handleWatchNow() {
74
+ const provider = (epData?.providers || [])[0];
75
+ const firstEp = provider?.sub?.[0] || provider?.dub?.[0];
76
+ if (firstEp) {
77
+ navigate(`/watch/${firstEp.id}?animeId=${media.id}`);
78
+ }
79
+ }
80
+
81
+ return (
82
+ <div className="info-page">
83
+ <section className="info-hero">
84
+ {banner ? (
85
+ <div className="info-hero-bg" style={{ backgroundImage: `url(${api.img(banner)})` }} />
86
+ ) : null}
87
+
88
+ <div className="container info-hero-inner">
89
+ <div className="info-poster">
90
+ <img src={api.img(media.coverImage)} alt={title} />
91
+ </div>
92
+
93
+ <div className="info-head">
94
+ <div className="info-eyebrow">
95
+ <span className="dot" />
96
+ <span>{media.format || "TV"}</span>
97
+ </div>
98
+
99
+ <div>
100
+ <h1 className="info-title">{title}</h1>
101
+ <div className="info-subtitle">
102
+ {media.seasonYear || "Ongoing"} • {media.status || "Airing"}
103
+ </div>
104
+ </div>
105
+
106
+ <div className="info-meta-row">
107
+ <span className="meta-pill meta-score">★ {score}</span>
108
+ <span className="meta-pill">{media.episodes || "?"} EP</span>
109
+ <span className="meta-pill">{media.duration ? `${media.duration}m` : "24m"}</span>
110
+ <span className={`meta-pill ${media.status === "RELEASING" ? "meta-status-airing" : ""}`}>
111
+ {media.status || "Airing"}
112
+ </span>
113
+ </div>
114
+
115
+ {media.genres ? (
116
+ <div className="info-genres">
117
+ {media.genres.map(g => (
118
+ <span key={g} className="info-genre-tag">{g}</span>
119
+ ))}
120
+ </div>
121
+ ) : null}
122
+
123
+ <div className="info-cta">
124
+ <button className="btn-watch" type="button" onClick={handleWatchNow}>▶ Watch Now</button>
125
+ <button className="btn-secondary" type="button">Share</button>
126
+ <button className="btn-secondary" type="button">My List</button>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </section>
131
+
132
+ <main className="info-body">
133
+ <div className="info-grid-2">
134
+ <section className="info-section">
135
+ <h2 className="info-section-title">Synopsis</h2>
136
+ <p className={`info-desc ${showFullSynopsis ? "expanded" : ""}`}>
137
+ {media.synopsis || "No description available for this title."}
138
+ </p>
139
+ {media.synopsis && media.synopsis.length > 200 ? (
140
+ <button className="info-desc-toggle" onClick={() => setShowFullSynopsis(v => !v)}>
141
+ {showFullSynopsis ? "Show Less" : "Read More"}
142
+ </button>
143
+ ) : null}
144
+ </section>
145
+
146
+ <aside className="info-section">
147
+ <h2 className="info-section-title">Details</h2>
148
+ <div className="info-stats">
149
+ <div className="info-stat-row">
150
+ <span className="label">Format</span>
151
+ <span className="value">{media.format || "TV"}</span>
152
+ </div>
153
+ <div className="info-stat-row">
154
+ <span className="label">Status</span>
155
+ <span className="value">{media.status || "Airing"}</span>
156
+ </div>
157
+ <div className="info-stat-row">
158
+ <span className="label">Episodes</span>
159
+ <span className="value">{media.episodes || "Ongoing"}</span>
160
+ </div>
161
+ <div className="info-stat-row">
162
+ <span className="label">Duration</span>
163
+ <span className="value">{media.duration ? `${media.duration}m` : "24m"}</span>
164
+ </div>
165
+ <div className="info-stat-row">
166
+ <span className="label">Year</span>
167
+ <span className="value">{media.seasonYear || "1999"}</span>
168
+ </div>
169
+ <div className="info-stat-row">
170
+ <span className="label">Score</span>
171
+ <span className="value">{score}</span>
172
+ </div>
173
+ </div>
174
+ </aside>
175
+ </div>
176
+
177
+ {media.genres ? (
178
+ <section className="info-section">
179
+ <h2 className="info-section-title">Tags</h2>
180
+ <div className="info-tags">
181
+ {media.genres.map(g => (
182
+ <span key={g} className="info-tag">{g}</span>
183
+ ))}
184
+ </div>
185
+ </section>
186
+ ) : null}
187
+
188
+ {media.relations && media.relations.length > 0 ? (
189
+ <div className="info-section" style={{ marginTop: 12 }}>
190
+ <AnimeRow title="You Might Also Like" items={media.relations} />
191
+ </div>
192
+ ) : null}
193
+ </main>
194
+ </div>
195
+ );
196
+ }
frontend/src/pages/AuthCallback.jsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { useNavigate, useSearchParams } from "react-router-dom";
3
+ import { api } from "../api.js";
4
+ import { getToken, setSession } from "../utils/auth.js";
5
+
6
+ export default function AuthCallback() {
7
+ const [searchParams] = useSearchParams();
8
+ const navigate = useNavigate();
9
+ const [msg, setMsg] = useState("Finishing sign in…");
10
+ const [error, setError] = useState("");
11
+
12
+ useEffect(() => {
13
+ const token = searchParams.get("token");
14
+ const err = searchParams.get("error");
15
+ if (err) {
16
+ setError(decodeURIComponent(err));
17
+ setMsg("");
18
+ const t = setTimeout(() => navigate("/home", { replace: true }), 3500);
19
+ return () => clearTimeout(t);
20
+ }
21
+ if (!token) {
22
+ setError("No sign-in result received.");
23
+ setMsg("");
24
+ const t = setTimeout(() => navigate("/home", { replace: true }), 3500);
25
+ return () => clearTimeout(t);
26
+ }
27
+ (async () => {
28
+ try {
29
+ setSession(token, null);
30
+ const user = await api.authMe();
31
+ setSession(getToken(), user);
32
+ navigate("/home", { replace: true });
33
+ } catch (e) {
34
+ setError(e.message || "Sign-in failed.");
35
+ setMsg("");
36
+ setTimeout(() => navigate("/home", { replace: true }), 3500);
37
+ }
38
+ })();
39
+ }, [searchParams, navigate]);
40
+
41
+ return (
42
+ <div className="loader" style={{ minHeight: "80vh", paddingTop: 120 }}>
43
+ <div className="spinner" />
44
+ {error ? (
45
+ <span style={{ color: "#fda4af", maxWidth: 420, textAlign: "center" }}>{error}</span>
46
+ ) : (
47
+ <span>{msg}</span>
48
+ )}
49
+ </div>
50
+ );
51
+ }
frontend/src/pages/Home.jsx ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { api, titleOf } from "../api.js";
4
+ import AnimeRow from "../components/AnimeRow.jsx";
5
+
6
+ export default function Home() {
7
+ const navigate = useNavigate();
8
+ const [trending, setTrending] = useState([]);
9
+ const [popular, setPopular] = useState([]);
10
+ const [recent, setRecent] = useState([]);
11
+
12
+ useEffect(() => {
13
+ let alive = true;
14
+ (async () => {
15
+ try {
16
+ const [t, p, r] = await Promise.all([
17
+ api.trending(1),
18
+ api.popular(1),
19
+ api.recent(1),
20
+ ]);
21
+ if (!alive) return;
22
+ setTrending(t.results || []);
23
+ setPopular(p.results || []);
24
+ setRecent(r.results || []);
25
+ } catch (e) {
26
+ console.error(e);
27
+ }
28
+ })();
29
+ return () => { alive = false; };
30
+ }, []);
31
+
32
+ const top10 = (trending || []).slice(0, 10);
33
+ const [slide, setSlide] = useState(0);
34
+ const [paused, setPaused] = useState(false);
35
+
36
+ useEffect(() => {
37
+ if (paused || top10.length < 2) return;
38
+ const t = setInterval(() => setSlide((s) => (s + 1) % top10.length), 5000);
39
+ return () => clearInterval(t);
40
+ }, [paused, top10.length]);
41
+
42
+ const safeSlide = Math.min(slide, Math.max(0, top10.length - 1));
43
+ const current = top10[safeSlide] || top10[0];
44
+ const go = (dir) => setSlide((s) => (s + dir + top10.length) % top10.length);
45
+ const jump = (i) => setSlide(i);
46
+ const fmtScore = (sc) => (sc != null ? (sc > 10 ? sc / 10 : sc).toFixed(1) : "");
47
+
48
+ const genres = useMemo(() => [
49
+ "Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy", "Horror",
50
+ "Mahou Shoujo", "Mecha", "Music", "Mystery", "Psychological", "Romance",
51
+ "Sci-Fi", "Slice of Life", "Sports", "Supernatural", "Thriller",
52
+ ], []);
53
+
54
+ return (
55
+ <>
56
+ {current ? (
57
+ <section id="hero" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
58
+ <button className="hero-prev" type="button" aria-label="Previous" onClick={(e) => { e.stopPropagation(); go(-1); }}>
59
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
60
+ <path d="M15 18l-6-6 6-6" />
61
+ </svg>
62
+ </button>
63
+ <button className="hero-next" type="button" aria-label="Next" onClick={(e) => { e.stopPropagation(); go(1); }}>
64
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
65
+ <path d="M9 18l6-6-6-6" />
66
+ </svg>
67
+ </button>
68
+
69
+ <div className={`hero-slide ${top10.length ? "active" : ""}`}>
70
+ <div className="hero-bg">
71
+ <img src={current.coverImage ? api.img(current.coverImage) : "https://images.unsplash.com/..."} alt={titleOf(current)} />
72
+ </div>
73
+ <div className="hero-gradient" />
74
+ <div className="hero-content">
75
+ <div className="container">
76
+ <div className="hero-layout">
77
+ <div className="hero-info">
78
+ <div className="hero-title-block">
79
+ <div className="hero-badges">
80
+ <span className="badge badge-dark">{current.format || "TV"}</span>
81
+ <span className="badge badge-score">★ {fmtScore(current.score)}</span>
82
+ <span className="badge badge-dark">{current.episodes ? `${current.episodes} eps` : "24 min"}</span>
83
+ </div>
84
+ <h1 className="hero-title">{titleOf(current)}</h1>
85
+ </div>
86
+
87
+ <p className="hero-desc">{current.description || "A new adventure is waiting. Dive into the latest episodes and discover your next obsession."}</p>
88
+
89
+ <div className="hero-genres">
90
+ {(current.genres || genres.slice(0, 4)).map((g) => (
91
+ <span key={g} className="hero-genre-tag">{g}</span>
92
+ ))}
93
+ </div>
94
+
95
+ <div className="hero-actions">
96
+ <button className="btn btn-primary" type="button" onClick={() => navigate(`/anime/${current.id}`)}>
97
+ Watch Now
98
+ </button>
99
+ <button className="btn btn-ghost" type="button" onClick={() => navigate(`/search?q=${encodeURIComponent(titleOf(current))}`)}>
100
+ More Info
101
+ </button>
102
+ </div>
103
+
104
+ <div className="hero-stats">
105
+ <div className="hero-stat">
106
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" /></svg>
107
+ {current.score != null ? fmtScore(current.score) : "9.4"}
108
+ </div>
109
+ <div className="hero-stat">
110
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm1 15h-2v-2h2Zm0-4h-2V7h2Z" /></svg>
111
+ {current.episodes ? `${current.episodes} episodes` : "Fresh"}
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ <div className="hero-cover-wrap">
117
+ <div className="hero-cover-glow" />
118
+ <img className="hero-cover" src={current.coverImage ? api.img(current.coverImage) : ""} alt={titleOf(current)} />
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <div className="hero-indicators">
126
+ {top10.map((m, i) => (
127
+ <button
128
+ key={m.id}
129
+ type="button"
130
+ className={`hero-dot${i === safeSlide ? " active" : ""}`}
131
+ aria-label={`Go to slide ${i + 1}`}
132
+ onClick={() => jump(i)}
133
+ />
134
+ ))}
135
+ </div>
136
+ </section>
137
+ ) : null}
138
+
139
+ <section className="content-section" style={{ paddingTop: 24 }}>
140
+ <div className="container">
141
+ <div className="genre-bar">
142
+ {genres.map((genre) => (
143
+ <button key={genre} className="genre-pill" type="button" onClick={() => navigate('/search')}>{genre}</button>
144
+ ))}
145
+ </div>
146
+ </div>
147
+ </section>
148
+
149
+ <section className="content-section">
150
+ <div className="container home-grid">
151
+ <div className="home-sections">
152
+ <AnimeRow title="Trending Now" items={trending} isRanked={true} viewAllUrl="/search" />
153
+ <AnimeRow title="Most Popular" items={popular} viewAllUrl="/search" />
154
+ <AnimeRow title="Recently Added" items={recent} viewAllUrl="/search" />
155
+ <AnimeRow title="Movies" items={popular.slice(0, 12)} viewAllUrl="/movies" />
156
+ </div>
157
+
158
+ <aside className="sidebar-widget">
159
+ <div className="sidebar-tabs">
160
+ <button type="button" className="sidebar-tab active">Top Airing</button>
161
+ <button type="button" className="sidebar-tab">New</button>
162
+ </div>
163
+ <div>
164
+ {trending.slice(0, 5).map((item, idx) => (
165
+ <button key={item.id || idx} type="button" className="sidebar-item" onClick={() => navigate(`/anime/${item.id}`)}>
166
+ <span className="sidebar-rank">{idx + 1}</span>
167
+ <img className="sidebar-thumb" src={item.coverImage ? api.img(item.coverImage) : ""} alt={titleOf(item)} />
168
+ <div style={{ flex: 1, textAlign: "left" }}>
169
+ <div className="sidebar-title">{titleOf(item)}</div>
170
+ <div className="sidebar-meta">{item.format || "TV"}</div>
171
+ </div>
172
+ </button>
173
+ ))}
174
+ </div>
175
+ </aside>
176
+ </div>
177
+ </section>
178
+ </>
179
+ );
180
+ }
frontend/src/pages/Landing.jsx ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { api } from "../api.js";
4
+ import HeroSection from "../components/HeroSection.jsx";
5
+ import LibraryMarquee from "../components/LibraryMarquee.jsx";
6
+
7
+ export default function Landing() {
8
+ const navigate = useNavigate();
9
+ const [marqueeItems, setMarqueeItems] = useState([]);
10
+
11
+ useEffect(() => {
12
+ let alive = true;
13
+ (async () => {
14
+ try {
15
+ const [t, p, r] = await Promise.all([
16
+ api.trending(1),
17
+ api.popular(1),
18
+ api.recent(1),
19
+ ]);
20
+ if (!alive) return;
21
+ setMarqueeItems([...(t.results || []), ...(p.results || []), ...(r.results || [])]);
22
+ } catch (e) {
23
+ console.error(e);
24
+ }
25
+ })();
26
+ return () => { alive = false; };
27
+ }, []);
28
+
29
+ return (
30
+ <>
31
+ {/* HERO — the anicove logo through the stats */}
32
+ <HeroSection />
33
+
34
+ {/* LIBRARY THAT KEEPS MOVING */}
35
+ <LibraryMarquee items={marqueeItems} />
36
+
37
+ {/* FEATURE — EVERYTHING STAYS CONNECTED */}
38
+ <section className="feature-section">
39
+ <div className="container">
40
+ <div className="feature-eyebrow">EVERYTHING STAYS CONNECTED</div>
41
+ <h2 className="feature-heading">
42
+ One place for every <span className="accent">kind of story.</span>
43
+ </h2>
44
+ <p className="feature-desc">
45
+ Jump between a weekly anime episode, a manga chapter, a movie, or an opening theme — all without rebuilding your library each time.
46
+ </p>
47
+
48
+ <div className="feature-card">
49
+ <div className="feature-card-icon">
50
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
51
+ <path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
52
+ </svg>
53
+ </div>
54
+ <div className="feature-card-eyebrow">YOUR RULES, YOUR LISTS</div>
55
+ <h3 className="feature-card-title">Connected Tracking</h3>
56
+ <p className="feature-card-desc">
57
+ Link AniList, MAL, or both. anidoom records completed episodes using the tracking rules you choose.
58
+ </p>
59
+
60
+ <div className="feature-track-item">
61
+ <div className="feature-track-name">AniList</div>
62
+ <div className="feature-track-sub">Progress &amp; status sync</div>
63
+ <div className="feature-track-badge">Synced</div>
64
+ </div>
65
+ <div className="feature-track-item">
66
+ <div className="feature-track-name">MyAnimeList</div>
67
+ <div className="feature-track-sub">Auto score &amp; list sync</div>
68
+ <div className="feature-track-badge">Synced</div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </section>
73
+
74
+ {/* ENTER CTA */}
75
+ <section className="landing-enter">
76
+ <div className="landing-enter-glow" />
77
+ <div className="landing-enter-inner">
78
+ <div className="landing-enter-label">EVERYTHING. CONNECTED.</div>
79
+ <h2 className="landing-enter-title">Ready to dive in?</h2>
80
+ <p className="landing-enter-desc">
81
+ Anime, manga, movies, TV and music — with progress that follows you everywhere.
82
+ </p>
83
+ <button className="btn btn-primary" onClick={() => navigate("/home")}>
84
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
85
+ <path d="M8 5v14l11-7z" />
86
+ </svg>
87
+ Enter anicove
88
+ </button>
89
+ </div>
90
+ </section>
91
+ </>
92
+ );
93
+ }
frontend/src/pages/Manga.jsx ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { api } from "../api.js";
3
+ import MangaCard from "../components/MangaCard.jsx";
4
+
5
+ export default function Manga() {
6
+ const [sections, setSections] = useState([]);
7
+ const [q, setQ] = useState("");
8
+ const [results, setResults] = useState([]);
9
+ const [loading, setLoading] = useState(true);
10
+ const [searching, setSearching] = useState(false);
11
+ const [error, setError] = useState(null);
12
+ const debounce = useRef(null);
13
+
14
+ useEffect(() => {
15
+ let alive = true;
16
+ (async () => {
17
+ try {
18
+ const data = await api.mangaHome();
19
+ if (!alive) return;
20
+ setSections(data || []);
21
+ } catch (e) {
22
+ if (alive) setError(e.message);
23
+ } finally {
24
+ if (alive) setLoading(false);
25
+ }
26
+ })();
27
+ return () => {
28
+ alive = false;
29
+ };
30
+ }, []);
31
+
32
+ useEffect(() => {
33
+ if (!q.trim()) {
34
+ setResults([]);
35
+ setSearching(false);
36
+ return undefined;
37
+ }
38
+ setSearching(true);
39
+ clearTimeout(debounce.current);
40
+ debounce.current = setTimeout(async () => {
41
+ try {
42
+ const data = await api.mangaSearch(q.trim());
43
+ setResults(data || []);
44
+ } catch (e) {
45
+ setError(e.message);
46
+ setResults([]);
47
+ } finally {
48
+ setSearching(false);
49
+ }
50
+ }, 350);
51
+ return () => clearTimeout(debounce.current);
52
+ }, [q]);
53
+
54
+ const showSearch = q.trim().length > 0;
55
+
56
+ return (
57
+ <div className="container" style={{ paddingTop: "calc(var(--header-h) + 30px)", paddingBottom: 80 }}>
58
+ {/* MANGA HERO BANNER */}
59
+ <div className="section-header" style={{ marginBottom: 12 }}>
60
+ <h1 className="section-title" style={{ fontSize: "1.8rem" }}>
61
+ Manga Vault
62
+ </h1>
63
+ </div>
64
+ <p style={{ color: "var(--text-secondary)", marginBottom: 28, fontSize: "0.95rem" }}>
65
+ Read thousands of manga, manhwa &amp; manhua online with zero ads.
66
+ </p>
67
+
68
+ {/* SEARCH BAR */}
69
+ <div className="hero-search-wrap" style={{ margin: "0 0 36px 0", maxWidth: 640 }}>
70
+ <input
71
+ type="text"
72
+ className="hero-search-input"
73
+ value={q}
74
+ onChange={(e) => setQ(e.target.value)}
75
+ placeholder="Search manga, manhwa, authors..."
76
+ />
77
+ <div className="hero-search-btn">
78
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" width="18" height="18">
79
+ <circle cx="11" cy="11" r="8" />
80
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
81
+ </svg>
82
+ </div>
83
+ </div>
84
+
85
+ {error && !showSearch ? (
86
+ <div className="error-box">
87
+ {error}
88
+ <div style={{ marginTop: 8, fontSize: "0.82rem" }}>
89
+ Start the MangaVault sidecar with <code>manga-vault/run.bat</code>.
90
+ </div>
91
+ </div>
92
+ ) : null}
93
+
94
+ {showSearch ? (
95
+ searching ? (
96
+ <div className="loader">
97
+ <div className="spinner" />
98
+ <span>Searching manga...</span>
99
+ </div>
100
+ ) : results.length ? (
101
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "20px 16px" }}>
102
+ {results.map((m, i) => (
103
+ <MangaCard key={`${m.source}-${m.id}-${i}`} manga={m} />
104
+ ))}
105
+ </div>
106
+ ) : (
107
+ <div className="empty-msg">
108
+ <p>No manga found for "{q}".</p>
109
+ </div>
110
+ )
111
+ ) : loading ? (
112
+ <div className="scroll-row" style={{ flexWrap: "wrap", gap: 20, overflow: "visible" }}>
113
+ {Array.from({ length: 12 }).map((_, i) => (
114
+ <div key={i} className="anime-card skel-card">
115
+ <div className="skel-poster skeleton" />
116
+ <div className="skel-line skeleton" />
117
+ <div className="skel-line skel-line-sm skeleton" />
118
+ </div>
119
+ ))}
120
+ </div>
121
+ ) : sections.length ? (
122
+ sections.map((section) => (
123
+ <section key={section.key} className="content-section" style={{ padding: "20px 0" }}>
124
+ <div className="section-header">
125
+ <div className="section-title">{section.title}</div>
126
+ <span className="section-link">{section.key.split("_")[0]} →</span>
127
+ </div>
128
+ <div className="scroll-row">
129
+ {section.items.map((m, i) => (
130
+ <MangaCard key={`${section.key}-${m.id}-${i}`} manga={m} />
131
+ ))}
132
+ </div>
133
+ </section>
134
+ ))
135
+ ) : (
136
+ <div className="empty-msg">
137
+ <p>No manga catalog loaded. Check sidecar connection.</p>
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ }
frontend/src/pages/MangaDetails.jsx ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { Link, useNavigate, useParams } from "react-router-dom";
3
+ import { api } from "../api.js";
4
+ import { lastChapterIndex, saveLastRead } from "../utils/mangaProgress.js";
5
+
6
+ export default function MangaDetails() {
7
+ const { source, id } = useParams();
8
+ const navigate = useNavigate();
9
+ const [manga, setManga] = useState(null);
10
+ const [error, setError] = useState(null);
11
+ const [showFullDesc, setShowFullDesc] = useState(false);
12
+
13
+ useEffect(() => {
14
+ let alive = true;
15
+ setManga(null);
16
+ setError(null);
17
+ (async () => {
18
+ try {
19
+ const data = await api.mangaDetails(source, id);
20
+ if (alive) setManga(data);
21
+ } catch (e) {
22
+ if (alive) setError(e.message);
23
+ }
24
+ })();
25
+ return () => {
26
+ alive = false;
27
+ };
28
+ }, [source, id]);
29
+
30
+ const chapters = manga?.chapters || [];
31
+ const lastIdx = useMemo(() => lastChapterIndex(source, id), [source, id, manga]);
32
+ const continueIdx = Number.isInteger(lastIdx) && lastIdx < chapters.length ? lastIdx : 0;
33
+
34
+ function openChapter(idx) {
35
+ const chapter = chapters[idx];
36
+ if (!chapter) return;
37
+ saveLastRead(source, id, idx);
38
+ navigate(
39
+ `/manga/read?source=${source}&id=${encodeURIComponent(id)}&path=${encodeURIComponent(chapter.imagePath)}`,
40
+ { state: { mangaTitle: manga?.title } }
41
+ );
42
+ }
43
+
44
+ if (error) {
45
+ return (
46
+ <div className="container" style={{ paddingTop: "calc(var(--header-h) + 40px)" }}>
47
+ <div className="error-box">{error}</div>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ if (!manga) {
53
+ return (
54
+ <div className="loader" style={{ minHeight: "80vh" }}>
55
+ <div className="spinner" />
56
+ <span>Loading manga details...</span>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ const cover = manga.cover ? api.mangaImg(manga.cover, manga.source) : null;
62
+
63
+ return (
64
+ <div className="info-page">
65
+ {/* HERO BANNER */}
66
+ <div className="info-hero">
67
+ {cover ? (
68
+ <div className="info-hero-bg" style={{ backgroundImage: `url(${cover})` }} />
69
+ ) : null}
70
+ <div className="info-hero-grad" />
71
+ <div className="container info-hero-content">
72
+ <div className="info-hero-body">
73
+ <div className="info-poster">
74
+ <img src={cover} alt={manga.title} />
75
+ </div>
76
+ <div className="info-meta-block">
77
+ <div className="info-type-tag">
78
+ <span className="info-type-tag-dot" />
79
+ <span>MANGA</span>
80
+ <span>•</span>
81
+ <span>{manga.source}</span>
82
+ </div>
83
+ <h1 className="info-title">{manga.title}</h1>
84
+ <div className="info-badges">
85
+ <span className="badge badge-green">{manga.status || "Ongoing"}</span>
86
+ <span className="badge badge-accent">{chapters.length} CHAPTERS</span>
87
+ {manga.views ? <span className="badge badge-dark">{manga.views} Views</span> : null}
88
+ </div>
89
+ {manga.genres ? (
90
+ <div className="info-genres">
91
+ {manga.genres.map((g) => (
92
+ <span key={g} className="info-genre-pill">{g}</span>
93
+ ))}
94
+ </div>
95
+ ) : null}
96
+ <div className="info-actions">
97
+ <button className="btn info-btn-watch" onClick={() => openChapter(continueIdx)}>
98
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
99
+ <path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
100
+ </svg>
101
+ {lastIdx != null ? "Continue Reading" : "Start Reading"}
102
+ </button>
103
+ <Link to="/manga" className="btn info-btn-ghost">
104
+ ← Back to Vault
105
+ </Link>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ {/* DETAILS & CHAPTER LIST */}
113
+ <div className="container info-body">
114
+ <div className="info-layout">
115
+ <div className="info-main-col">
116
+ <div className="info-section-label">Synopsis</div>
117
+ <p className={`info-synopsis ${!showFullDesc ? "clamped" : ""}`}>
118
+ {manga.description || "No synopsis available for this title."}
119
+ </p>
120
+ {manga.description && manga.description.length > 200 ? (
121
+ <button className="show-more-btn" onClick={() => setShowFullDesc(!showFullDesc)}>
122
+ {showFullDesc ? "Show Less" : "Show More"}
123
+ </button>
124
+ ) : null}
125
+
126
+ <div className="info-section-label" style={{ marginTop: 36 }}>
127
+ Chapters ({chapters.length})
128
+ </div>
129
+ {chapters.length ? (
130
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(130px, 1fr))", gap: 10 }}>
131
+ {chapters.map((ch, i) => (
132
+ <button
133
+ key={`${ch.id}-${i}`}
134
+ className={`ep-btn ${i === continueIdx && lastIdx != null ? "active" : ""}`}
135
+ onClick={() => openChapter(i)}
136
+ style={{
137
+ padding: "10px 12px",
138
+ borderRadius: "var(--radius-md)",
139
+ background: i === continueIdx && lastIdx != null ? "rgba(124,58,237,0.18)" : "var(--bg-surface)",
140
+ border: "1px solid " + (i === continueIdx && lastIdx != null ? "rgba(124,58,237,0.4)" : "var(--border)"),
141
+ color: "var(--text-primary)",
142
+ textAlign: "center",
143
+ fontWeight: 600,
144
+ fontSize: "0.83rem",
145
+ cursor: "pointer",
146
+ transition: "var(--transition)"
147
+ }}
148
+ >
149
+ {ch.number != null ? `Ch. ${ch.number}` : ch.title || `Ch. ${i + 1}`}
150
+ </button>
151
+ ))}
152
+ </div>
153
+ ) : (
154
+ <div className="empty-msg">
155
+ <p>No chapters available.</p>
156
+ </div>
157
+ )}
158
+ </div>
159
+
160
+ <div className="info-side-col">
161
+ <div className="info-section-label">Information</div>
162
+ <div className="info-stats-grid">
163
+ <div className="info-stat-cell">
164
+ <div className="info-stat-label">Source</div>
165
+ <div className="info-stat-value">{manga.source}</div>
166
+ </div>
167
+ <div className="info-stat-cell">
168
+ <div className="info-stat-label">Status</div>
169
+ <div className="info-stat-value">{manga.status || "Ongoing"}</div>
170
+ </div>
171
+ <div className="info-stat-cell">
172
+ <div className="info-stat-label">Authors</div>
173
+ <div className="info-stat-value">{manga.authors || "Unknown"}</div>
174
+ </div>
175
+ <div className="info-stat-cell">
176
+ <div className="info-stat-label">Total Chapters</div>
177
+ <div className="info-stat-value">{chapters.length}</div>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ );
185
+ }
frontend/src/pages/MovieDetails.jsx ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { useNavigate, useParams } from "react-router-dom";
3
+ import { api } from "../api.js";
4
+ import { getProgress, CW_KEY } from "../utils/watchProgress.js";
5
+
6
+ export default function MovieDetails() {
7
+ const { slug } = useParams();
8
+ const navigate = useNavigate();
9
+ const [media, setMedia] = useState(null);
10
+ const [loading, setLoading] = useState(true);
11
+ const [error, setError] = useState(null);
12
+ const [season, setSeason] = useState(null);
13
+ const [showFull, setShowFull] = useState(false);
14
+ const [watchedEps, setWatchedEps] = useState(new Set());
15
+
16
+ useEffect(() => {
17
+ let alive = true;
18
+ setLoading(true);
19
+ setError(null);
20
+ (async () => {
21
+ try {
22
+ const m = await api.movieDetails(slug);
23
+ if (!alive) return;
24
+ setMedia(m);
25
+ setSeason(m.seasons?.length ? m.seasons[0] : null);
26
+
27
+ // Load watched episodes
28
+ const progress = getProgress(CW_KEY.movie(slug));
29
+ if (progress) {
30
+ const watched = new Set();
31
+ if (progress.se && progress.ep) {
32
+ watched.add(`${progress.se}-${progress.ep}`);
33
+ }
34
+ setWatchedEps(watched);
35
+ }
36
+ } catch (e) {
37
+ if (alive) setError(e.message);
38
+ } finally {
39
+ if (alive) setLoading(false);
40
+ }
41
+ })();
42
+ return () => { alive = false; };
43
+ }, [slug]);
44
+
45
+ if (loading) {
46
+ return (
47
+ <div className="loader" style={{ minHeight: "80vh" }}>
48
+ <div className="spinner" />
49
+ <span>Loading title...</span>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ if (error || !media) {
55
+ const isMissing = error?.status === 404 || /not found|coming soon/i.test(error || "");
56
+ return (
57
+ <div className="empty" style={{ minHeight: "50vh", paddingTop: 80 }}>
58
+ <div className="big">🎬</div>
59
+ {isMissing ? (
60
+ <>
61
+ <h2 style={{ margin: "0 0 8px" }}>Not available yet</h2>
62
+ <p style={{ maxWidth: 420, margin: "0 auto" }}>
63
+ This title isn&apos;t watchable on MovieBox right now — it may be
64
+ coming soon or has been removed. Try searching for something else.
65
+ </p>
66
+ <button className="btn info-btn-ghost" onClick={() => navigate("/movies")}>
67
+ Browse Movies
68
+ </button>
69
+ </>
70
+ ) : (
71
+ <p>{error || "Title not found."}</p>
72
+ )}
73
+ </div>
74
+ );
75
+ }
76
+
77
+ const isSeries = media.type === 2 || media.seasons?.length > 0;
78
+ const title = media.title;
79
+ const genres = media.genres || [];
80
+ const cast = media.cast || [];
81
+ const rating = media.rating ? Number(media.rating).toFixed(1) : null;
82
+
83
+ function handleWatch(se = 0, ep = 0) {
84
+ if (isSeries) {
85
+ navigate(`/movies/${media.slug}/watch?se=${se}&ep=${ep}`);
86
+ } else {
87
+ navigate(`/movies/${media.slug}/watch`);
88
+ }
89
+ }
90
+
91
+ return (
92
+ <div className="info-page">
93
+ <div className="info-hero">
94
+ {media.banner ? (
95
+ <div className="info-hero-bg" style={{ backgroundImage: `url(${api.img(media.banner)})` }} />
96
+ ) : null}
97
+ <div className="info-hero-grad" />
98
+ <div className="container info-hero-content">
99
+ <div className="info-hero-body">
100
+ <div className="info-poster">
101
+ <img src={api.img(media.cover)} alt={title} />
102
+ </div>
103
+ <div className="info-meta-block">
104
+ <div className="info-type-tag">
105
+ <span className="info-type-tag-dot" />
106
+ <span>{isSeries ? "TV Series" : "Movie"}</span>
107
+ <span>•</span>
108
+ <span>{media.year || media.country || "—"}</span>
109
+ {media.corner ? <span>•</span> : null}
110
+ {media.corner ? <span>{media.corner}</span> : null}
111
+ </div>
112
+ <h1 className="info-title">{title}</h1>
113
+ <div className="info-badges">
114
+ {rating ? <span className="badge badge-score">★ {rating}</span> : null}
115
+ <span className="badge badge-green">{isSeries ? "Series" : "Feature"}</span>
116
+ {media.duration ? <span className="badge badge-dark">{media.duration} min</span> : null}
117
+ {media.country ? <span className="badge badge-dark">{media.country}</span> : null}
118
+ </div>
119
+ {genres.length ? (
120
+ <div className="info-genres">
121
+ {genres.map((g) => (
122
+ <span key={g} className="info-genre-pill">{g}</span>
123
+ ))}
124
+ </div>
125
+ ) : null}
126
+ <div className="info-actions">
127
+ {media.hasResource ? (
128
+ <button
129
+ className="btn info-btn-watch"
130
+ onClick={() => handleWatch(season?.se ?? 0, isSeries ? 1 : 0)}
131
+ >
132
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
133
+ <path d="M8 5v14l11-7z" />
134
+ </svg>
135
+ {isSeries ? `Watch S${season?.se ?? 1}E1` : "Watch Now"}
136
+ </button>
137
+ ) : (
138
+ <span className="badge badge-dark" style={{ fontSize: "0.85rem", padding: "10px 16px" }}>
139
+ Stream unavailable{media.note ? ` — ${media.note}` : ""}
140
+ </span>
141
+ )}
142
+ {media.trailer ? (
143
+ <a
144
+ className="btn info-btn-ghost"
145
+ href={media.trailer}
146
+ target="_blank"
147
+ rel="noreferrer"
148
+ style={{ textDecoration: "none" }}
149
+ >
150
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
151
+ <path d="M8 5v14l11-7z" />
152
+ </svg>
153
+ Trailer
154
+ </a>
155
+ ) : null}
156
+ <button className="btn info-btn-ghost" onClick={() => {
157
+ navigator.clipboard.writeText(window.location.href);
158
+ }} title="Copy link">
159
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
160
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
161
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
162
+ </svg>
163
+ Share
164
+ </button>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+
171
+ <div className="container info-body">
172
+ <div className="info-layout">
173
+ <div className="info-main-col">
174
+ <div className="info-section-label">Synopsis</div>
175
+ <p className={`info-synopsis ${!showFull ? "clamped" : ""}`}>
176
+ {media.description || "No description available for this title."}
177
+ </p>
178
+ {media.description && media.description.length > 200 ? (
179
+ <button className="show-more-btn" onClick={() => setShowFull(!showFull)}>
180
+ {showFull ? "Show Less" : "Show More"}
181
+ </button>
182
+ ) : null}
183
+
184
+ {cast.length ? (
185
+ <>
186
+ <div className="info-section-label" style={{ marginTop: 32 }}>Cast</div>
187
+ <div className="cast-row">
188
+ {cast.map((person, i) => (
189
+ <div key={`${person.name}-${i}`} className="cast-chip" title={person.character}>
190
+ {person.avatar ? (
191
+ <img src={api.img(person.avatar)} alt={person.name} loading="lazy" />
192
+ ) : (
193
+ <div className="cast-avatar-fallback">{(person.name || "?").charAt(0)}</div>
194
+ )}
195
+ <div className="cast-chip-name">{person.name}</div>
196
+ <div className="cast-chip-role">{person.character || "—"}</div>
197
+ </div>
198
+ ))}
199
+ </div>
200
+ </>
201
+ ) : null}
202
+ </div>
203
+
204
+ <div className="info-side-col">
205
+ <div className="info-section-label">Information</div>
206
+ <div className="info-stats-grid">
207
+ <div className="info-stat-cell">
208
+ <div className="info-stat-label">Type</div>
209
+ <div className="info-stat-value">{isSeries ? "TV Series" : "Movie"}</div>
210
+ </div>
211
+ <div className="info-stat-cell">
212
+ <div className="info-stat-label">Released</div>
213
+ <div className="info-stat-value">{media.releaseDate || media.year || "—"}</div>
214
+ </div>
215
+ <div className="info-stat-cell">
216
+ <div className="info-stat-label">Rating</div>
217
+ <div className="info-stat-value">{rating ? `★ ${rating}` : "—"}</div>
218
+ </div>
219
+ <div className="info-stat-cell">
220
+ <div className="info-stat-label">Duration</div>
221
+ <div className="info-stat-value">{media.duration ? `${media.duration} min` : "—"}</div>
222
+ </div>
223
+ <div className="info-stat-cell">
224
+ <div className="info-stat-label">Audio</div>
225
+ <div className="info-stat-value">
226
+ {media.audioTracks?.length
227
+ ? media.audioTracks.filter((t) => t?.original).map((t) => t.lanName).join(", ")
228
+ : "Original"}
229
+ </div>
230
+ </div>
231
+ <div className="info-stat-cell">
232
+ <div className="info-stat-label">IMDb votes</div>
233
+ <div className="info-stat-value">
234
+ {media.imdbCount ? media.imdbCount.toLocaleString?.() || media.imdbCount : "—"}
235
+ </div>
236
+ </div>
237
+ </div>
238
+ </div>
239
+ </div>
240
+
241
+ {/* Season + episode picker for series */}
242
+ {isSeries && media.seasons?.length ? (
243
+ <div style={{ marginTop: 40 }}>
244
+ <div className="info-section-label">Episodes</div>
245
+ {media.seasons.length > 1 ? (
246
+ <div className="movie-tabs" style={{ marginBottom: 18 }}>
247
+ {media.seasons.map((s) => (
248
+ <button
249
+ key={s.se}
250
+ type="button"
251
+ className={season?.se === s.se ? "active" : ""}
252
+ onClick={() => setSeason(s)}
253
+ >
254
+ Season {s.se}
255
+ </button>
256
+ ))}
257
+ </div>
258
+ ) : null}
259
+ <div className="ep-grid">
260
+ {Array.from({ length: season?.maxEp || 0 }).map((_, i) => {
261
+ const ep = i + 1;
262
+ const isWatched = watchedEps.has(`${season.se}-${ep}`);
263
+ return (
264
+ <button
265
+ key={ep}
266
+ className={`ep-grid-btn ${isWatched ? "watched" : ""}`}
267
+ onClick={() => handleWatch(season.se, ep)}
268
+ >
269
+ <span className="ep-grid-num">{ep}</span>
270
+ {isWatched && <span className="ep-grid-watched">✓</span>}
271
+ <span className="ep-grid-play">
272
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
273
+ <path d="M8 5v14l11-7z" />
274
+ </svg>
275
+ </span>
276
+ </button>
277
+ );
278
+ })}
279
+ </div>
280
+ </div>
281
+ ) : null}
282
+ </div>
283
+ </div>
284
+ );
285
+ }
frontend/src/pages/MovieWatch.jsx ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
3
+ import { api, proxiedStreamUrl, streamHeaders } from "../api.js";
4
+ import HlsPlayer from "../components/HlsPlayer.jsx";
5
+ import { CW_KEY, getProgress, removeProgress, saveProgress } from "../utils/watchProgress.js";
6
+
7
+ /** Fetch an .srt via the HLS worker and return a WebVTT blob URL (or null). */
8
+ async function srtToVttBlob(url, ref) {
9
+ try {
10
+ const res = await fetch(proxiedStreamUrl(url, ref), { headers: streamHeaders() });
11
+ if (!res.ok) return null;
12
+ const srt = await res.text();
13
+ // SRT timestamps "00:00:01,000" → WebVTT "00:00:01.000"
14
+ const vtt = "WEBVTT\n\n" + srt.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2");
15
+ return URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export default function MovieWatch() {
22
+ const { slug } = useParams();
23
+ const [searchParams] = useSearchParams();
24
+ const se = Number(searchParams.get("se") || 0);
25
+ const ep = Number(searchParams.get("ep") || 0);
26
+ const navigate = useNavigate();
27
+
28
+ const [data, setData] = useState(null);
29
+ const [media, setMedia] = useState(null);
30
+ const [quality, setQuality] = useState(0);
31
+ const [captions, setCaptions] = useState([]);
32
+ const [loading, setLoading] = useState(true);
33
+ const [error, setError] = useState(null);
34
+ const [selectedSeason, setSelectedSeason] = useState(se || 1);
35
+
36
+ useEffect(() => {
37
+ let alive = true;
38
+ setLoading(true);
39
+ setError(null);
40
+ setCaptions([]);
41
+ setQuality(0);
42
+ (async () => {
43
+ try {
44
+ const [s, m] = await Promise.all([
45
+ api.movieStream(slug, se, ep),
46
+ api.movieDetails(slug).catch(() => null),
47
+ ]);
48
+ if (!alive) return;
49
+ setData(s);
50
+ setMedia(m);
51
+ // Convert SRT captions to WebVTT blobs (through the HLS worker for CORS).
52
+ const ref = s.streams?.[0]?.referer || "";
53
+ const tracks = await Promise.all(
54
+ (s.captions || []).map(async (c) => ({
55
+ ...c,
56
+ vtt: await srtToVttBlob(c.url, ref),
57
+ }))
58
+ );
59
+ if (alive) setCaptions(tracks.filter((t) => t.vtt));
60
+ } catch (e) {
61
+ if (alive) setError(e.message);
62
+ } finally {
63
+ if (alive) setLoading(false);
64
+ }
65
+ })();
66
+ return () => { alive = false; };
67
+ }, [slug, se, ep]);
68
+
69
+ const streams = useMemo(() => data?.streams || [], [data]);
70
+ const selected = streams[quality] || streams[0];
71
+
72
+ const isSeries = media?.type === 2 || media?.seasons?.length > 0;
73
+ const currentSeasonData = media?.seasons?.find((s) => s.se === selectedSeason);
74
+ const maxEp = currentSeasonData?.maxEp;
75
+ const totalSeasons = media?.seasons?.length || 1;
76
+ const seasons = media?.seasons || [];
77
+
78
+ function goTo(nse, nep) {
79
+ const params = new URLSearchParams();
80
+ if (nse > 0) params.set("se", nse);
81
+ if (nep > 0) params.set("ep", nep);
82
+ navigate(`/movies/${slug}/watch${params.toString() ? `?${params}` : ""}`);
83
+ setSelectedSeason(nse);
84
+ }
85
+
86
+ function nextEp() {
87
+ if (!isSeries) return;
88
+ if (ep < (maxEp || 1)) goTo(selectedSeason, ep + 1);
89
+ else if (selectedSeason < totalSeasons) goTo(selectedSeason + 1, 1);
90
+ }
91
+
92
+ function prevEp() {
93
+ if (!isSeries) return;
94
+ if (ep > 1) goTo(selectedSeason, ep - 1);
95
+ else if (selectedSeason > 1) goTo(selectedSeason - 1, media?.seasons?.find((s) => s.se === selectedSeason - 1)?.maxEp || 1);
96
+ }
97
+
98
+ const title = media?.title || "Title";
99
+ const watchLabel = isSeries ? `S${se || 1} E${ep || 1}` : "Movie";
100
+
101
+ const progressKey = CW_KEY.movie(slug);
102
+
103
+ // Resume position for this episode, from the Continue Watching store.
104
+ const startAt = useMemo(() => {
105
+ const p = getProgress(progressKey);
106
+ return p && p.se === se && p.ep === ep ? p.position || 0 : 0;
107
+ }, [progressKey, se, ep]);
108
+
109
+ // Record playback progress (throttled by the player). Finished titles drop
110
+ // out of Continue Watching automatically.
111
+ const handleProgress = useCallback(
112
+ ({ position, duration, ended }) => {
113
+ if (ended || (duration > 0 && position / duration > 0.92)) {
114
+ removeProgress(progressKey);
115
+ return;
116
+ }
117
+ saveProgress({
118
+ key: progressKey,
119
+ type: "movie",
120
+ mediaId: slug,
121
+ title,
122
+ image: media?.cover || "",
123
+ href: se > 0 || ep > 0 ? `/movies/${slug}/watch?se=${se}&ep=${ep}` : `/movies/${slug}/watch`,
124
+ label: watchLabel,
125
+ category: "",
126
+ se,
127
+ ep,
128
+ position: Math.floor(position),
129
+ duration: Math.floor(duration),
130
+ updatedAt: Date.now(),
131
+ });
132
+ },
133
+ [progressKey, slug, se, ep, title, watchLabel, media]
134
+ );
135
+
136
+ return (
137
+ <div className="watch-page">
138
+ <div className="watch-container">
139
+ <div className="watch-layout">
140
+ <div className="watch-main">
141
+ <div className="player-wrap">
142
+ {loading ? (
143
+ <div className="player-loading">
144
+ <div className="spinner" />
145
+ <span>Loading Player...</span>
146
+ </div>
147
+ ) : error ? (
148
+ <div className="player-error">
149
+ <div className="big">🎬</div>
150
+ <p>{error}</p>
151
+ </div>
152
+ ) : selected ? (
153
+ <HlsPlayer
154
+ key={`${slug}-${se}-${ep}`}
155
+ sources={{
156
+ streams: [selected],
157
+ subtitles: captions.map((c) => ({
158
+ file: c.vtt,
159
+ label: c.label || c.lang || "English",
160
+ kind: "captions",
161
+ srcLang: c.lang || "en",
162
+ })),
163
+ }}
164
+ onProgress={handleProgress}
165
+ startAt={startAt}
166
+ />
167
+ ) : (
168
+ <div className="player-error">
169
+ <div className="big">📺</div>
170
+ <p>{data?.note || "No stream available for this title yet."}</p>
171
+ </div>
172
+ )}
173
+ </div>
174
+
175
+ {streams.length > 1 ? (
176
+ <div className="movie-quality-bar">
177
+ <span className="movie-quality-label">Quality</span>
178
+ <div className="movie-quality-options">
179
+ {streams.map((s, i) => (
180
+ <button
181
+ key={`${s.resolution}-${i}`}
182
+ className={`movie-quality-btn ${quality === i ? "active" : ""}`}
183
+ onClick={() => setQuality(i)}
184
+ >
185
+ {s.resolution || s.format || "HD"}
186
+ </button>
187
+ ))}
188
+ </div>
189
+ </div>
190
+ ) : null}
191
+
192
+ <div className="ep-label">{watchLabel}</div>
193
+ <h1 className="ep-title">{title}</h1>
194
+
195
+ <div className="watch-anime-row">
196
+ {media?.cover ? (
197
+ <div className="watch-anime-thumb">
198
+ <img src={api.img(media.cover)} alt={title} />
199
+ </div>
200
+ ) : null}
201
+ <div>
202
+ <div className="watch-anime-name">{title}</div>
203
+ <div className="watch-anime-sub-text">
204
+ {isSeries ? "TV Series" : "Movie"} • {media?.year || media?.country || "—"}
205
+ </div>
206
+ </div>
207
+
208
+ <div className="watch-audio-btns">
209
+ {isSeries ? (
210
+ <>
211
+ <button className="audio-btn" onClick={prevEp} disabled={se <= 1 && ep <= 1}>
212
+ ← Prev
213
+ </button>
214
+ <button className="audio-btn" onClick={nextEp}>
215
+ Next →
216
+ </button>
217
+ </>
218
+ ) : null}
219
+ {selected?.downloadUrl ? (
220
+ <a
221
+ className="server-btn download-btn"
222
+ href={selected.downloadUrl}
223
+ download
224
+ title={`Download ${selected.resolution || selected.format || "video"}`}
225
+ >
226
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
227
+ <path d="M12 3v10.17l-3.59-3.58L7 11l5 5 5-5-1.41-1.41L13 13.17V3h-1zM5 19h14v2H5z" />
228
+ </svg>
229
+ Download
230
+ </a>
231
+ ) : null}
232
+ {media?.trailer ? (
233
+ <a className="server-btn" href={media.trailer} target="_blank" rel="noreferrer">
234
+ Trailer
235
+ </a>
236
+ ) : null}
237
+ <button className="server-btn" onClick={() => navigate(`/movies/${slug}`)}>
238
+ Details
239
+ </button>
240
+ </div>
241
+ </div>
242
+
243
+ <div className="watch-info-card">
244
+ <div className="watch-info-title">{title}</div>
245
+ <div className="watch-info-meta">
246
+ {media?.rating ? <span className="watch-meta-score">★ {Number(media.rating).toFixed(1)}</span> : null}
247
+ <span className="watch-meta-dot" />
248
+ <span className="watch-meta-item">{isSeries ? "TV Series" : "Movie"}</span>
249
+ <span className="watch-meta-dot" />
250
+ <span className="watch-meta-item">{media?.year || "—"}</span>
251
+ {media?.duration ? (
252
+ <>
253
+ <span className="watch-meta-dot" />
254
+ <span className="watch-meta-item">{media.duration}m</span>
255
+ </>
256
+ ) : null}
257
+ </div>
258
+ {media?.genres?.length ? (
259
+ <div className="watch-genres">
260
+ {media.genres.map((g) => (
261
+ <span key={g} className="watch-genre-pill">{g}</span>
262
+ ))}
263
+ </div>
264
+ ) : null}
265
+ <p className="watch-synopsis">{media?.description || "No description available."}</p>
266
+ </div>
267
+ </div>
268
+
269
+ <div className="watch-sidebar">
270
+ <div className="sidebar-head">
271
+ <div className="sidebar-upnext">{isSeries ? "Episodes" : "Now Playing"}</div>
272
+ <div className="sidebar-subtitle">
273
+ {isSeries ? `Season ${selectedSeason || 1}` : media?.title || " "}
274
+ </div>
275
+ </div>
276
+
277
+ {isSeries && totalSeasons > 1 ? (
278
+ <div className="season-selector">
279
+ <span className="season-label">Season</span>
280
+ <div className="season-options">
281
+ {seasons.map((s) => (
282
+ <button
283
+ key={s.se}
284
+ className={`season-btn ${selectedSeason === s.se ? "active" : ""}`}
285
+ onClick={() => goTo(s.se, 1)}
286
+ >
287
+ S{s.se}
288
+ </button>
289
+ ))}
290
+ </div>
291
+ </div>
292
+ ) : null}
293
+
294
+ {isSeries ? (
295
+ <div className="ep-list">
296
+ {Array.from({ length: maxEp || 0 }).map((_, i) => {
297
+ const n = i + 1;
298
+ const active = n === ep;
299
+ const progress = getProgress(CW_KEY.movie(slug));
300
+ const isWatched = progress && progress.se === selectedSeason && progress.ep === n && (progress.position / progress.duration) > 0.92;
301
+ return (
302
+ <div key={n} className={`ep-item ${active ? "active" : ""} ${isWatched ? "watched" : ""}`} onClick={() => goTo(selectedSeason, n)}>
303
+ <div className="ep-thumb">
304
+ {media?.cover ? <img src={api.img(media.cover)} alt="" /> : null}
305
+ <div className="ep-thumb-label">EP {n}</div>
306
+ {isWatched && <div className="ep-watched-badge">✓</div>}
307
+ </div>
308
+ <div className="ep-info">
309
+ <div className="ep-info-title">Episode {n}</div>
310
+ <div className="ep-info-sub">{title}</div>
311
+ </div>
312
+ </div>
313
+ );
314
+ })}
315
+ </div>
316
+ ) : (
317
+ <div className="empty" style={{ padding: "24px 8px" }}>
318
+ <p>Feature film — sit back and enjoy.</p>
319
+ </div>
320
+ )}
321
+ </div>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ );
326
+ }
frontend/src/pages/Movies.jsx ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { api } from "../api.js";
3
+ import MovieCard from "../components/MovieCard.jsx";
4
+ import MovieRow from "../components/MovieRow.jsx";
5
+
6
+ const TABS = [
7
+ { key: "home", label: "Home" },
8
+ { key: "movie", label: "Movies" },
9
+ { key: "tv", label: "TV Series" },
10
+ { key: "animation", label: "Animation" },
11
+ ];
12
+
13
+ const SORTS = ["RECOMMEND", "HOT", "NEW", "RATING", "POPULAR", "LATEST"];
14
+
15
+ const GRID_STYLE = {
16
+ display: "grid",
17
+ gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
18
+ gap: "20px 16px",
19
+ };
20
+
21
+ export default function Movies() {
22
+ const [tab, setTab] = useState("home");
23
+ const [sections, setSections] = useState([]);
24
+ const [catalog, setCatalog] = useState([]);
25
+ const [page, setPage] = useState(1);
26
+ const [hasMore, setHasMore] = useState(false);
27
+ const [sort, setSort] = useState("RECOMMEND");
28
+ const [q, setQ] = useState("");
29
+ const [results, setResults] = useState([]);
30
+ const [searching, setSearching] = useState(false);
31
+ const [suggestions, setSuggestions] = useState([]);
32
+ const [loading, setLoading] = useState(true);
33
+ const [error, setError] = useState(null);
34
+ const debounce = useRef(null);
35
+ const suggestRef = useRef(null);
36
+
37
+ // ── home sections (once) ─────────────────────────────────────────
38
+ useEffect(() => {
39
+ let alive = true;
40
+ (async () => {
41
+ try {
42
+ const data = await api.movieHome();
43
+ if (alive) setSections(data || []);
44
+ } catch (e) {
45
+ if (alive) setError(e.message);
46
+ } finally {
47
+ if (alive) setLoading(false);
48
+ }
49
+ })();
50
+ return () => { alive = false; };
51
+ }, []);
52
+
53
+ // ── catalog load (per tab/sort/page) ─────────────────────────────
54
+ useEffect(() => {
55
+ if (tab === "home" || q.trim()) return undefined;
56
+ let alive = true;
57
+ setLoading(true);
58
+ (async () => {
59
+ try {
60
+ const data = await api.movieCatalog(tab, page, sort);
61
+ if (!alive) return;
62
+ setCatalog((prev) => (page === 1 ? data.results : [...prev, ...data.results]));
63
+ setHasMore(data.hasNextPage);
64
+ } catch (e) {
65
+ if (alive) setError(e.message);
66
+ } finally {
67
+ if (alive) setLoading(false);
68
+ }
69
+ })();
70
+ return () => { alive = false; };
71
+ }, [tab, page, sort, q]);
72
+
73
+ // ── search + suggestions (debounced) ─────────────────────────────
74
+ useEffect(() => {
75
+ if (!q.trim()) {
76
+ setResults([]);
77
+ setSuggestions([]);
78
+ setSearching(false);
79
+ return undefined;
80
+ }
81
+ setSearching(true);
82
+ clearTimeout(debounce.current);
83
+ debounce.current = setTimeout(async () => {
84
+ try {
85
+ const [data, sug] = await Promise.all([
86
+ api.movieSearch(q.trim(), 1),
87
+ api.movieSuggest(q.trim()).catch(() => []),
88
+ ]);
89
+ setResults(data.results || []);
90
+ setSuggestions(sug || []);
91
+ } catch (e) {
92
+ setError(e.message);
93
+ setResults([]);
94
+ } finally {
95
+ setSearching(false);
96
+ }
97
+ }, 350);
98
+ return () => clearTimeout(debounce.current);
99
+ }, [q]);
100
+
101
+ // close suggestions on outside click
102
+ useEffect(() => {
103
+ function onClick(e) {
104
+ if (suggestRef.current && !suggestRef.current.contains(e.target)) {
105
+ setSuggestions([]);
106
+ }
107
+ }
108
+ document.addEventListener("mousedown", onClick);
109
+ return () => document.removeEventListener("mousedown", onClick);
110
+ }, []);
111
+
112
+ function switchTab(key) {
113
+ setTab(key);
114
+ setPage(1);
115
+ setCatalog([]);
116
+ setError(null);
117
+ setSuggestions([]);
118
+ }
119
+
120
+ function switchSort(value) {
121
+ setSort(value);
122
+ setPage(1);
123
+ setCatalog([]);
124
+ }
125
+
126
+ function loadMore() {
127
+ setPage((p) => p + 1);
128
+ }
129
+
130
+ function pickSuggestion(s) {
131
+ setSuggestions([]);
132
+ setQ(s);
133
+ }
134
+
135
+ const showSearch = q.trim().length > 0;
136
+ const showHome = tab === "home" && !showSearch;
137
+
138
+ return (
139
+ <div className="container page-shell">
140
+ <div className="section-header compact">
141
+ <h1 className="section-title">Movies &amp; TV</h1>
142
+ </div>
143
+
144
+ {/* Search + suggestions */}
145
+ <div className="movie-search-wrap" ref={suggestRef}>
146
+ <div className="hero-search-wrap" style={{ margin: "0 0 22px 0", maxWidth: 640 }}>
147
+ <input
148
+ type="text"
149
+ className="hero-search-input"
150
+ value={q}
151
+ onChange={(e) => setQ(e.target.value)}
152
+ placeholder="Search movies & TV…"
153
+ />
154
+ <div className="hero-search-btn">
155
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" width="18" height="18">
156
+ <circle cx="11" cy="11" r="8" />
157
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
158
+ </svg>
159
+ </div>
160
+ {suggestions.length > 0 ? (
161
+ <div className="movie-suggest">
162
+ {suggestions.map((s, i) => (
163
+ <button key={`${s.title}-${i}`} className="movie-suggest-item" onClick={() => pickSuggestion(s.title)}>
164
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
165
+ <circle cx="11" cy="11" r="7" />
166
+ <path d="m20 20-3.5-3.5" />
167
+ </svg>
168
+ {s.title}
169
+ </button>
170
+ ))}
171
+ </div>
172
+ ) : null}
173
+ </div>
174
+ </div>
175
+
176
+ {/* Tabs */}
177
+ <div className="movie-tabs">
178
+ {TABS.map((t) => (
179
+ <button
180
+ key={t.key}
181
+ type="button"
182
+ className={tab === t.key && !showSearch ? "active" : ""}
183
+ onClick={() => switchTab(t.key)}
184
+ >
185
+ {t.label}
186
+ </button>
187
+ ))}
188
+ </div>
189
+
190
+ {/* Sort (catalog tabs only) */}
191
+ {!showHome && !showSearch ? (
192
+ <div className="movie-sort">
193
+ <span className="movie-sort-label">Sort</span>
194
+ {SORTS.map((s) => (
195
+ <button key={s} className={sort === s ? "active" : ""} onClick={() => switchSort(s)}>
196
+ {s}
197
+ </button>
198
+ ))}
199
+ </div>
200
+ ) : null}
201
+
202
+ {error ? (
203
+ <div className="error-box">
204
+ {error}
205
+ <div style={{ marginTop: 8, fontSize: "0.82rem" }}>
206
+ MovieBox is rate-limiting requests right now — try again in a few minutes.
207
+ </div>
208
+ </div>
209
+ ) : null}
210
+
211
+ {/* Search results */}
212
+ {showSearch ? (
213
+ searching ? (
214
+ <div style={GRID_STYLE}>
215
+ {Array.from({ length: 12 }).map((_, i) => (
216
+ <div key={i} className="anime-card skel-card">
217
+ <div className="skel-poster skeleton" />
218
+ <div className="skel-line skeleton" />
219
+ <div className="skel-line skel-line-sm skeleton" />
220
+ </div>
221
+ ))}
222
+ </div>
223
+ ) : results.length ? (
224
+ <div style={GRID_STYLE}>
225
+ {results.map((m) => (
226
+ <MovieCard key={m.slug || m.id} item={m} />
227
+ ))}
228
+ </div>
229
+ ) : (
230
+ <div className="empty">
231
+ <div className="big">🎬</div>
232
+ <p>No movies found for “{q}”.</p>
233
+ </div>
234
+ )
235
+ ) : null}
236
+
237
+ {/* Home rows */}
238
+ {showHome ? (
239
+ loading ? (
240
+ <div style={GRID_STYLE}>
241
+ {Array.from({ length: 12 }).map((_, i) => (
242
+ <div key={i} className="anime-card skel-card">
243
+ <div className="skel-poster skeleton" />
244
+ <div className="skel-line skeleton" />
245
+ <div className="skel-line skel-line-sm skeleton" />
246
+ </div>
247
+ ))}
248
+ </div>
249
+ ) : sections.length ? (
250
+ sections.map((section) => (
251
+ <MovieRow key={section.key} title={section.title} items={section.items} />
252
+ ))
253
+ ) : (
254
+ <div className="empty">
255
+ <div className="big">🎬</div>
256
+ <p>No sections loaded from MovieBox.</p>
257
+ </div>
258
+ )
259
+ ) : null}
260
+
261
+ {/* Catalog grid */}
262
+ {!showHome && !showSearch ? (
263
+ <>
264
+ <div style={GRID_STYLE}>
265
+ {catalog.map((m) => (
266
+ <MovieCard key={m.slug || m.id} item={m} />
267
+ ))}
268
+ </div>
269
+ {loading ? (
270
+ <div className="loader">
271
+ <div className="spinner" />
272
+ </div>
273
+ ) : null}
274
+ {!loading && catalog.length === 0 && !error ? (
275
+ <div className="empty">
276
+ <div className="big">📺</div>
277
+ <p>No titles in this tab yet.</p>
278
+ </div>
279
+ ) : null}
280
+ {hasMore ? (
281
+ <div className="load-more-wrap">
282
+ <button className="btn btn-primary load-more-btn" onClick={loadMore}>
283
+ Load More
284
+ </button>
285
+ </div>
286
+ ) : null}
287
+ </>
288
+ ) : null}
289
+ </div>
290
+ );
291
+ }