Files
pipu-music/docs/Разработка дизайна приложения/Pipu Web.dc.html
T
mrleo1nidandClaude Opus 5.5 9eb1c25147 docs: add product idea, design prototypes and development documentation
- IDEA.md: product vision from the user's perspective (input for design)
- Architecture, ADRs, data model, REST API overview
- Auth and roles, library storage (NFS, path regexes, scanner, search),
  playback, recommendation system ("My Wave"), track upload queue
- Web, Android (offline, self-update), i18n, design system
- Testing strategy, Gitea Actions CI/CD, deployment, conventions, roadmap
- Clickable web/Android design prototypes and wave button variants
- .claude/launch.json to serve the prototypes locally

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:21:31 +03:00

1615 lines
193 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700&family=Unbounded:wght@500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0&display=block" rel="stylesheet">
<script src="pipu-data.js"></script>
<style>
:root{--bg:#05070D;--bg2:#080B14;--surface:#0B0F1A;--surface2:#121827;--surface3:#1A2236;--elev:#111726;--hover:rgba(120,150,255,.07);--line:rgba(130,160,255,.10);--line2:rgba(130,160,255,.18);--text:#EDF1FF;--text2:#A0ABC6;--text3:#737F9C;--accent:#2F80FF;--accent2:#5E9BFF;--accent-soft:rgba(47,128,255,.15);--violet:#9B7CFF;--violet-soft:rgba(124,92,255,.16);--ok:#2FD39A;--ok-soft:rgba(47,211,154,.14);--warn:#FFB547;--warn-soft:rgba(255,181,71,.14);--danger:#FF5C70;--danger-soft:rgba(255,92,112,.14);--overlay:rgba(3,5,10,.66);--shadow:0 24px 64px rgba(0,0,0,.55);--glass:rgba(12,16,28,.78);--glow:0 8px 28px rgba(47,128,255,.38);--surface-a:rgba(11,15,26,.70);--bar-a:rgba(6,9,16,.55);--amb-op:.8}
[data-theme="light"]{--bg:#F3F5FA;--bg2:#EBEEF5;--surface:#FFFFFF;--surface2:#F0F3F9;--surface3:#E4E9F3;--elev:#FFFFFF;--hover:rgba(30,60,140,.06);--line:rgba(20,35,80,.10);--line2:rgba(20,35,80,.17);--text:#0A0F1E;--text2:#4B5675;--text3:#677290;--accent:#1F6FF2;--accent2:#1F6FF2;--accent-soft:rgba(31,111,242,.10);--violet:#6A47F0;--violet-soft:rgba(106,71,240,.10);--ok:#0E9465;--ok-soft:rgba(14,148,101,.10);--warn:#A86200;--warn-soft:rgba(168,98,0,.10);--danger:#D32F47;--danger-soft:rgba(211,47,71,.09);--overlay:rgba(20,30,60,.32);--shadow:0 24px 64px rgba(20,30,60,.18);--glass:rgba(255,255,255,.84);--glow:0 8px 24px rgba(31,111,242,.28);--surface-a:rgba(255,255,255,.74);--bar-a:rgba(255,255,255,.55);--amb-op:.45}
[data-shell="classic"]{--side-w:240px;--gap:0px;--pad:0px;--pr:0px;--panel-bg:transparent;--panel-blur:none;--side-border:1px solid var(--line);--top-border:1px solid var(--line);--top-px:20px;--player-bg:var(--bar-a)}
[data-shell="floating"]{--side-w:232px;--gap:8px;--pad:8px;--pr:18px;--panel-bg:var(--surface-a);--panel-blur:blur(28px) saturate(1.2);--side-border:0;--top-border:0;--top-px:8px;--player-bg:var(--surface-a)}
[data-player="bar"]{--rows:60px minmax(0,1fr) 88px;--cpb:40px}
[data-player="island"]{--rows:60px minmax(0,1fr);--cpb:132px}
@keyframes pipuDrift1{0%{transform:translate(-6%,-4%) scale(1)}35%{transform:translate(45%,28%) scale(1.2)}70%{transform:translate(15%,60%) scale(.9)}100%{transform:translate(60%,-5%) scale(1.1)}}
@keyframes pipuDrift2{0%{transform:translate(4%,0) scale(1.05)}40%{transform:translate(-50%,40%) scale(.9)}75%{transform:translate(-20%,-25%) scale(1.2)}100%{transform:translate(-60%,15%) scale(1)}}
@keyframes pipuDrift3{0%{transform:translate(0,0) scale(.95)}30%{transform:translate(-40%,-45%) scale(1.15)}65%{transform:translate(45%,-25%) scale(1)}100%{transform:translate(10%,-55%) scale(1.2)}}
@keyframes pipuHue{0%{filter:hue-rotate(0deg)}50%{filter:hue-rotate(-35deg)}100%{filter:hue-rotate(0deg)}}
@keyframes pipuEq{0%{transform:scaleY(.25)}100%{transform:scaleY(1)}}
@keyframes pipuIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
body{margin:0;background:#05070D;font-family:Onest,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
a{color:var(--accent2);text-decoration:none}a:hover{color:var(--text)}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
input,textarea,select{font:inherit;color:inherit}
i{font-family:'Material Symbols Rounded';font-style:normal;font-weight:normal;line-height:1;font-size:22px;display:inline-block;-webkit-font-smoothing:antialiased;font-feature-settings:'liga';user-select:none;flex-shrink:0}
::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:rgba(130,160,255,.18);border-radius:10px;border:3px solid transparent;background-clip:padding-box}
</style>
</helmet>
<div data-theme="{{ theme }}" data-shell="{{ shell }}" data-player="{{ playerV }}" style="height:100vh;background:var(--bg);color:var(--text);font-family:Onest,system-ui,sans-serif;position:relative;overflow:hidden">
{{ ambient }}
<sc-if value="{{ isAuth }}">
<div style="position:absolute;inset:0;z-index:1;display:flex;align-items:center;justify-content:center;overflow:hidden">
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);opacity:.55">
<x-import component="WaveButton" from="./wave.js" variant="{{ waveVariant }}" playing="{{ false }}" interactive="{{ false }}" theme="{{ theme }}" size="{{ 620 }}" hint-size="620px,620px"></x-import>
</div>
<div style="position:absolute;top:24px;left:28px;display:flex;align-items:center;gap:10px">
<div style="width:28px;height:28px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8);box-shadow:0 0 18px rgba(47,128,255,.55)"></div>
<div style="font-family:Unbounded;font-weight:600;font-size:19px;letter-spacing:-0.02em">pipu</div>
</div>
<div style="position:absolute;top:22px;right:24px;display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--glass);border:1px solid var(--line)">
<sc-for list="{{ langs }}" as="l" hint-placeholder-count="2">
<button onClick="{{ l.onClick }}" style="height:30px;padding:0 12px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button>
</sc-for>
</div>
<sc-if value="{{ auth.login }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;width:400px;max-width:calc(100% - 32px);display:flex;flex-direction:column;gap:20px;padding:36px;border-radius:24px;background:var(--glass);backdrop-filter:blur(28px) saturate(1.3);border:1px solid var(--line2);box-shadow:var(--shadow);box-sizing:border-box">
<div style="display:flex;flex-direction:column;gap:8px">
<div style="font-family:Unbounded;font-size:26px;font-weight:600;letter-spacing:-0.01em">Вход</div>
<div style="font-size:14px;line-height:1.5;color:var(--text2)">Учётные записи выдаёт администратор сервера</div>
</div>
<div style="display:flex;flex-direction:column;gap:14px">
<label style="display:flex;flex-direction:column;gap:6px">
<span style="font-size:13px;font-weight:500;color:var(--text2)">Логин</span>
<input value="{{ f.login }}" onChange="{{ fh.login }}" onKeyDown="{{ onLoginKey }}" autocomplete="username" style="height:48px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
</label>
<label style="display:flex;flex-direction:column;gap:6px">
<span style="font-size:13px;font-weight:500;color:var(--text2)">Пароль</span>
<div style="position:relative;display:flex">
<input type="{{ pwType }}" value="{{ f.pass }}" onChange="{{ fh.pass }}" onKeyDown="{{ onLoginKey }}" autocomplete="current-password" style="flex:1;height:48px;padding:0 48px 0 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
<button onClick="{{ toggleShowPw }}" aria-label="Показать пароль" style="position:absolute;right:6px;top:6px;width:36px;height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="color:var(--text)"><i style="font-size:20px">{{ pwIcon }}</i></button>
</div>
</label>
<sc-if value="{{ err.login }}">
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:10px;background:var(--danger-soft);color:var(--danger);font-size:13px;font-weight:500"><i style="font-size:18px">error</i>{{ err.login }}</div>
</sc-if>
</div>
<button onClick="{{ onLogin }}" style="height:48px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)">Войти</button>
<div style="font-size:12px;line-height:1.5;color:var(--text3);text-align:center">Демо: <span style="font-family:'JetBrains Mono';color:var(--text2)">admin / admin</span> — первый вход администратора, пароль <span style="font-family:'JetBrains Mono';color:var(--text2)">reset</span> — после сброса</div>
</div>
</sc-if>
<sc-if value="{{ auth.setup }}">
<div style="position:relative;width:440px;max-width:calc(100% - 32px);display:flex;flex-direction:column;gap:20px;padding:36px;border-radius:24px;background:var(--glass);backdrop-filter:blur(28px) saturate(1.3);border:1px solid var(--line2);box-shadow:var(--shadow);box-sizing:border-box">
<div style="display:flex;flex-direction:column;gap:10px">
<div style="align-self:flex-start;display:flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;background:var(--warn-soft);color:var(--warn);font-size:12px;font-weight:600"><i style="font-size:16px">{{ setupBadgeIcon }}</i>{{ setupBadge }}</div>
<div style="font-family:Unbounded;font-size:24px;font-weight:600;letter-spacing:-0.01em;text-wrap:pretty">{{ setupTitle }}</div>
<div style="font-size:14px;line-height:1.5;color:var(--text2);text-wrap:pretty">{{ setupText }}</div>
</div>
<div style="display:flex;flex-direction:column;gap:14px">
<sc-if value="{{ auth.setupAdmin }}">
<label style="display:flex;flex-direction:column;gap:6px">
<span style="font-size:13px;font-weight:500;color:var(--text2)">Новый логин</span>
<input value="{{ f.nlogin }}" onChange="{{ fh.nlogin }}" placeholder="не admin" style="height:48px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.nlogin }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
<sc-if value="{{ err.nlogin }}"><span style="font-size:12px;color:var(--danger)">{{ err.nlogin }}</span></sc-if>
</label>
</sc-if>
<label style="display:flex;flex-direction:column;gap:6px">
<span style="font-size:13px;font-weight:500;color:var(--text2)">Новый пароль</span>
<input type="password" value="{{ f.np }}" onChange="{{ fh.np }}" style="height:48px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.np }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
<div style="display:flex;align-items:center;gap:10px;margin-top:2px">
<div style="flex:1;display:grid;grid-template-columns:repeat(4,1fr);gap:4px">
<sc-for list="{{ strength.bars }}" as="b" hint-placeholder-count="4"><div style="height:4px;border-radius:2px;background:{{ b.bg }}"></div></sc-for>
</div>
<span style="font-size:12px;font-weight:500;color:{{ strength.color }};min-width:72px;text-align:right">{{ strength.label }}</span>
</div>
<sc-if value="{{ err.np }}"><span style="font-size:12px;color:var(--danger)">{{ err.np }}</span></sc-if>
</label>
<label style="display:flex;flex-direction:column;gap:6px">
<span style="font-size:13px;font-weight:500;color:var(--text2)">Подтверждение</span>
<input type="password" value="{{ f.np2 }}" onChange="{{ fh.np2 }}" style="height:48px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.np2 }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
<sc-if value="{{ err.np2 }}"><span style="font-size:12px;color:var(--danger)">{{ err.np2 }}</span></sc-if>
</label>
</div>
<button onClick="{{ onSetup }}" style="height:48px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)">Сохранить и продолжить</button>
<button onClick="{{ onLogout }}" style="height:20px;font-size:13px;color:var(--text3)" style-hover="color:var(--text)">Выйти</button>
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ isApp }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;z-index:1;height:100%;display:grid;grid-template-columns:var(--side-w) minmax(0,1fr);grid-template-rows:var(--rows);gap:var(--gap);padding:var(--pad);box-sizing:border-box">
<header style="grid-column:1/-1;grid-row:1;display:flex;align-items:center;gap:16px;padding:0 var(--top-px);border-bottom:var(--top-border);position:relative;z-index:30">
<div onClick="{{ nav.home }}" style="width:calc(var(--side-w) - 36px);display:flex;align-items:center;gap:10px;cursor:pointer;flex-shrink:0;padding-left:4px">
<div style="width:28px;height:28px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8);box-shadow:0 0 18px rgba(47,128,255,.55)"></div>
<div style="font-family:Unbounded;font-weight:600;font-size:19px;letter-spacing:-0.02em">pipu</div>
</div>
<div style="flex:1;max-width:620px;position:relative">
<div style="display:flex;align-items:center;gap:10px;height:42px;padding:0 8px 0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ sBorder }}">
<i style="font-size:20px;color:var(--text3)">search</i>
<input ref="{{ searchRef }}" value="{{ q }}" onChange="{{ onSearchInput }}" onFocus="{{ onSearchFocus }}" onBlur="{{ onSearchBlur }}" onKeyDown="{{ onSearchKey }}" placeholder="Исполнители, альбомы, треки" style="flex:1;min-width:0;height:100%;background:none;border:0;outline:none;font-size:15px">
<div style="height:24px;padding:0 7px;border-radius:6px;border:1px solid var(--line2);display:flex;align-items:center;font-family:'JetBrains Mono';font-size:12px;color:var(--text3)">/</div>
</div>
<sc-if value="{{ showDrop }}">
<div style="position:absolute;top:calc(100% + 8px);left:0;right:0;z-index:60;display:flex;flex-direction:column;gap:4px;padding:8px;background:var(--elev);border:1px solid var(--line2);border-radius:16px;box-shadow:var(--shadow);max-height:70vh;overflow:auto;animation:pipuIn .14s ease-out">
<sc-if value="{{ sFixed }}">
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:13px;color:var(--text2)"><i style="font-size:16px;color:var(--accent2)">keyboard</i>Исправили раскладку: «{{ sFixed }}»</div>
</sc-if>
<sc-if value="{{ sHas.artists }}">
<div style="padding:8px 10px 4px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Исполнители</div>
<sc-for list="{{ sArtists }}" as="it">
<div onMouseDown="{{ it.act }}" style="display:flex;align-items:center;gap:12px;padding:6px 10px;border-radius:10px;cursor:pointer;background:{{ it.bg }}" style-hover="background:var(--hover)">
<div style="width:40px;height:40px;border-radius:50%;background:{{ it.img }};display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:14px;color:#fff">{{ it.ini }}</div>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-size:15px;font-weight:500">{{ it.title }}</span><span style="font-size:13px;color:var(--text2)">{{ it.sub }}</span></div>
</div>
</sc-for>
</sc-if>
<sc-if value="{{ sHas.albums }}">
<div style="padding:8px 10px 4px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Альбомы</div>
<sc-for list="{{ sAlbums }}" as="it">
<div onMouseDown="{{ it.act }}" style="display:flex;align-items:center;gap:12px;padding:6px 10px;border-radius:10px;cursor:pointer;background:{{ it.bg }}" style-hover="background:var(--hover)">
<div style="width:40px;height:40px;border-radius:8px;background:{{ it.img }}"></div>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-size:15px;font-weight:500">{{ it.title }}</span><span style="font-size:13px;color:var(--text2)">{{ it.sub }}</span></div>
</div>
</sc-for>
</sc-if>
<sc-if value="{{ sHas.tracks }}">
<div style="padding:8px 10px 4px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Треки</div>
<sc-for list="{{ sTracks }}" as="it">
<div onMouseDown="{{ it.act }}" style="display:flex;align-items:center;gap:12px;padding:6px 10px;border-radius:10px;cursor:pointer;background:{{ it.bg }}" style-hover="background:var(--hover)">
<div style="width:40px;height:40px;border-radius:8px;background:{{ it.img }};display:flex;align-items:center;justify-content:center"><i style="font-size:20px;color:#fff;font-variation-settings:'FILL' 1;opacity:.9">play_arrow</i></div>
<div style="flex:1;display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-size:15px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ it.title }}</span><span style="font-size:13px;color:var(--text2)">{{ it.sub }}</span></div>
<span style="font-size:13px;color:var(--text3);font-variant-numeric:tabular-nums">{{ it.dur }}</span>
</div>
</sc-for>
</sc-if>
<sc-if value="{{ sNone }}">
<div style="padding:20px 10px;text-align:center;font-size:14px;color:var(--text2)">Ничего не нашлось</div>
</sc-if>
<div onMouseDown="{{ openSearch }}" style="display:flex;align-items:center;justify-content:space-between;margin-top:4px;padding:10px;border-top:1px solid var(--line);font-size:14px;font-weight:500;color:var(--accent2);cursor:pointer">
<span>Все результаты по «{{ q }}»</span>
<span style="font-family:'JetBrains Mono';font-size:12px;color:var(--text3)">Enter ↵</span>
</div>
</div>
</sc-if>
</div>
<div style="flex:1"></div>
<div style="position:relative">
<button onClick="{{ toggleProfile }}" style="display:flex;align-items:center;gap:10px;height:42px;padding:0 6px 0 6px;border-radius:12px" style-hover="background:var(--hover)">
<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(140deg,#2F80FF,#7C5CFF);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff">{{ me.ini }}</div>
<i style="font-size:20px;color:var(--text3)">expand_more</i>
</button>
<sc-if value="{{ profileOpen }}">
<div style="position:absolute;right:0;top:calc(100% + 8px);width:260px;display:flex;flex-direction:column;padding:8px;background:var(--elev);border:1px solid var(--line2);border-radius:16px;box-shadow:var(--shadow);animation:pipuIn .14s ease-out;z-index:60">
<div style="display:flex;flex-direction:column;gap:2px;padding:10px 12px 12px;border-bottom:1px solid var(--line);margin-bottom:6px">
<span style="font-weight:600;font-size:15px">{{ me.name }}</span>
<span style="font-size:13px;color:var(--text2)">{{ me.login }} · {{ me.role }}</span>
</div>
<button onClick="{{ nav.settings }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:10px;font-size:14px;text-align:left" style-hover="background:var(--hover)"><i style="font-size:20px;color:var(--text2)">settings</i>Профиль и настройки</button>
<button onClick="{{ toggleTheme }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:10px;font-size:14px;text-align:left" style-hover="background:var(--hover)"><i style="font-size:20px;color:var(--text2)">{{ themeIcon }}</i>{{ themeLabel }}</button>
<button onClick="{{ onLogout }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:10px;font-size:14px;text-align:left;color:var(--danger)" style-hover="background:var(--danger-soft)"><i style="font-size:20px">logout</i>Выйти</button>
</div>
</sc-if>
</div>
</header>
<nav style="grid-row:2;grid-column:1;display:flex;flex-direction:column;gap:2px;padding:12px;background:var(--panel-bg);backdrop-filter:var(--panel-blur);border-radius:var(--pr);border-right:var(--side-border);overflow:auto;box-sizing:border-box">
<sc-for list="{{ navTop }}" as="n" hint-placeholder-count="1">
<button onClick="{{ n.onClick }}" style="display:flex;align-items:center;gap:12px;height:42px;padding:0 12px;border-radius:12px;font-size:15px;font-weight:500;text-align:left;background:{{ n.bg }};color:{{ n.color }}" style-hover="background:var(--hover);color:var(--text)">
<i style="font-size:22px;color:{{ n.ic }};font-variation-settings:{{ n.fill }}">{{ n.icon }}</i><span style="flex:1">{{ n.label }}</span>
</button>
</sc-for>
<div style="height:12px"></div>
<div style="display:flex;align-items:center;gap:12px;height:32px;padding:0 12px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Коллекция</div>
<sc-for list="{{ navCol }}" as="n" hint-placeholder-count="3">
<button onClick="{{ n.onClick }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:12px;font-size:15px;font-weight:500;text-align:left;background:{{ n.bg }};color:{{ n.color }}" style-hover="background:var(--hover);color:var(--text)">
<i style="font-size:21px;color:{{ n.ic }};font-variation-settings:{{ n.fill }}">{{ n.icon }}</i><span style="flex:1">{{ n.label }}</span><span style="font-size:12px;color:var(--text3);font-variant-numeric:tabular-nums">{{ n.count }}</span>
</button>
</sc-for>
<sc-if value="{{ showAdmin }}" hint-placeholder-val="{{ true }}">
<div style="height:12px"></div>
<div style="display:flex;align-items:center;gap:12px;height:32px;padding:0 12px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Админка</div>
<sc-for list="{{ navAdm }}" as="n" hint-placeholder-count="5">
<button onClick="{{ n.onClick }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:12px;font-size:15px;font-weight:500;text-align:left;background:{{ n.bg }};color:{{ n.color }}" style-hover="background:var(--hover);color:var(--text)">
<i style="font-size:21px;color:{{ n.ic }};font-variation-settings:{{ n.fill }}">{{ n.icon }}</i><span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ n.label }}</span>
<sc-if value="{{ n.badge }}"><span style="min-width:22px;height:20px;padding:0 6px;border-radius:10px;background:var(--accent);color:#fff;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;box-sizing:border-box">{{ n.badge }}</span></sc-if>
</button>
</sc-for>
</sc-if>
</nav>
<div style="grid-row:2;grid-column:2;position:relative;border-radius:var(--pr);background:var(--panel-bg);backdrop-filter:var(--panel-blur);overflow:hidden;min-width:0">
<div ref="{{ mainRef }}" style="position:absolute;inset:0;overflow-y:auto;overflow-x:hidden">
<sc-if value="{{ pg.home }}" hint-placeholder-val="{{ true }}">
<sc-if value="{{ homeCenter }}" hint-placeholder-val="{{ true }}">
<div style="min-height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:44px;padding:48px 32px var(--cpb);box-sizing:border-box">
<x-import component="WaveButton" from="./wave.js" variant="{{ waveVariant }}" playing="{{ waveIsPlaying }}" bpm="{{ cur.bpm }}" energy="{{ cur.energy }}" color="{{ waveC.c1 }}" color2="{{ waveC.c2 }}" color3="{{ waveC.c3 }}" theme="{{ theme }}" size="{{ 340 }}" onClick="{{ onWave }}" ariaLabel="Моя волна" hint-size="340px,340px">
<i style="font-size:52px;color:#fff;font-variation-settings:'FILL' 1">{{ waveIcon }}</i>
<div style="font-family:Unbounded;font-weight:600;font-size:26px;color:#fff;margin-top:10px;letter-spacing:-0.01em">Моя волна</div>
<div style="font-size:14px;color:rgba(255,255,255,.78);margin-top:6px">{{ ctxLabel }}</div>
</x-import>
<div style="display:flex;flex-direction:column;gap:22px;align-items:center;max-width:860px">
<div style="display:flex;flex-direction:column;gap:10px;align-items:center">
<div style="font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Занятие</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center">
<sc-for list="{{ activities }}" as="c" hint-placeholder-count="7">
<button onClick="{{ c.onClick }}" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 16px 0 12px;border-radius:999px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }};box-shadow:{{ c.shadow }}" style-hover="border-color:var(--accent)"><i style="font-size:19px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
</sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;align-items:center">
<div style="font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Настроение</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center">
<sc-for list="{{ moods }}" as="c" hint-placeholder-count="4">
<button onClick="{{ c.onClick }}" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 16px 0 12px;border-radius:999px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }};box-shadow:{{ c.shadow }}" style-hover="border-color:var(--accent)"><i style="font-size:19px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
</sc-for>
</div>
</div>
</div>
<sc-if value="{{ waveOn }}">
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--text2)">
<div style="width:6px;height:6px;border-radius:50%;background:var(--accent2);box-shadow:0 0 10px var(--accent)"></div>
<span>Сейчас в волне: <span style="color:var(--text);font-weight:500">{{ cur.title }}</span> — {{ cur.artist }}</span>
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ homeSplit }}">
<div style="min-height:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));align-items:center;gap:40px 56px;padding:48px 56px var(--cpb);box-sizing:border-box;max-width:1280px;margin:0 auto">
<div style="display:flex;align-items:center;justify-content:center;min-height:480px">
<x-import component="WaveButton" from="./wave.js" variant="{{ waveVariant }}" playing="{{ waveIsPlaying }}" bpm="{{ cur.bpm }}" energy="{{ cur.energy }}" color="{{ waveC.c1 }}" color2="{{ waveC.c2 }}" color3="{{ waveC.c3 }}" theme="{{ theme }}" size="{{ 400 }}" onClick="{{ onWave }}" ariaLabel="Моя волна" hint-size="400px,400px">
<i style="font-size:64px;color:#fff;font-variation-settings:'FILL' 1">{{ waveIcon }}</i>
</x-import>
</div>
<div style="display:flex;flex-direction:column;gap:28px;min-width:0">
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font-family:Unbounded;font-size:52px;font-weight:600;letter-spacing:-0.03em;line-height:1.05">Моя волна</div>
<div style="font-size:17px;line-height:1.5;color:var(--text2);max-width:460px;text-wrap:pretty">Бесконечный поток под ваш вкус. Учится на лайках, пропусках и звучании треков — выберите, что вы делаете, и волна подстроится.</div>
</div>
<sc-if value="{{ waveOn }}">
<div style="display:flex;align-items:center;gap:14px;padding:12px;border-radius:16px;background:var(--surface2);border:1px solid var(--line)">
<div style="width:52px;height:52px;border-radius:10px;background:{{ cur.cover }}"></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px"><span style="font-weight:600;font-size:15px">{{ cur.title }}</span><span style="font-size:13px;color:var(--text2)">{{ cur.artist }} · {{ ctxLabel }}</span></div>
<button onClick="{{ next }}" style="width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover);color:var(--text)"><i style="font-variation-settings:'FILL' 1">skip_next</i></button>
</div>
</sc-if>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Занятие</span>
<sc-if value="{{ hasCtx }}"><button onClick="{{ clearCtx }}" style="font-size:13px;color:var(--accent2)" style-hover="color:var(--text)">Сбросить — просто под меня</button></sc-if>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));gap:8px">
<sc-for list="{{ activities }}" as="c" hint-placeholder-count="7">
<button onClick="{{ c.onClick }}" style="display:flex;flex-direction:column;align-items:flex-start;gap:14px;padding:14px;border-radius:14px;font-size:14px;font-weight:500;text-align:left;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }};box-shadow:{{ c.shadow }}" style-hover="border-color:var(--accent)"><i style="font-size:24px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
</sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<span style="font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Настроение</span>
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px">
<sc-for list="{{ moods }}" as="c" hint-placeholder-count="4">
<button onClick="{{ c.onClick }}" style="display:flex;align-items:center;justify-content:center;gap:8px;height:44px;padding:0 10px;border-radius:12px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }};box-shadow:{{ c.shadow }}" style-hover="border-color:var(--accent)"><i style="font-size:19px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
</sc-for>
</div>
</div>
</div>
</div>
</sc-if>
</sc-if>
<sc-if value="{{ pg.search }}">
<div style="display:flex;flex-direction:column;gap:28px;padding:32px 36px var(--cpb);max-width:1200px">
<div style="display:flex;flex-direction:column;gap:6px">
<div style="font-size:14px;color:var(--text2)">Результаты поиска</div>
<div style="font-family:Unbounded;font-size:32px;font-weight:600;letter-spacing:-0.02em">«{{ sp.q }}»</div>
<sc-if value="{{ sp.fixed }}"><div style="font-size:14px;color:var(--text2)">Показаны результаты для «{{ sp.fixed }}» — запрос был набран в другой раскладке</div></sc-if>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<sc-for list="{{ sp.tabs }}" as="t" hint-placeholder-count="4">
<button onClick="{{ t.onClick }}" style="height:36px;padding:0 14px;border-radius:999px;font-size:14px;font-weight:500;background:{{ t.bg }};color:{{ t.color }};border:1px solid {{ t.border }}">{{ t.label }} <span style="opacity:.6">{{ t.count }}</span></button>
</sc-for>
</div>
<sc-if value="{{ sp.showArtists }}">
<div style="display:flex;flex-direction:column;gap:14px">
<div style="font-size:20px;font-weight:600">Исполнители</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px">
<sc-for list="{{ sp.artists }}" as="a">
<div onClick="{{ a.onClick }}" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border-radius:16px;cursor:pointer;text-align:center" style-hover="background:var(--hover)">
<div style="width:128px;height:128px;border-radius:50%;background:{{ a.img }};display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:34px;color:#fff">{{ a.ini }}</div>
<div style="display:flex;flex-direction:column;gap:3px"><span style="font-weight:600;font-size:15px">{{ a.name }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div>
</div>
</sc-for>
</div>
</div>
</sc-if>
<sc-if value="{{ sp.showAlbums }}">
<div style="display:flex;flex-direction:column;gap:14px">
<div style="font-size:20px;font-weight:600">Альбомы</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:16px">
<sc-for list="{{ sp.albums }}" as="a">
<div onClick="{{ a.onClick }}" style="display:flex;flex-direction:column;gap:10px;padding:10px;border-radius:16px;cursor:pointer" style-hover="background:var(--hover)">
<div style="aspect-ratio:1;border-radius:12px;background:{{ a.img }}"></div>
<div style="display:flex;flex-direction:column;gap:3px;min-width:0"><span style="font-weight:600;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ a.title }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div>
</div>
</sc-for>
</div>
</div>
</sc-if>
<sc-if value="{{ sp.showTracks }}">
<div style="display:flex;flex-direction:column;gap:8px">
<div style="font-size:20px;font-weight:600;margin-bottom:6px">Треки</div>
<sc-for list="{{ sp.tracks }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:grid;grid-template-columns:32px 44px minmax(0,1.4fr) minmax(0,1fr) 40px 52px;align-items:center;gap:14px;padding:6px 12px;border-radius:12px;cursor:pointer" style-hover="background:var(--hover)">
<div style="display:flex;justify-content:center;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
<div style="width:44px;height:44px;border-radius:8px;background:{{ r.cover }}"></div>
<div style="min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span onClick="{{ r.onArtist }}" style="font-size:13px;color:var(--text2);align-self:flex-start" style-hover="color:var(--text);text-decoration:underline">{{ r.artist }}</span></div>
<span onClick="{{ r.onAlbum }}" style="font-size:14px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" style-hover="color:var(--text)">{{ r.album }}</span>
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
<span style="text-align:right;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ sp.none }}">
<div style="padding:64px 0;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--text2)"><i style="font-size:40px;color:var(--text3)">search_off</i><span style="font-size:16px">Ничего не нашлось. Попробуйте другое написание.</span></div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ pg.artist }}">
<div style="display:flex;flex-direction:column">
<div style="position:relative;padding:56px 36px 32px;background:{{ ap.banner }};display:flex;align-items:flex-end;gap:28px;flex-wrap:wrap">
<div style="width:200px;height:200px;border-radius:50%;background:{{ ap.img }};box-shadow:0 20px 60px rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:56px;color:#fff;flex-shrink:0">{{ ap.ini }}</div>
<div style="display:flex;flex-direction:column;gap:12px;min-width:0;padding-bottom:6px">
<span style="font-size:13px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text2)">Исполнитель · {{ ap.genre }}</span>
<div style="font-family:Unbounded;font-size:60px;font-weight:600;letter-spacing:-0.03em;line-height:1">{{ ap.name }}</div>
<span style="font-size:14px;color:var(--text2)">{{ ap.meta }}</span>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:32px;padding:8px 36px var(--cpb)">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<button onClick="{{ ap.onWave }}" style="display:flex;align-items:center;gap:10px;height:48px;padding:0 22px 0 16px;border-radius:999px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)"><i style="font-size:22px">graphic_eq</i>Волна по исполнителю</button>
<button onClick="{{ ap.onPlay }}" style="display:flex;align-items:center;gap:8px;height:48px;padding:0 20px 0 14px;border-radius:999px;background:var(--surface2);border:1px solid var(--line2);font-weight:600;font-size:15px" style-hover="background:var(--surface3)"><i style="font-variation-settings:'FILL' 1">play_arrow</i>Слушать</button>
<button onClick="{{ ap.onShuffle }}" aria-label="Перемешать" style="width:48px;height:48px;border-radius:50%;background:var(--surface2);border:1px solid var(--line2);display:flex;align-items:center;justify-content:center" style-hover="background:var(--surface3)"><i>shuffle</i></button>
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<div style="font-size:20px;font-weight:600;margin-bottom:10px">Популярные треки</div>
<sc-for list="{{ ap.tracks }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:grid;grid-template-columns:32px 44px minmax(0,1.4fr) minmax(0,1fr) 40px 52px;align-items:center;gap:14px;padding:6px 12px;border-radius:12px;cursor:pointer" style-hover="background:var(--hover)">
<div style="display:flex;justify-content:center;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
<div style="width:44px;height:44px;border-radius:8px;background:{{ r.cover }}"></div>
<span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span>
<span onClick="{{ r.onAlbum }}" style="font-size:14px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" style-hover="color:var(--text)">{{ r.album }}</span>
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
<span style="text-align:right;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
</div>
</sc-for>
</div>
<div style="display:flex;flex-direction:column;gap:14px">
<div style="font-size:20px;font-weight:600">Альбомы</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px">
<sc-for list="{{ ap.albums }}" as="a">
<div onClick="{{ a.onClick }}" style="display:flex;flex-direction:column;gap:10px;padding:10px;border-radius:16px;cursor:pointer" style-hover="background:var(--hover)">
<div style="aspect-ratio:1;border-radius:12px;background:{{ a.img }};box-shadow:0 12px 32px rgba(0,0,0,.3)"></div>
<div style="display:flex;flex-direction:column;gap:3px;min-width:0"><span style="font-weight:600;font-size:15px">{{ a.title }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div>
</div>
</sc-for>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.album }}">
<div style="display:flex;flex-direction:column">
<div style="padding:48px 36px 28px;background:{{ alp.tint }};display:flex;align-items:flex-end;gap:28px;flex-wrap:wrap">
<div style="width:232px;height:232px;border-radius:16px;background:{{ alp.cover }};box-shadow:0 24px 64px rgba(0,0,0,.5);flex-shrink:0"></div>
<div style="display:flex;flex-direction:column;gap:12px;min-width:0;padding-bottom:4px">
<span style="font-size:13px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text2)">Альбом</span>
<div style="font-family:Unbounded;font-size:48px;font-weight:600;letter-spacing:-0.03em;line-height:1.05;text-wrap:balance">{{ alp.title }}</div>
<div style="display:flex;align-items:center;gap:8px;font-size:14px;color:var(--text2);flex-wrap:wrap">
<div style="width:24px;height:24px;border-radius:50%;background:{{ alp.artistImg }}"></div>
<span onClick="{{ alp.onArtist }}" style="color:var(--text);font-weight:600;cursor:pointer" style-hover="text-decoration:underline">{{ alp.artist }}</span>
<span>· {{ alp.year }} · {{ alp.meta }}</span>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:20px;padding:8px 36px var(--cpb)">
<div style="display:flex;align-items:center;gap:10px">
<button onClick="{{ alp.onPlay }}" aria-label="Слушать" style="width:56px;height:56px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)"><i style="font-size:32px;font-variation-settings:'FILL' 1">{{ alp.playIcon }}</i></button>
<button onClick="{{ alp.onShuffle }}" aria-label="Перемешать" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover);color:var(--text)"><i>shuffle</i></button>
</div>
<div style="display:flex;flex-direction:column">
<div style="display:grid;grid-template-columns:32px minmax(0,1fr) 40px 52px;gap:14px;padding:0 12px 10px;border-bottom:1px solid var(--line);font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)"><span style="text-align:center">#</span><span>Название</span><span></span><span style="text-align:right"><i style="font-size:16px">schedule</i></span></div>
<div style="height:8px"></div>
<sc-for list="{{ alp.tracks }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:grid;grid-template-columns:32px minmax(0,1fr) 40px 52px;align-items:center;gap:14px;height:52px;padding:0 12px;border-radius:12px;cursor:pointer" style-hover="background:var(--hover)">
<div style="display:flex;justify-content:center;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
<div style="min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="font-size:13px;color:var(--text2)">{{ r.artist }}</span></div>
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
<span style="text-align:right;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
</div>
</sc-for>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.collection }}">
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:1200px">
<div style="display:flex;align-items:flex-end;gap:24px;flex-wrap:wrap">
<div style="width:120px;height:120px;border-radius:20px;background:{{ col.tile }};display:flex;align-items:center;justify-content:center;box-shadow:0 16px 40px rgba(0,0,0,.3)"><i style="font-size:52px;color:#fff;font-variation-settings:'FILL' 1">{{ col.icon }}</i></div>
<div style="display:flex;flex-direction:column;gap:8px;flex:1;min-width:240px">
<span style="font-size:13px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text2)">Коллекция</span>
<div style="font-family:Unbounded;font-size:40px;font-weight:600;letter-spacing:-0.02em;line-height:1">{{ col.title }}</div>
<span style="font-size:14px;color:var(--text2);text-wrap:pretty">{{ col.sub }}</span>
</div>
<sc-if value="{{ col.canPlay }}">
<div style="display:flex;gap:10px">
<button onClick="{{ col.onPlay }}" style="display:flex;align-items:center;gap:8px;height:44px;padding:0 20px 0 14px;border-radius:999px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)"><i style="font-variation-settings:'FILL' 1">play_arrow</i>Слушать всё</button>
<button onClick="{{ col.onShuffle }}" style="display:flex;align-items:center;gap:8px;height:44px;padding:0 18px 0 14px;border-radius:999px;background:var(--surface2);border:1px solid var(--line2);font-weight:600;font-size:15px" style-hover="background:var(--surface3)"><i>shuffle</i>Перемешать</button>
</div>
</sc-if>
</div>
<div style="display:flex;gap:6px;border-bottom:1px solid var(--line)">
<sc-for list="{{ col.tabs }}" as="t" hint-placeholder-count="3">
<button onClick="{{ t.onClick }}" style="height:42px;padding:0 14px;font-size:15px;font-weight:500;color:{{ t.color }};border-bottom:2px solid {{ t.border }};margin-bottom:-1px">{{ t.label }}</button>
</sc-for>
</div>
<sc-for list="{{ col.groups }}" as="g">
<div style="display:flex;flex-direction:column;gap:2px">
<sc-if value="{{ g.day }}"><div style="font-size:14px;font-weight:600;color:var(--text2);padding:8px 12px">{{ g.day }}</div></sc-if>
<sc-for list="{{ g.rows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:grid;grid-template-columns:44px 44px minmax(0,1.4fr) minmax(0,1fr) auto 52px;align-items:center;gap:14px;padding:6px 12px;border-radius:12px;cursor:pointer" style-hover="background:var(--hover)">
<div style="display:flex;justify-content:center;font-size:13px;color:var(--text3);font-variant-numeric:tabular-nums"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
<div style="width:44px;height:44px;border-radius:8px;background:{{ r.cover }};opacity:{{ r.op }}"></div>
<div style="min-width:0;display:flex;flex-direction:column;gap:2px;opacity:{{ r.op }}"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span onClick="{{ r.onArtist }}" style="font-size:13px;color:var(--text2);align-self:flex-start" style-hover="color:var(--text);text-decoration:underline">{{ r.artist }}</span></div>
<span onClick="{{ r.onAlbum }}" style="font-size:14px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:{{ r.op }}" style-hover="color:var(--text)">{{ r.album }}</span>
<sc-if value="{{ col.isDis }}">
<button onClick="{{ r.onUndo }}" style="display:flex;align-items:center;gap:6px;height:34px;padding:0 12px;border-radius:999px;border:1px solid var(--line2);font-size:13px;font-weight:500;color:var(--text2)" style-hover="color:var(--text);border-color:var(--accent)"><i style="font-size:18px">undo</i>Убрать дизлайк</button>
</sc-if>
<sc-if value="{{ col.notDis }}">
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
</sc-if>
<span style="text-align:right;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
</div>
</sc-for>
</div>
</sc-for>
<sc-if value="{{ col.empty }}">
<div style="padding:56px 0;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--text2)"><i style="font-size:40px;color:var(--text3)">{{ col.icon }}</i><span style="font-size:16px">{{ col.emptyText }}</span></div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ pg.settings }}">
<div style="display:flex;flex-direction:column;gap:28px;padding:32px 36px var(--cpb);max-width:760px">
<div style="font-family:Unbounded;font-size:32px;font-weight:600;letter-spacing:-0.02em">Профиль и настройки</div>
<div style="display:flex;align-items:center;gap:16px;padding:20px;border-radius:20px;background:var(--surface2);border:1px solid var(--line)">
<div style="width:56px;height:56px;border-radius:50%;background:linear-gradient(140deg,#2F80FF,#7C5CFF);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:#fff">{{ me.ini }}</div>
<div style="flex:1;display:flex;flex-direction:column;gap:3px"><span style="font-weight:600;font-size:17px">{{ me.name }}</span><span style="font-size:14px;color:var(--text2)">Логин: {{ me.login }}</span></div>
<span style="height:28px;padding:0 12px;border-radius:999px;background:var(--accent-soft);color:var(--accent2);font-size:13px;font-weight:600;display:flex;align-items:center">{{ me.role }}</span>
</div>
<div style="display:flex;flex-direction:column;gap:14px">
<div style="font-size:18px;font-weight:600">Смена пароля</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Текущий пароль</span><input type="password" value="{{ f.cur }}" onChange="{{ fh.cur }}" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.cur }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)"></label>
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Новый пароль</span><input type="password" value="{{ f.sp }}" onChange="{{ fh.sp }}" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.sp }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)"></label>
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Ещё раз</span><input type="password" value="{{ f.sp2 }}" onChange="{{ fh.sp2 }}" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.sp2 }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)"></label>
</div>
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<button onClick="{{ onChangePw }}" style="height:42px;padding:0 18px;border-radius:12px;background:var(--surface3);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)">Сменить пароль</button>
<sc-if value="{{ err.sp }}"><span style="font-size:13px;color:var(--danger)">{{ err.sp }}</span></sc-if>
</div>
</div>
<div style="display:flex;flex-direction:column;border-radius:20px;border:1px solid var(--line);overflow:hidden">
<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;border-bottom:1px solid var(--line)">
<i style="color:var(--text2)">translate</i>
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Язык интерфейса</span><span style="font-size:13px;color:var(--text2)">Словари ru / en, можно добавить другие</span></div>
<div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2)">
<sc-for list="{{ langs }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 14px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.full }}</button></sc-for>
</div>
</div>
<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;border-bottom:1px solid var(--line)">
<i style="color:var(--text2)">contrast</i>
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Тема</span></div>
<div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2)">
<sc-for list="{{ themes }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 14px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for>
</div>
</div>
<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;border-bottom:1px solid var(--line)">
<i style="color:var(--text2)">blur_linear</i>
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Кроссфейд</span><span style="font-size:13px;color:var(--text2)">Плавный переход между треками</span></div>
<input type="range" min="0" max="6" step="1" value="{{ crossfade }}" onChange="{{ onCrossfade }}" style="width:160px;accent-color:var(--accent)">
<span style="width:36px;text-align:right;font-size:14px;color:var(--text2);font-variant-numeric:tabular-nums">{{ crossfade }} с</span>
</div>
<div onClick="{{ toggleNormalize }}" style="display:flex;align-items:center;gap:16px;padding:16px 20px;cursor:pointer">
<i style="color:var(--text2)">equalizer</i>
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Выравнивание громкости</span><span style="font-size:13px;color:var(--text2)">По EBU R128, без перепадов между треками</span></div>
<div style="width:44px;height:26px;border-radius:13px;background:{{ normT.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:3px;left:{{ normT.x }};width:20px;height:20px;border-radius:50%;background:#fff;transition:left .2s;box-shadow:0 1px 3px rgba(0,0,0,.3)"></div></div>
</div>
</div>
<button onClick="{{ onLogout }}" style="align-self:flex-start;display:flex;align-items:center;gap:8px;height:42px;padding:0 16px;border-radius:12px;color:var(--danger);font-weight:600;font-size:14px;border:1px solid var(--danger-soft)" style-hover="background:var(--danger-soft)"><i style="font-size:20px">logout</i>Выйти из аккаунта</button>
</div>
</sc-if>
<sc-if value="{{ pg.users }}">
<div onClick="{{ closeMenus }}" style="display:flex;flex-direction:column;gap:20px;padding:32px 36px var(--cpb);max-width:1100px;min-height:100%;box-sizing:border-box">
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Пользователи</div><div style="font-size:14px;color:var(--text2)">{{ uu.hint }}</div></div>
<button onClick="{{ uu.onCreate }}" style="display:flex;align-items:center;gap:8px;height:44px;padding:0 18px 0 14px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)"><i style="font-size:20px">person_add</i>Создать пользователя</button>
</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:8px;height:40px;padding:0 12px;border-radius:12px;background:var(--surface2);border:1px solid var(--line);flex:1;min-width:220px;max-width:340px"><i style="font-size:19px;color:var(--text3)">search</i><input value="{{ uu.q }}" onChange="{{ uu.onQ }}" placeholder="Имя или логин" style="flex:1;min-width:0;background:none;border:0;outline:none;font-size:14px"></div>
<div style="display:flex;gap:2px;padding:3px;border-radius:12px;background:var(--surface2);border:1px solid var(--line)">
<sc-for list="{{ uu.tabs }}" as="t" hint-placeholder-count="4"><button onClick="{{ t.onClick }}" style="height:32px;padding:0 12px;border-radius:9px;font-size:13px;font-weight:500;background:{{ t.bg }};color:{{ t.color }}">{{ t.label }} <span style="opacity:.55">{{ t.count }}</span></button></sc-for>
</div>
</div>
<div style="overflow-x:auto;border:1px solid var(--line);border-radius:18px;background:var(--surface-a)"><div style="display:flex;flex-direction:column;min-width:660px">
<div style="display:grid;grid-template-columns:minmax(200px,2fr) minmax(110px,1fr) minmax(120px,1fr) minmax(90px,.8fr) 44px;gap:16px;align-items:center;padding:12px 20px;border-bottom:1px solid var(--line);font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)"><span>Пользователь</span><span>Роль</span><span>Статус</span><span>Был в сети</span><span></span></div>
<sc-for list="{{ uu.rows }}" as="u">
<div style="display:grid;grid-template-columns:minmax(200px,2fr) minmax(110px,1fr) minmax(120px,1fr) minmax(90px,.8fr) 44px;gap:16px;align-items:center;padding:12px 20px;border-bottom:1px solid var(--line);position:relative">
<div style="display:flex;align-items:center;gap:12px;min-width:0;opacity:{{ u.op }}">
<div style="width:38px;height:38px;border-radius:50%;background:var(--surface3);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:var(--text2);flex-shrink:0">{{ u.ini }}</div>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;font-size:15px">{{ u.name }}</span><span style="font-size:13px;color:var(--text3);font-family:'JetBrains Mono'">{{ u.login }}</span></div>
</div>
<span style="justify-self:start;height:26px;padding:0 10px;border-radius:999px;background:{{ u.roleBg }};color:{{ u.roleColor }};font-size:12px;font-weight:600;display:flex;align-items:center">{{ u.roleLabel }}</span>
<span style="display:flex;align-items:center;gap:8px;font-size:14px;color:var(--text2)"><span style="width:7px;height:7px;border-radius:50%;background:{{ u.stColor }}"></span>{{ u.stLabel }}</span>
<span style="font-size:14px;color:var(--text2)">{{ u.last }}</span>
<sc-if value="{{ u.canManage }}">
<button onClick="{{ u.onMenu }}" aria-label="Действия" style="width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover);color:var(--text)"><i>more_horiz</i></button>
</sc-if>
<sc-if value="{{ u.menuOpen }}">
<div style="position:absolute;right:20px;top:calc(100% - 6px);z-index:30;width:240px;display:flex;flex-direction:column;padding:6px;background:var(--elev);border:1px solid var(--line2);border-radius:14px;box-shadow:var(--shadow);animation:pipuIn .14s ease-out">
<sc-for list="{{ u.acts }}" as="a"><button onClick="{{ a.onClick }}" style="display:flex;align-items:center;gap:10px;height:38px;padding:0 10px;border-radius:9px;font-size:14px;text-align:left;color:{{ a.color }}" style-hover="background:var(--hover)"><i style="font-size:19px">{{ a.icon }}</i>{{ a.label }}</button></sc-for>
</div>
</sc-if>
</div>
</sc-for>
</div></div>
</div>
</sc-if>
<sc-if value="{{ pg.sources }}">
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:1180px">
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Хранилища</div><div style="font-size:14px;color:var(--text2)">Откуда берётся музыка и как из путей извлекаются данные о треках</div></div>
<button style="display:flex;align-items:center;gap:8px;height:44px;padding:0 18px 0 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)"><i style="font-size:20px">add</i>Добавить хранилище</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px">
<sc-for list="{{ src.list }}" as="x" hint-placeholder-count="3">
<div onClick="{{ x.onClick }}" style="display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:16px;background:{{ x.bg }};border:1px solid {{ x.border }};cursor:pointer">
<div style="display:flex;align-items:center;gap:10px"><i style="color:var(--text2)">hard_drive</i><span style="flex:1;font-weight:600;font-size:15px">{{ x.name }}</span><span style="display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:{{ x.stColor }}"><span style="width:7px;height:7px;border-radius:50%;background:{{ x.stColor }}"></span>{{ x.stLabel }}</span></div>
<span style="font-family:'JetBrains Mono';font-size:12px;color:var(--text2);word-break:break-all">{{ x.addr }}</span>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--text3)"><span>{{ x.typeLabel }}</span><span>·</span><span>{{ x.tracks }}</span><sc-if value="{{ x.accepts }}"><span style="height:20px;padding:0 8px;border-radius:6px;background:var(--accent-soft);color:var(--accent2);font-weight:600;display:flex;align-items:center">принимает загрузки</span></sc-if></div>
<sc-if value="{{ x.err }}"><span style="font-size:12px;line-height:1.4;color:var(--danger)">{{ x.err }}</span></sc-if>
</div>
</sc-for>
</div>
<div style="display:flex;flex-direction:column;gap:20px;padding:24px;border-radius:20px;border:1px solid var(--line);background:var(--surface-a)">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<input value="{{ src.name }}" onChange="{{ src.onName }}" style="flex:1;min-width:200px;font-size:20px;font-weight:600;background:transparent;border:1px solid transparent;border-radius:8px;padding:4px 8px;margin-left:-9px;outline:none" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent)">
<div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2)"><sc-for list="{{ src.types }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 12px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for></div>
</div>
<sc-if value="{{ src.isNfs }}">
<div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) minmax(0,1.4fr);gap:12px">
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Адрес сервера</span><input value="{{ src.server }}" onChange="{{ src.onServer }}" style="height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)"></label>
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Путь экспорта</span><input value="{{ src.exp }}" onChange="{{ src.onExp }}" style="height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)"></label>
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Опции монтирования</span><input value="{{ src.opts }}" onChange="{{ src.onOpts }}" style="height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)"></label>
</div>
</sc-if>
<sc-if value="{{ src.isLocal }}">
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Подпапка в /srv/pipu/library-host</span><input value="{{ src.path }}" onChange="{{ src.onPath }}" style="height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)"></label>
</sc-if>
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
<button onClick="{{ src.onTest }}" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 16px 0 12px;border-radius:10px;background:var(--surface3);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)"><i style="font-size:19px">lan</i>{{ src.testLabel }}</button>
<div onClick="{{ src.onAcc }}" style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:14px"><div style="width:44px;height:26px;border-radius:13px;background:{{ src.acc.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:3px;left:{{ src.acc.x }};width:20px;height:20px;border-radius:50%;background:#fff;transition:left .2s"></div></div>Принимает новые треки (монтируется на запись)</div>
</div>
<sc-if value="{{ src.testRes }}">
<div style="display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:12px;background:{{ src.testRes.b }};color:{{ src.testRes.c }};font-size:14px;font-weight:500"><i style="font-size:20px">{{ src.testRes.icon }}</i>{{ src.testRes.text }}</div>
</sc-if>
<div style="height:1px;background:var(--line)"></div>
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div style="display:flex;flex-direction:column;gap:4px"><span style="font-size:17px;font-weight:600">Шаблоны путей</span><span style="font-size:13px;color:var(--text2)">Регулярные выражения RE2 с именованными группами. Срабатывает первое совпавшее — порядок меняется перетаскиванием.</span></div>
<div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2)"><sc-for list="{{ src.tags }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 12px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for></div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<sc-for list="{{ src.patterns }}" as="p" hint-placeholder-count="4">
<div draggable="true" onDragStart="{{ p.onDragStart }}" onDragOver="{{ p.onDragOver }}" onDrop="{{ p.onDrop }}" onDragEnd="{{ p.onDragEnd }}" style="display:flex;align-items:flex-start;gap:10px">
<div style="display:flex;align-items:center;gap:4px;height:42px;color:var(--text3);cursor:grab"><i style="font-size:20px">drag_indicator</i><span style="font-family:'JetBrains Mono';font-size:12px;width:20px">#{{ p.n }}</span></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px">
<div style="position:relative;border-radius:10px;background:var(--surface2);border:1px solid {{ p.border }}">
<div aria-hidden="true" style="padding:10px 12px;font-family:'JetBrains Mono';font-size:13px;line-height:20px;white-space:pre-wrap;word-break:break-all;min-height:20px"><sc-for list="{{ p.tokens }}" as="k"><span style="color:{{ k.c }};font-weight:{{ k.w }}">{{ k.t }}</span></sc-for></div>
<textarea value="{{ p.text }}" onChange="{{ p.onChange }}" spellcheck="false" style="position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;padding:10px 12px;margin:0;border:0;outline:none;resize:none;overflow:hidden;background:transparent;color:transparent;caret-color:var(--text);font-family:'JetBrains Mono';font-size:13px;line-height:20px;white-space:pre-wrap;word-break:break-all"></textarea>
</div>
<sc-if value="{{ p.err }}"><span style="font-size:12px;color:var(--danger);font-family:'JetBrains Mono'">{{ p.err }}</span></sc-if>
</div>
<button onClick="{{ p.onDel }}" aria-label="Удалить" style="width:42px;height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--danger-soft);color:var(--danger)"><i style="font-size:20px">delete</i></button>
</div>
</sc-for>
<button onClick="{{ src.onAddPattern }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;height:36px;padding:0 12px;margin-left:56px;border-radius:10px;font-size:14px;font-weight:500;color:var(--accent2)" style-hover="background:var(--accent-soft)"><i style="font-size:19px">add</i>Добавить выражение</button>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px"><span style="font-size:15px;font-weight:600">Предпросмотр на реальных файлах</span><span style="font-size:13px;color:var(--text2)">{{ src.prevSummary }}</span></div>
<div style="display:flex;flex-direction:column;border:1px solid var(--line);border-radius:14px;overflow:hidden">
<sc-for list="{{ src.prev }}" as="r">
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 14px;border-bottom:1px solid var(--line)">
<div style="display:flex;align-items:flex-start;gap:12px">
<div style="flex:1;min-width:0;font-family:'JetBrains Mono';font-size:13px;line-height:20px;word-break:break-all"><sc-for list="{{ r.segs }}" as="g"><span style="color:{{ g.c }}">{{ g.t }}</span></sc-for></div>
<span style="flex-shrink:0;height:22px;padding:0 8px;border-radius:6px;background:{{ r.bb }};color:{{ r.bc }};font-size:12px;font-weight:600;display:flex;align-items:center">{{ r.badge }}</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<sc-for list="{{ r.fields }}" as="fl"><span style="display:flex;align-items:center;gap:6px;height:24px;padding:0 8px;border-radius:6px;background:var(--surface2);font-size:12px"><span style="color:{{ fl.c }};font-weight:600">{{ fl.k }}</span><span style="color:var(--text)">{{ fl.v }}</span></span></sc-for>
</div>
</div>
</sc-for>
</div>
</div>
<div style="height:1px;background:var(--line)"></div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;flex-direction:column;gap:4px"><span style="font-size:17px;font-weight:600">Шаблон раскладки новых треков</span><span style="font-size:13px;color:var(--text2)">По нему одобренные файлы записываются в хранилище — так же, как уже разложена музыка</span></div>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<input value="{{ src.tpl }}" onChange="{{ src.onTpl }}" spellcheck="false" style="flex:1;min-width:280px;height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)">
<button onClick="{{ src.onAutoTpl }}" style="display:flex;align-items:center;gap:6px;height:42px;padding:0 14px 0 10px;border-radius:10px;background:var(--surface3);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)"><i style="font-size:19px">auto_fix_high</i>Подобрать автоматически</button>
<button onClick="{{ src.onCheckTpl }}" style="display:flex;align-items:center;gap:6px;height:42px;padding:0 14px 0 10px;border-radius:10px;background:var(--surface3);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)"><i style="font-size:19px">fact_check</i>Проверить</button>
</div>
<sc-if value="{{ src.checked }}">
<div style="display:flex;flex-direction:column;gap:8px;padding:14px;border-radius:14px;background:{{ src.chkBg }}">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:{{ src.chkColor }}">{{ src.chkSummary }}</span><span style="font-size:13px;color:var(--text2)">{{ src.chkNote }}</span></div>
<sc-for list="{{ src.chk }}" as="c">
<div style="display:grid;grid-template-columns:20px minmax(0,1fr);gap:8px;align-items:start;font-family:'JetBrains Mono';font-size:12px;line-height:18px">
<i style="font-size:18px;color:{{ c.c }}">{{ c.icon }}</i>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;word-break:break-all"><span style="color:var(--text2)">{{ c.real }}</span><span style="color:{{ c.c }}">→ {{ c.gen }}</span></div>
</div>
</sc-for>
</div>
</sc-if>
</div>
<button onClick="{{ src.onSave }}" style="align-self:flex-start;height:44px;padding:0 20px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)">Сохранить и пересканировать</button>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.library }}">
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:1180px">
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Библиотека</div><div style="font-size:14px;color:var(--text2)">Состояние каталога, сканирования и аудиоанализа</div></div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
<sc-for list="{{ lib.stats }}" as="x" hint-placeholder-count="4">
<div style="display:flex;flex-direction:column;gap:6px;padding:18px;border-radius:16px;background:var(--surface-a);border:1px solid var(--line)"><span style="font-size:13px;color:var(--text2)">{{ x.l }}</span><span style="font-family:Unbounded;font-size:28px;font-weight:600;letter-spacing:-0.02em">{{ x.v }}</span><span style="font-size:12px;color:var(--text3)">{{ x.n }}</span></div>
</sc-for>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px">
<div style="display:flex;flex-direction:column;gap:12px;padding:20px;border-radius:18px;background:var(--surface-a);border:1px solid var(--line)">
<div style="display:flex;align-items:center;gap:10px"><i style="color:var(--accent2)">folder_open</i><span style="flex:1;font-weight:600">{{ lib.scanLabel }}</span><span style="font-family:Unbounded;font-weight:600">{{ lib.scanPct }}</span></div>
<div style="height:8px;border-radius:4px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ lib.scanW }};border-radius:4px;background:linear-gradient(90deg,var(--accent),#35D3FF)"></div></div>
<div style="display:flex;justify-content:space-between;gap:12px;font-size:13px;color:var(--text2)"><span>{{ lib.scanFiles }}</span><span>{{ lib.scanEta }}</span></div>
<button onClick="{{ lib.onRescan }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;height:36px;padding:0 12px 0 8px;border-radius:10px;font-size:13px;font-weight:600;border:1px solid var(--line2)" style-hover="border-color:var(--accent)"><i style="font-size:18px">refresh</i>Пересканировать</button>
</div>
<div style="display:flex;flex-direction:column;gap:12px;padding:20px;border-radius:18px;background:var(--surface-a);border:1px solid var(--line)">
<div style="display:flex;align-items:center;gap:10px"><i style="color:var(--violet)">graphic_eq</i><span style="flex:1;font-weight:600">Аудиоанализ звучания</span><span style="font-family:Unbounded;font-weight:600">{{ lib.anaPct }}</span></div>
<div style="height:8px;border-radius:4px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ lib.anaW }};border-radius:4px;background:linear-gradient(90deg,#7C5CFF,#B39CFF)"></div></div>
<div style="display:flex;justify-content:space-between;gap:12px;font-size:13px;color:var(--text2)"><span>{{ lib.anaFiles }}</span><span>{{ lib.anaEta }}</span></div>
<span style="font-size:13px;color:var(--text3)">Темп, энергия, настроение, жанры — нужны волне даже для треков, которые никто не слушал</span>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<span style="font-size:17px;font-weight:600">Качество «Моей волны» · 7 дней</span>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px">
<sc-for list="{{ lib.metrics }}" as="x" hint-placeholder-count="5">
<div style="display:flex;flex-direction:column;gap:4px;padding:16px;border-radius:14px;border:1px solid var(--line)"><span style="font-size:13px;color:var(--text2)">{{ x.l }}</span><span style="font-size:24px;font-weight:600">{{ x.v }}</span><span style="font-size:12px;color:var(--ok)">{{ x.n }}</span></div>
</sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<span style="font-size:17px;font-weight:600">Ошибки</span>
<div style="display:flex;flex-direction:column;border:1px solid var(--line);border-radius:14px;overflow:hidden">
<sc-for list="{{ lib.errors }}" as="x">
<div style="display:grid;grid-template-columns:20px minmax(0,1fr) 110px;gap:12px;align-items:start;padding:12px 14px;border-bottom:1px solid var(--line)">
<i style="font-size:19px;color:var(--danger)">error</i>
<div style="display:flex;flex-direction:column;gap:3px;min-width:0"><span style="font-family:'JetBrains Mono';font-size:12px;color:var(--text);word-break:break-all">{{ x.p }}</span><span style="font-size:13px;color:var(--text2)">{{ x.e }}</span></div>
<span style="font-size:12px;color:var(--text3);text-align:right">{{ x.w }}</span>
</div>
</sc-for>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.upload }}">
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:1000px">
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Предложить треки</div><div style="font-size:14px;color:var(--text2);text-wrap:pretty">Файлы попадут в очередь на добавление. Дубли отсекаются до загрузки и не занимают место.</div></div>
<div style="display:flex;flex-direction:column;gap:10px;padding:18px 20px;border-radius:16px;background:var(--surface-a);border:1px solid var(--line)">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:12px"><span style="font-weight:600">Очередь на добавление</span><span style="font-size:14px;color:var(--text2)"><span style="font-family:Unbounded;font-weight:600;color:var(--text);font-size:18px">{{ up.usedStr }}</span> из {{ up.limStr }} ГБ · свободно {{ up.freeStr }} ГБ</span></div>
<div style="height:10px;border-radius:5px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ up.pct }};border-radius:5px;background:{{ up.barC }};transition:width .3s"></div></div>
</div>
<div onClick="{{ up.onPick }}" onDragOver="{{ up.onDragOver }}" onDragLeave="{{ up.onDragLeave }}" onDrop="{{ up.onDrop }}" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:44px 24px;border-radius:20px;border:2px dashed {{ up.dzBorder }};background:{{ up.dzBg }};cursor:pointer;text-align:center;transition:background .15s" style-hover="border-color:var(--accent)">
<div style="width:56px;height:56px;border-radius:50%;background:var(--accent-soft);display:flex;align-items:center;justify-content:center"><i style="font-size:28px;color:var(--accent2)">upload</i></div>
<span style="font-size:17px;font-weight:600">Перетащите файлы или папки альбомов</span>
<span style="font-size:13px;color:var(--text2)">flac, mp3, m4a, ogg, opus, wav, aiff, wv · до 2 ГБ на файл · cover.jpg привяжется к альбому</span>
</div>
<sc-if value="{{ up.hasCur }}">
<div style="display:flex;flex-direction:column;gap:10px">
<span style="font-size:17px;font-weight:600">Текущая загрузка</span>
<div style="display:flex;flex-direction:column;border:1px solid var(--line);border-radius:14px;overflow:hidden">
<sc-for list="{{ up.cur }}" as="u">
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 14px;border-bottom:1px solid var(--line);opacity:{{ u.op }}">
<div style="display:flex;align-items:center;gap:12px">
<i style="font-size:20px;color:var(--text3)">audio_file</i>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ u.name }}</span><span style="font-size:12px;color:var(--text3)">{{ u.folder }} · {{ u.size }}</span></div>
<span style="height:24px;padding:0 9px;border-radius:999px;background:{{ u.stBg }};color:{{ u.stColor }};font-size:12px;font-weight:600;display:flex;align-items:center">{{ u.stLabel }}</span>
<sc-if value="{{ u.canW }}"><button onClick="{{ u.onW }}" style="font-size:13px;color:var(--text2)" style-hover="color:var(--danger)">Отозвать</button></sc-if>
</div>
<sc-if value="{{ u.isUp }}"><div style="height:4px;border-radius:2px;background:var(--line2);overflow:hidden;margin-left:32px"><div style="height:100%;width:{{ u.w }};background:var(--accent);transition:width .25s"></div></div></sc-if>
<sc-if value="{{ u.hasNote }}"><span style="margin-left:32px;font-size:12px;color:var(--text2)">{{ u.note }}</span></sc-if>
</div>
</sc-for>
</div>
</div>
</sc-if>
<div style="display:flex;flex-direction:column;gap:10px">
<span style="font-size:17px;font-weight:600">Мои предложения</span>
<div style="display:flex;flex-direction:column;border:1px solid var(--line);border-radius:14px;overflow:hidden">
<sc-for list="{{ up.props }}" as="p">
<div style="display:flex;align-items:center;gap:14px;padding:14px;border-bottom:1px solid var(--line)">
<i style="color:var(--text3)">album</i>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px"><span style="font-size:15px;font-weight:500">{{ p.name }}</span><span style="font-size:13px;color:var(--text3)">{{ p.meta }}</span><sc-if value="{{ p.hasReason }}"><span style="font-size:13px;color:var(--text2)">{{ p.reason }}</span></sc-if></div>
<span style="height:24px;padding:0 9px;border-radius:999px;background:{{ p.stBg }};color:{{ p.stColor }};font-size:12px;font-weight:600;display:flex;align-items:center;white-space:nowrap">{{ p.stLabel }}</span>
<sc-if value="{{ p.canW }}"><button onClick="{{ p.onW }}" style="font-size:13px;color:var(--text2)" style-hover="color:var(--danger)">Отозвать</button></sc-if>
</div>
</sc-for>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.queue }}">
<div style="display:flex;flex-direction:column;gap:20px;padding:32px 36px var(--cpb);max-width:1280px">
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Очередь на добавление</div><div style="font-size:14px;color:var(--text2)">{{ qq.summary }}</div></div>
<div style="display:flex;gap:6px;flex-wrap:wrap"><sc-for list="{{ qq.filters }}" as="t" hint-placeholder-count="3"><button onClick="{{ t.onClick }}" style="height:34px;padding:0 14px;border-radius:999px;font-size:13px;font-weight:500;background:{{ t.bg }};color:{{ t.color }};border:1px solid {{ t.border }}">{{ t.label }} <span style="opacity:.6">{{ t.count }}</span></button></sc-for></div>
</div>
<sc-if value="{{ qq.hasSel }}">
<div style="position:sticky;top:12px;z-index:10;display:flex;align-items:center;gap:12px;padding:10px 12px 10px 18px;border-radius:14px;background:var(--elev);border:1px solid var(--accent);box-shadow:var(--shadow);animation:pipuIn .14s ease-out">
<span style="flex:1;font-weight:600;font-size:14px">{{ qq.selLabel }}</span>
<button onClick="{{ qq.onClearSel }}" style="height:36px;padding:0 12px;border-radius:10px;font-size:13px;color:var(--text2)" style-hover="background:var(--hover)">Снять выделение</button>
<button onClick="{{ qq.onBulkReject }}" style="display:flex;align-items:center;gap:6px;height:36px;padding:0 14px 0 10px;border-radius:10px;font-size:13px;font-weight:600;color:var(--danger);border:1px solid var(--danger-soft)" style-hover="background:var(--danger-soft)"><i style="font-size:18px">close</i>Отклонить</button>
<button onClick="{{ qq.onBulkApprove }}" style="display:flex;align-items:center;gap:6px;height:36px;padding:0 14px 0 10px;border-radius:10px;font-size:13px;font-weight:600;background:var(--ok);color:#04110B" style-hover="filter:brightness(1.08)"><i style="font-size:18px">check</i>Одобрить</button>
</div>
</sc-if>
<sc-if value="{{ qq.empty }}">
<div style="padding:64px 0;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--text2)"><i style="font-size:44px;color:var(--ok)">task_alt</i><span style="font-size:16px">Всё рассмотрено — новых предложений нет</span></div>
</sc-if>
<sc-if value="{{ qq.grouped }}" hint-placeholder-val="{{ true }}">
<sc-for list="{{ qq.groups }}" as="g">
<div style="display:flex;flex-direction:column;border-radius:20px;border:1px solid var(--line);background:var(--surface-a);overflow:hidden">
<div style="display:flex;align-items:center;gap:18px;padding:18px 20px;flex-wrap:wrap;border-bottom:1px solid var(--line)">
<div onClick="{{ g.onSelAll }}" style="width:20px;height:20px;border-radius:6px;border:1.5px solid {{ g.selBorder }};background:{{ g.selBg }};display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0"><i style="font-size:16px;color:#fff">{{ g.selIcon }}</i></div>
<div style="position:relative;width:76px;height:76px;border-radius:12px;background:{{ g.cover }};flex-shrink:0;cursor:pointer" title="Заменить обложку"><div style="position:absolute;right:4px;bottom:4px;width:24px;height:24px;border-radius:7px;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center"><i style="font-size:15px;color:#fff">edit</i></div></div>
<div style="flex:1;min-width:260px;display:flex;flex-direction:column;gap:2px">
<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap">
<input value="{{ g.artist }}" onChange="{{ g.onArtist }}" style="width:{{ g.aw }};max-width:100%;font-size:19px;font-weight:600;background:transparent;border:1px solid transparent;border-radius:8px;padding:2px 8px;margin-left:-9px;outline:none" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
<span style="color:var(--text3)">—</span>
<input value="{{ g.album }}" onChange="{{ g.onAlbum }}" style="width:{{ g.alw }};max-width:100%;font-size:19px;font-weight:600;background:transparent;border:1px solid transparent;border-radius:8px;padding:2px 8px;outline:none" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
<input value="{{ g.year }}" onChange="{{ g.onYear }}" style="width:70px;font-size:16px;color:var(--text2);background:transparent;border:1px solid transparent;border-radius:8px;padding:2px 8px;outline:none" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
</div>
<span style="font-size:13px;color:var(--text2)">{{ g.count }} · {{ g.sizeStr }} · {{ g.quality }} · от {{ g.by }}, {{ g.when }}</span>
<span style="font-size:13px;color:var(--accent2)">{{ g.note }}</span>
</div>
<div style="display:flex;gap:8px">
<button onClick="{{ g.onReject }}" style="height:38px;padding:0 14px;border-radius:10px;font-size:13px;font-weight:600;color:var(--danger);border:1px solid var(--danger-soft)" style-hover="background:var(--danger-soft)">Отклонить</button>
<button onClick="{{ g.onApprove }}" style="display:flex;align-items:center;gap:6px;height:38px;padding:0 14px 0 10px;border-radius:10px;font-size:13px;font-weight:600;background:var(--ok);color:#04110B" style-hover="filter:brightness(1.08)"><i style="font-size:18px">done_all</i>Одобрить альбом</button>
</div>
</div>
<sc-for list="{{ g.tracks }}" as="t">
<div style="display:grid;grid-template-columns:20px 34px 26px minmax(0,1fr) 64px 76px;gap:12px;align-items:center;padding:8px 20px;border-bottom:1px solid var(--line)" style-hover="background:var(--hover)">
<div onClick="{{ t.onSel }}" style="width:20px;height:20px;border-radius:6px;border:1.5px solid {{ t.selBorder }};background:{{ t.selBg }};display:flex;align-items:center;justify-content:center;cursor:pointer"><i style="font-size:16px;color:#fff">{{ t.selIcon }}</i></div>
<button onClick="{{ t.onPlay }}" aria-label="Прослушать" style="width:32px;height:32px;border-radius:50%;background:{{ t.playBg }};color:{{ t.playC }};display:flex;align-items:center;justify-content:center"><i style="font-size:20px;font-variation-settings:'FILL' 1">{{ t.playIcon }}</i></button>
<span style="font-size:13px;color:var(--text3);font-variant-numeric:tabular-nums">{{ t.n }}</span>
<div style="display:flex;flex-direction:column;gap:3px;min-width:0">
<input value="{{ t.title }}" onChange="{{ t.onTitle }}" style="font-size:14px;font-weight:500;background:transparent;border:1px solid transparent;border-radius:6px;padding:2px 6px;margin-left:-7px;outline:none;min-width:0" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
<sc-if value="{{ t.hasFlag }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:6px;background:{{ t.flagBg }};color:{{ t.flagColor }};font-size:11px;font-weight:600"><i style="font-size:13px">{{ t.flagIcon }}</i>{{ t.flagLabel }}</span></sc-if>
<sc-if value="{{ t.hasNote }}"><span style="font-size:12px;color:var(--text2)">{{ t.note }}</span></sc-if>
<div style="display:flex;align-items:flex-start;gap:6px;font-family:'JetBrains Mono';font-size:12px;line-height:17px;overflow-wrap:anywhere"><i style="font-size:15px;color:var(--text3);margin-top:1px">subdirectory_arrow_right</i><span><sc-for list="{{ t.segs }}" as="sg"><span style="color:{{ sg.c }}">{{ sg.t }}</span></sc-for></span></div>
</div>
<span style="font-size:12px;color:var(--text3);text-align:right">{{ t.size }}</span>
<div style="display:flex;gap:4px;justify-content:flex-end">
<button onClick="{{ t.onReject }}" aria-label="Отклонить" style="width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--danger-soft);color:var(--danger)"><i style="font-size:19px">close</i></button>
<button onClick="{{ t.onApprove }}" aria-label="Одобрить" style="width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--ok-soft);color:var(--ok)"><i style="font-size:19px">check</i></button>
</div>
</div>
</sc-for>
</div>
</sc-for>
<div style="display:flex;align-items:center;gap:16px;font-size:12px;color:var(--text3)"><span style="display:flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:var(--text2)"></span>папка уже есть</span><span style="display:flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:var(--accent2)"></span>будет создана</span></div>
</sc-if>
<sc-if value="{{ qq.split }}">
<div style="display:grid;grid-template-columns:300px minmax(0,1fr);gap:16px;align-items:start">
<div style="display:flex;flex-direction:column;gap:6px;position:sticky;top:12px">
<sc-for list="{{ qq.groups }}" as="g">
<div onClick="{{ g.onPick }}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:14px;background:{{ g.bg }};border:1px solid {{ g.border }};cursor:pointer" style-hover="background:var(--hover)">
<div style="width:52px;height:52px;border-radius:10px;background:{{ g.cover }};flex-shrink:0"></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ g.album }}</span><span style="font-size:13px;color:var(--text2)">{{ g.artist }}</span><span style="font-size:12px;color:var(--text3)">{{ g.count }} · {{ g.by }}</span></div>
</div>
</sc-for>
</div>
<sc-if value="{{ qq.hasCur }}">
<div style="display:flex;flex-direction:column;gap:20px;padding:24px;border-radius:20px;border:1px solid var(--line);background:var(--surface-a)">
<div style="display:flex;gap:20px;flex-wrap:wrap">
<div style="position:relative;width:160px;height:160px;border-radius:16px;background:{{ qq.cur.cover }};flex-shrink:0"><button style="position:absolute;left:8px;bottom:8px;display:flex;align-items:center;gap:4px;height:28px;padding:0 10px 0 6px;border-radius:8px;background:rgba(0,0,0,.6);color:#fff;font-size:12px;font-weight:600"><i style="font-size:16px">image</i>Заменить</button></div>
<div style="flex:1;min-width:260px;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;align-content:start">
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:12px;color:var(--text2)">Исполнитель альбома</span><input value="{{ qq.cur.artist }}" onChange="{{ qq.cur.onArtist }}" style="height:40px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px" style-focus="border-color:var(--accent)"></label>
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:12px;color:var(--text2)">Альбом</span><input value="{{ qq.cur.album }}" onChange="{{ qq.cur.onAlbum }}" style="height:40px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px" style-focus="border-color:var(--accent)"></label>
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:12px;color:var(--text2)">Год</span><input value="{{ qq.cur.year }}" onChange="{{ qq.cur.onYear }}" style="height:40px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px" style-focus="border-color:var(--accent)"></label>
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-size:12px;color:var(--text2)">Качество</span><span style="height:40px;display:flex;align-items:center;font-size:14px">{{ qq.cur.quality }}</span></div>
<span style="grid-column:1/-1;font-size:13px;color:var(--text2)">{{ qq.cur.count }} · {{ qq.cur.sizeStr }} · от {{ qq.cur.by }}, {{ qq.cur.when }} · <span style="color:var(--accent2)">{{ qq.cur.note }}</span></span>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px;padding:14px 16px;border-radius:14px;background:var(--surface2)">
<span style="font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Куда ляжет в хранилище</span>
<sc-for list="{{ qq.cur.tree }}" as="n">
<div style="display:flex;align-items:center;gap:8px;padding-left:{{ n.pad }};font-family:'JetBrains Mono';font-size:13px;color:{{ n.c }}"><i style="font-size:17px">{{ n.icon }}</i><span>{{ n.t }}</span><span style="font-family:Onest;font-size:12px;color:var(--text3)">{{ n.note }}</span></div>
</sc-for>
</div>
<div style="display:flex;flex-direction:column">
<sc-for list="{{ qq.cur.tracks }}" as="t">
<div style="display:grid;grid-template-columns:20px 34px 26px minmax(0,1fr) 64px 76px;gap:12px;align-items:center;padding:8px 4px;border-bottom:1px solid var(--line)">
<div onClick="{{ t.onSel }}" style="width:20px;height:20px;border-radius:6px;border:1.5px solid {{ t.selBorder }};background:{{ t.selBg }};display:flex;align-items:center;justify-content:center;cursor:pointer"><i style="font-size:16px;color:#fff">{{ t.selIcon }}</i></div>
<button onClick="{{ t.onPlay }}" aria-label="Прослушать" style="width:32px;height:32px;border-radius:50%;background:{{ t.playBg }};color:{{ t.playC }};display:flex;align-items:center;justify-content:center"><i style="font-size:20px;font-variation-settings:'FILL' 1">{{ t.playIcon }}</i></button>
<span style="font-size:13px;color:var(--text3)">{{ t.n }}</span>
<div style="display:flex;flex-direction:column;gap:3px;min-width:0">
<input value="{{ t.title }}" onChange="{{ t.onTitle }}" style="font-size:14px;font-weight:500;background:transparent;border:1px solid transparent;border-radius:6px;padding:2px 6px;margin-left:-7px;outline:none;min-width:0" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
<sc-if value="{{ t.hasFlag }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:6px;background:{{ t.flagBg }};color:{{ t.flagColor }};font-size:11px;font-weight:600"><i style="font-size:13px">{{ t.flagIcon }}</i>{{ t.flagLabel }}</span></sc-if>
<sc-if value="{{ t.hasNote }}"><span style="font-size:12px;color:var(--text2)">{{ t.note }}</span></sc-if>
</div>
<span style="font-size:12px;color:var(--text3);text-align:right">{{ t.size }}</span>
<div style="display:flex;gap:4px;justify-content:flex-end">
<button onClick="{{ t.onReject }}" aria-label="Отклонить" style="width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--danger-soft);color:var(--danger)"><i style="font-size:19px">close</i></button>
<button onClick="{{ t.onApprove }}" aria-label="Одобрить" style="width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--ok-soft);color:var(--ok)"><i style="font-size:19px">check</i></button>
</div>
</div>
</sc-for>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button onClick="{{ qq.cur.onReject }}" style="height:42px;padding:0 16px;border-radius:12px;font-size:14px;font-weight:600;color:var(--danger);border:1px solid var(--danger-soft)" style-hover="background:var(--danger-soft)">Отклонить альбом</button>
<button onClick="{{ qq.cur.onApprove }}" style="display:flex;align-items:center;gap:6px;height:42px;padding:0 18px 0 14px;border-radius:12px;font-size:14px;font-weight:600;background:var(--ok);color:#04110B" style-hover="filter:brightness(1.08)"><i style="font-size:19px">done_all</i>Одобрить и разложить</button>
</div>
</div>
</sc-if>
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ pg.aset }}">
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:760px">
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Настройки</div><div style="font-size:14px;color:var(--text2)">Очередь на добавление и запись в хранилище</div></div>
<div style="display:flex;flex-direction:column;border-radius:20px;border:1px solid var(--line);background:var(--surface-a);overflow:hidden">
<div style="display:flex;align-items:center;gap:16px;padding:18px 20px;border-bottom:1px solid var(--line)">
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Лимит очереди</span><span style="font-size:13px;color:var(--text2)">Общий объём файлов на рассмотрении</span></div>
<div style="display:flex;align-items:center;gap:8px"><input type="number" min="1" value="{{ as.limit }}" onChange="{{ as.onLimit }}" style="width:80px;height:40px;padding:0 10px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px;text-align:right" style-focus="border-color:var(--accent)"><span style="color:var(--text2)">ГБ</span></div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;padding:18px 20px;border-bottom:1px solid var(--line)">
<div onClick="{{ as.onPerUser }}" style="display:flex;align-items:center;gap:16px;cursor:pointer">
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Лимит на модератора</span><span style="font-size:13px;color:var(--text2)">Чтобы один человек не занял всю очередь</span></div>
<div style="width:44px;height:26px;border-radius:13px;background:{{ as.perUser.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:3px;left:{{ as.perUser.x }};width:20px;height:20px;border-radius:50%;background:#fff;transition:left .2s"></div></div>
</div>
<sc-if value="{{ as.showPer }}"><div style="display:flex;align-items:center;gap:8px"><input type="number" min="1" value="{{ as.perUserLimit }}" onChange="{{ as.onPerUserLimit }}" style="width:80px;height:40px;padding:0 10px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px;text-align:right"><span style="color:var(--text2)">ГБ на человека</span></div></sc-if>
</div>
<div style="display:flex;align-items:center;gap:16px;padding:18px 20px;border-bottom:1px solid var(--line)">
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Максимальный размер файла</span></div>
<div style="display:flex;align-items:center;gap:8px"><input type="number" min="1" value="{{ as.maxFile }}" onChange="{{ as.onMaxFile }}" style="width:80px;height:40px;padding:0 10px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px;text-align:right" style-focus="border-color:var(--accent)"><span style="color:var(--text2)">ГБ</span></div>
</div>
<div style="display:flex;align-items:center;gap:16px;padding:18px 20px;border-bottom:1px solid var(--line)">
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Хранилище для новых треков</span><span style="font-size:13px;color:var(--text2)">При одобрении можно выбрать другое</span></div>
<select value="{{ as.defSrc }}" onChange="{{ as.onDefSrc }}" style="height:40px;padding:0 10px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px">
<sc-for list="{{ as.srcOpts }}" as="o"><option value="{{ o.v }}">{{ o.l }}</option></sc-for>
</select>
</div>
<div onClick="{{ as.onUpgrade }}" style="display:flex;align-items:center;gap:16px;padding:18px 20px;cursor:pointer">
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Принимать замену на лучшее качество</span><span style="font-size:13px;color:var(--text2)">Тот же трек в lossless пойдёт на рассмотрение, а не отсеется как дубль</span></div>
<div style="width:44px;height:26px;border-radius:13px;background:{{ as.upgrade.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:3px;left:{{ as.upgrade.x }};width:20px;height:20px;border-radius:50%;background:#fff;transition:left .2s"></div></div>
</div>
</div>
<button onClick="{{ as.onSave }}" style="align-self:flex-start;height:44px;padding:0 20px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)">Сохранить</button>
</div>
</sc-if>
</div>
<sc-if value="{{ isIsland }}">
<div style="position:absolute;left:50%;bottom:16px;transform:translateX(-50%);width:min(860px, calc(100% - 32px));background:var(--glass);backdrop-filter:blur(24px) saturate(1.4);border:1px solid var(--line2);border-radius:22px;box-shadow:var(--shadow);overflow:hidden;z-index:20">
<div style="display:flex;align-items:center;gap:14px;padding:10px 14px 12px 10px">
<div onClick="{{ cur.onAlbum }}" style="width:52px;height:52px;border-radius:12px;background:{{ cur.cover }};cursor:pointer;flex-shrink:0"></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px">
<span style="font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ cur.title }}</span>
<div style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text2);min-width:0"><span onClick="{{ cur.onArtist }}" style="cursor:pointer;white-space:nowrap" style-hover="color:var(--text)">{{ cur.artist }}</span><sc-if value="{{ waveOn }}"><span style="color:var(--accent2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">· {{ srcLabel }}</span></sc-if></div>
</div>
<button onClick="{{ prev }}" aria-label="Назад" style="width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:26px;font-variation-settings:'FILL' 1">skip_previous</i></button>
<button onClick="{{ togglePlay }}" aria-label="Пауза" style="width:48px;height:48px;border-radius:50%;background:var(--text);color:var(--bg);display:flex;align-items:center;justify-content:center" style-hover="transform:scale(1.05)"><i style="font-size:30px;font-variation-settings:'FILL' 1">{{ playIcon }}</i></button>
<button onClick="{{ next }}" aria-label="Вперёд" style="width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:26px;font-variation-settings:'FILL' 1">skip_next</i></button>
<span style="width:92px;text-align:center;font-size:13px;color:var(--text2);font-variant-numeric:tabular-nums">{{ posStr }} / {{ durStr }}</span>
<button onClick="{{ cur.onLike }}" aria-label="Лайк" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:22px;color:{{ cur.likeColor }};font-variation-settings:{{ cur.likeFill }}">favorite</i></button>
<button onClick="{{ cur.onDislike }}" aria-label="Дизлайк" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:22px;color:{{ cur.disColor }};font-variation-settings:{{ cur.disFill }}">thumb_down</i></button>
<button onClick="{{ toggleQueue }}" aria-label="Очередь" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:{{ queueColor }}" style-hover="background:var(--hover)"><i style="font-size:22px">queue_music</i></button>
<button onClick="{{ toggleMute }}" aria-label="Громкость" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover)"><i style="font-size:22px">{{ volIcon }}</i></button>
</div>
<div onClick="{{ onSeek }}" style="position:absolute;left:0;right:0;bottom:0;height:10px;cursor:pointer;display:flex;align-items:flex-end">
<div style="width:100%;height:3px;background:var(--line2)"><div style="height:100%;width:{{ pct }};background:linear-gradient(90deg,var(--accent),#35D3FF);box-shadow:0 0 8px var(--accent)"></div></div>
</div>
</div>
</sc-if>
</div>
<sc-if value="{{ isBar }}" hint-placeholder-val="{{ true }}">
<footer style="grid-row:3;grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr) minmax(0,1fr);align-items:center;gap:24px;padding:0 20px;background:var(--player-bg);backdrop-filter:blur(28px) saturate(1.2);border-top:var(--top-border);border-radius:var(--pr)">
<div style="display:flex;align-items:center;gap:14px;min-width:0">
<div onClick="{{ cur.onAlbum }}" style="width:56px;height:56px;border-radius:10px;background:{{ cur.cover }};cursor:pointer;flex-shrink:0"></div>
<div style="min-width:0;display:flex;flex-direction:column;gap:3px">
<span style="font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ cur.title }}</span>
<span onClick="{{ cur.onArtist }}" style="font-size:13px;color:var(--text2);cursor:pointer;align-self:flex-start" style-hover="color:var(--text);text-decoration:underline">{{ cur.artist }}</span>
<div style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:{{ srcColor }};white-space:nowrap;overflow:hidden"><i style="font-size:15px">{{ srcIcon }}</i><span style="overflow:hidden;text-overflow:ellipsis">{{ srcLabel }}</span></div>
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0">
<div style="display:flex;align-items:center;gap:14px">
<button onClick="{{ cur.onDislike }}" aria-label="Дизлайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ cur.disColor }};font-variation-settings:{{ cur.disFill }}">thumb_down</i></button>
<button onClick="{{ prev }}" aria-label="Назад" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:28px;font-variation-settings:'FILL' 1">skip_previous</i></button>
<button onClick="{{ togglePlay }}" aria-label="Пауза" style="width:44px;height:44px;border-radius:50%;background:var(--text);color:var(--bg);display:flex;align-items:center;justify-content:center" style-hover="transform:scale(1.06)"><i style="font-size:30px;font-variation-settings:'FILL' 1">{{ playIcon }}</i></button>
<button onClick="{{ next }}" aria-label="Вперёд" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:28px;font-variation-settings:'FILL' 1">skip_next</i></button>
<button onClick="{{ cur.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ cur.likeColor }};font-variation-settings:{{ cur.likeFill }}">favorite</i></button>
</div>
<div style="width:100%;max-width:560px;display:flex;align-items:center;gap:10px;font-size:12px;color:var(--text3);font-variant-numeric:tabular-nums">
<span style="width:36px;text-align:right">{{ posStr }}</span>
<div onClick="{{ onSeek }}" style="flex:1;height:14px;display:flex;align-items:center;cursor:pointer">
<div style="position:relative;width:100%;height:4px;border-radius:2px;background:var(--line2)">
<div style="height:100%;width:{{ pct }};border-radius:2px;background:linear-gradient(90deg,var(--accent),#35D3FF)"></div>
<div style="position:absolute;top:50%;left:{{ pct }};width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:#fff;box-shadow:0 0 0 3px rgba(47,128,255,.35)"></div>
</div>
</div>
<span style="width:36px">{{ durStr }}</span>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:6px">
<button onClick="{{ toggleQueue }}" aria-label="Очередь" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:{{ queueColor }}" style-hover="background:var(--hover)"><i style="font-size:22px">queue_music</i></button>
<button onClick="{{ toggleMute }}" aria-label="Громкость" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover)"><i style="font-size:22px">{{ volIcon }}</i></button>
<div onClick="{{ onVol }}" style="width:110px;height:14px;display:flex;align-items:center;cursor:pointer">
<div style="width:100%;height:4px;border-radius:2px;background:var(--line2)"><div style="height:100%;width:{{ volPct }};border-radius:2px;background:var(--text2)"></div></div>
</div>
</div>
</footer>
</sc-if>
</div>
<sc-if value="{{ queueOpen }}">
<div style="position:absolute;top:68px;right:8px;bottom:{{ drawerBottom }};width:380px;display:flex;flex-direction:column;background:var(--elev);border:1px solid var(--line2);border-radius:20px;box-shadow:var(--shadow);z-index:40;animation:pipuIn .16s ease-out;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 16px 12px 20px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:17px">Очередь</span><span style="font-size:13px;color:var(--text2)">{{ srcLabel }}</span></div>
<button onClick="{{ toggleQueue }}" aria-label="Закрыть" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover)"><i>close</i></button>
</div>
<div style="flex:1;overflow:auto;padding:0 8px 8px">
<sc-for list="{{ queueRows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:12px;padding:6px 10px;border-radius:12px;cursor:pointer;background:{{ r.bg }}" style-hover="background:var(--hover)">
<div style="width:40px;height:40px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:14px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="font-size:12px;color:var(--text2)">{{ r.artist }}</span></div>
<span style="font-size:12px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
</div>
</sc-for>
<sc-if value="{{ waveOn }}">
<div style="display:flex;align-items:center;gap:10px;margin:8px 10px 4px;padding:12px;border-radius:12px;background:var(--accent-soft);font-size:13px;line-height:1.45;color:var(--text2)"><i style="font-size:20px;color:var(--accent2)">all_inclusive</i>Волна бесконечна — следующие треки подбираются по ходу, с учётом ваших реакций.</div>
</sc-if>
</div>
</div>
</sc-if>
<sc-if value="{{ dlg.any }}">
<div onClick="{{ dlg.close }}" style="position:absolute;inset:0;z-index:100;background:var(--overlay);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;animation:pipuIn .14s ease-out">
<div onClick="{{ dlg.stop }}" style="width:440px;max-width:calc(100% - 32px);display:flex;flex-direction:column;gap:18px;padding:28px;border-radius:22px;background:var(--elev);border:1px solid var(--line2);box-shadow:var(--shadow);box-sizing:border-box">
<sc-if value="{{ dlg.create }}">
<div style="font-family:Unbounded;font-size:22px;font-weight:600">Новый пользователь</div>
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Логин</span><input value="{{ f.nuLogin }}" onChange="{{ fh.nuLogin }}" placeholder="например, katya" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ nu.border }};outline:none;font-size:15px;font-family:'JetBrains Mono'" style-focus="border-color:var(--accent)"><sc-if value="{{ nu.err }}"><span style="font-size:12px;color:var(--danger)">{{ nu.err }}</span></sc-if></label>
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Имя</span><input value="{{ f.nuName }}" onChange="{{ fh.nuName }}" placeholder="Как показывать в списке" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px" style-focus="border-color:var(--accent)"></label>
<sc-if value="{{ nu.canRole }}">
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Роль</span><div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2);align-self:flex-start"><sc-for list="{{ nu.roles }}" as="l"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 14px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for></div></div>
</sc-if>
<span style="font-size:13px;line-height:1.5;color:var(--text2)">Пароль сгенерируется автоматически. При первом входе пользователь задаст свой.</span>
<div style="display:flex;justify-content:flex-end;gap:8px"><button onClick="{{ dlg.close }}" style="height:42px;padding:0 16px;border-radius:12px;font-size:14px;font-weight:500;color:var(--text2)" style-hover="background:var(--hover)">Отмена</button><button onClick="{{ nu.onCreate }}" style="height:42px;padding:0 18px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:14px">Создать</button></div>
</sc-if>
<sc-if value="{{ dlg.temp }}">
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:22px;font-weight:600">Временный пароль</div><span style="font-size:14px;color:var(--text2)">Для: {{ tp.who }}</span></div>
<div style="display:flex;align-items:center;gap:10px;padding:14px 14px 14px 18px;border-radius:14px;background:var(--surface2);border:1px solid var(--line2)"><span style="flex:1;font-family:'JetBrains Mono';font-size:20px;font-weight:600;letter-spacing:.04em">{{ tp.pw }}</span><button onClick="{{ tp.onCopy }}" style="display:flex;align-items:center;gap:6px;height:36px;padding:0 12px 0 8px;border-radius:10px;background:var(--accent);color:#fff;font-size:13px;font-weight:600"><i style="font-size:18px">content_copy</i>Копировать</button></div>
<div style="display:flex;align-items:flex-start;gap:8px;font-size:13px;line-height:1.5;color:var(--warn)"><i style="font-size:18px">visibility_off</i>Пароль показывается один раз. При входе пользователь обязан будет задать новый.</div>
<button onClick="{{ dlg.close }}" style="align-self:flex-end;height:42px;padding:0 18px;border-radius:12px;background:var(--surface3);font-weight:600;font-size:14px">Готово</button>
</sc-if>
<sc-if value="{{ dlg.reject }}">
<div style="font-family:Unbounded;font-size:22px;font-weight:600">{{ rj.title }}</div>
<div style="display:flex;flex-wrap:wrap;gap:6px"><sc-for list="{{ rj.quick }}" as="r"><button onClick="{{ r.onClick }}" style="height:32px;padding:0 12px;border-radius:999px;font-size:13px;background:{{ r.bg }};border:1px solid {{ r.border }}">{{ r.label }}</button></sc-for></div>
<textarea value="{{ rj.reason }}" onChange="{{ fh.reason }}" placeholder="Причина — её увидит автор предложения" rows="3" style="padding:12px 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px;resize:vertical" style-focus="border-color:var(--accent)"></textarea>
<div style="display:flex;justify-content:flex-end;gap:8px"><button onClick="{{ dlg.close }}" style="height:42px;padding:0 16px;border-radius:12px;font-size:14px;font-weight:500;color:var(--text2)" style-hover="background:var(--hover)">Отмена</button><button onClick="{{ rj.onConfirm }}" style="height:42px;padding:0 18px;border-radius:12px;background:var(--danger);color:#fff;font-weight:600;font-size:14px">Отклонить</button></div>
</sc-if>
</div>
</div>
</sc-if>
<sc-if value="{{ toast }}">
<div style="position:absolute;left:50%;bottom:{{ toastBottom }};transform:translateX(-50%);display:flex;align-items:center;gap:10px;padding:12px 18px;border-radius:14px;background:var(--text);color:var(--bg);font-size:14px;font-weight:500;box-shadow:var(--shadow);z-index:200;animation:pipuIn .18s ease-out;max-width:560px"><i style="font-size:20px">check_circle</i>{{ toast }}</div>
</sc-if>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;dark&quot;,&quot;light&quot;],&quot;default&quot;:&quot;dark&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Вид&quot;},&quot;waveVariant&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;orb&quot;,&quot;rings&quot;,&quot;liquid&quot;],&quot;default&quot;:&quot;orb&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Вид&quot;},&quot;ambient&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Вид&quot;},&quot;homeLayout&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;center&quot;,&quot;split&quot;],&quot;default&quot;:&quot;center&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Варианты&quot;},&quot;shell&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;classic&quot;,&quot;floating&quot;],&quot;default&quot;:&quot;classic&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Варианты&quot;},&quot;player&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;bar&quot;,&quot;island&quot;],&quot;default&quot;:&quot;bar&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Варианты&quot;},&quot;queueLayout&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;grouped&quot;,&quot;split&quot;],&quot;default&quot;:&quot;grouped&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Варианты&quot;},&quot;role&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;admin&quot;,&quot;moderator&quot;,&quot;user&quot;],&quot;default&quot;:&quot;admin&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Прототип&quot;},&quot;startScreen&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;login&quot;,&quot;app&quot;],&quot;default&quot;:&quot;app&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Прототип&quot;}}">
const USERS0 = [
{ id: 1, name: 'Алексей Морозов', login: 'admin', role: 'admin', status: 'active', last: 'сейчас в сети' },
{ id: 2, name: 'Илья Сафонов', login: 'ilya', role: 'moderator', status: 'active', last: '12 мин назад' },
{ id: 3, name: 'Мария Котова', login: 'masha', role: 'moderator', status: 'active', last: 'вчера' },
{ id: 4, name: 'Анна Белова', login: 'anna', role: 'user', status: 'active', last: '2 ч назад' },
{ id: 5, name: 'Дмитрий Орлов', login: 'dima', role: 'user', status: 'active', last: '3 дня назад' },
{ id: 6, name: 'Олег Ким', login: 'oleg', role: 'user', status: 'blocked', last: '2 нед. назад' },
{ id: 7, name: 'Света Ли', login: 'sveta', role: 'user', status: 'mustchange', last: '—' },
{ id: 8, name: 'Никита Громов', login: 'nik', role: 'user', status: 'active', last: '5 ч назад' }
];
const SOURCES0 = [
{ id: 'nas', name: 'NAS — музыка', type: 'nfs', server: '192.168.1.20', export: '/volume1/music', opts: 'nfsvers=4.1,soft,timeo=150', path: '', status: 'ok', tracks: '11 284', accepts: true, last: 'сегодня, 04:00' },
{ id: 'old', name: 'Старый NAS', type: 'nfs', server: '192.168.1.31', export: '/export/flac', opts: 'nfsvers=4.1,soft', path: '', status: 'error', msg: 'stat: нет ответа за 15 с — треки помечены недоступными', tracks: '2 140', accepts: false, last: '3 дня назад' },
{ id: 'vinyl', name: 'Оцифровка винила', type: 'local', server: '', export: '', opts: '', path: 'vinyl', status: 'ok', tracks: '412', accepts: false, last: 'вчера, 04:00' }
];
const PATTERNS0 = [
'^(?P<artist>[^/]+)/(?P<year>\\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\\d)-)?(?P<track>\\d{1,3}) - (?P<title>[^/]+)\\.\\w+$',
'^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \\((?P<year>\\d{4})\\)/(?P<track>\\d+)\\. (?P<title>[^/]+)\\.\\w+$',
'^Compilations/(?P<album>[^/]+)/(?P<track>\\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\\.\\w+$',
'^(?P<skip>_incoming)/'
];
const PREVIEW = [
'Radiohead/2007 - In Rainbows/04 - Weird Fishes_Arpeggi.flac',
'Кино/1988 - Группа крови/01 - Группа крови.flac',
'Daft Punk/2013 - Random Access Memories/1-08 - Get Lucky.flac',
'Электроника/Bonobo/Migration (2017)/03. Outlier.mp3',
'Compilations/Ночь на Петроградке/07 - Сплин - Орбит без сахара.mp3',
'_incoming/rip/track03.wav',
'Земфира/Прости меня моя любовь/Хочешь.flac'
];
const DEF_TPL = '{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}';
const Q0 = [
{ id: 'q1', artist: 'Moderat', album: 'III', year: '2016', c1: '#E6E1D6', c2: '#2E2E33', by: 'Илья Сафонов', when: '2 ч назад', note: 'Исполнитель уже есть · новый альбом', fmt: 'FLAC', q: '16 бит / 44,1 кГц', ext: 'flac', tracks: ['Eating Hooks', 'Running', 'Finder', 'Ghostmother', 'Reminder', 'The Fool', 'Intruder', 'Animal Trails'] },
{ id: 'q2', artist: 'Molchat Doma', album: 'Этажи', year: '2018', c1: '#9AA6B2', c2: '#161B21', by: 'Илья Сафонов', when: 'вчера', note: 'Новый исполнитель · новый альбом', fmt: 'FLAC', q: '24 бит / 48 кГц', ext: 'flac', tracks: ['Тоска', 'Клетка', 'Судно (Борис Рыжий)', 'Танцевать', 'Discoteque', 'Звёзды'] },
{ id: 'q3', artist: 'Земфира', album: 'Live в Олимпийском', year: '2007', c1: '#E0507F', c2: '#1E0A1A', by: 'Мария Котова', when: '3 дня назад', note: 'Исполнитель уже есть · новый альбом', fmt: 'MP3', q: '320 кбит/с', ext: 'mp3', tracks: [['Хочешь?', 'dup', 'Похоже на «Хочешь?» · Прости меня моя любовь, 2000 — другая версия?'], ['Искала', 'dup', 'Похоже на «Искала» · Прости меня моя любовь, 2000'], 'Небо Лондона', 'Бесконечность'] },
{ id: 'q4', artist: 'Radiohead', album: 'In Rainbows', year: '2007', c1: '#F2A23A', c2: '#1C3B6E', by: 'Мария Котова', when: '3 дня назад', note: 'Альбом уже есть · замена файла', fmt: 'FLAC', q: '24 бит / 96 кГц', ext: 'flac', tracks: [['Nude', 'better', 'Сейчас в библиотеке MP3 320 кбит/с — заменится на FLAC 24/96', 3]] }
];
const PROP0 = [
{ id: 'p1', name: 'Moderat — III (2016)', files: 8, size: '312 МБ', status: 'pending_review', date: 'сегодня, 10:14' },
{ id: 'p2', name: 'Molchat Doma — Этажи (2018)', files: 6, size: '248 МБ', status: 'pending_review', date: 'вчера, 22:40' },
{ id: 'p3', name: 'Bonobo — Fragments (2022)', files: 12, size: '486 МБ', status: 'imported', date: '12 окт' },
{ id: 'p4', name: 'Сплин — Сигнал из космоса (2009)', files: 11, size: '98 МБ', status: 'rejected', reason: 'Низкое качество: MP3 128 кбит/с — нужен lossless', date: '10 окт' },
{ id: 'p5', name: 'Daft Punk — Get Lucky (Radio Edit).mp3', files: 1, size: '9 МБ', status: 'duplicate', reason: 'Уже есть: Daft Punk — Get Lucky', date: '9 окт' },
{ id: 'p6', name: 'Massive Attack — 100th Window (2003)', files: 9, size: '402 МБ', status: 'withdrawn', date: '2 окт' }
];
const BATCHES = [
[['Pompeya — Tropical (2012)/01 - Power.flac', 34], ['Pompeya — Tropical (2012)/02 - Y.A.H.F..flac', 29], ['Pompeya — Tropical (2012)/03 - Tropical.flac', 31], ['Pompeya — Tropical (2012)/04 - 90.flac', 27], ['Pompeya — Tropical (2012)/05 - Slaves.flac', 33], ['Pompeya — Tropical (2012)/01 - Power (копия).flac', 34, 'Дубль файла в этой же пачке: 01 - Power.flac'], ['Radiohead - Nude.mp3', 9, 'Уже есть: Radiohead — Nude · In Rainbows']],
[['Kedr Livanskiy — Your Need (2019)/01 - Your Need.flac', 38], ['Kedr Livanskiy — Your Need (2019)/02 - Sky Kisses.flac', 41], ['Kedr Livanskiy — Your Need (2019)/03 - Kiska.flac', 36], ['Кино - Война.mp3', 8, 'Уже есть: Кино — Война · Группа крови']]
];
const ST = {
uploading: ['Загружается', 'var(--accent2)', 'var(--accent-soft)'], processing: ['Обработка', 'var(--violet)', 'var(--violet-soft)'],
pending_review: ['На рассмотрении', 'var(--warn)', 'var(--warn-soft)'], approved: ['Одобрено', 'var(--ok)', 'var(--ok-soft)'],
imported: ['Добавлено', 'var(--ok)', 'var(--ok-soft)'], rejected: ['Отклонено', 'var(--danger)', 'var(--danger-soft)'],
duplicate: ['Уже есть', 'var(--text2)', 'var(--hover)'], withdrawn: ['Отозвано', 'var(--text3)', 'var(--hover)']
};
const ROLE = { admin: ['Администратор', 'var(--violet)', 'var(--violet-soft)'], moderator: ['Модератор', 'var(--accent2)', 'var(--accent-soft)'], user: ['Пользователь', 'var(--text2)', 'var(--hover)'] };
const USTAT = { active: ['Активен', 'var(--ok)'], blocked: ['Заблокирован', 'var(--danger)'], mustchange: ['Ждёт смены пароля', 'var(--warn)'] };
const F1 = "'FILL' 1", F0 = "'FILL' 0";
const ini = n => n.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase();
const genPw = () => { const c = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let s = ''; for (let i = 0; i < 12; i++) s += c[Math.floor(Math.random() * c.length)]; return s.slice(0, 4) + '-' + s.slice(4, 8) + '-' + s.slice(8); };
class Component extends DCLogic {
state = { ready: false };
mainRef = React.createRef();
searchRef = React.createRef();
_fh = {};
componentDidMount() {
const wait = () => (window.PIPU ? this.init() : setTimeout(wait, 40));
wait();
this.iv = setInterval(() => this.tick(), 250);
this.kd = e => this.onKey(e);
window.addEventListener('keydown', this.kd);
}
componentWillUnmount() { clearInterval(this.iv); window.removeEventListener('keydown', this.kd); }
init() {
const D = window.PIPU;
let sv = null; try { sv = JSON.parse(localStorage.getItem('pipu-web-player') || 'null'); } catch (e) {}
const ok = sv && D.TR[sv.trackId];
const alb = D.ALB['rh-ir'].tracks;
const hist = []; let k = 3;
[['Сегодня', 5, 18], ['Вчера', 7, 22], ['17 октября', 6, 21]].forEach(([d, n, h]) => { let m = 40; for (let j = 0; j < n; j++) { k = (k * 7 + 5) % D.ALL.length; hist.push({ id: D.ALL[k], day: d, time: String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') }); m -= 13; if (m < 0) { m += 60; h--; } } });
const qg = Q0.map(g => ({ ...g, tracks: g.tracks.map((t, i) => { const a = Array.isArray(t) ? t : [t]; const h = D.H(g.id + a[0]); return { id: g.id + '-' + i, title: a[0], flag: a[1] || null, note: a[2] || '', n: a[3] || i + 1, size: g.fmt === 'MP3' ? 8 + h % 5 : g.q.startsWith('24') ? 70 + h % 50 : 24 + h % 18 }; }) }));
this.setState({
ready: true, auth: this.props.startScreen === 'app' ? null : 'login', page: 'home', params: {},
queue: ok && sv.queue ? sv.queue.filter(id => D.TR[id]) : alb, qi: ok ? sv.qi || 0 : 3, trackId: ok ? sv.trackId : alb[3], pos: ok ? sv.pos || 0 : 83,
source: (ok && sv.source) || { type: 'list', label: 'Альбом · In Rainbows' }, waveOn: ok ? !!sv.waveOn : false, playing: false, vol: 0.7, muted: false,
activity: null, mood: null,
likes: ['rh-ir-6', 'ma-mz-2', 'kino-gk-0', 'mod-2-1', 'bon-mg-2', 'dp-ram-4', 'zem-pm-0', 'kino-zs-4', 'spl-ga-0', 'rh-ir-3', 'bon-mg-0', 'ma-mz-0'],
dislikes: ['dp-ram-1', 'spl-ga-3', 'kino-gk-5'], history: hist,
q: '', sOpen: false, sIdx: -1, searchTab: 'all', queueOpen: false, profileOpen: false, theme: null, lang: 'ru', crossfade: 2, normalize: true,
f: {}, err: {}, showPw: false,
users: USERS0, uq: '', uRole: 'all', uMenu: null, dlg: null, tempPw: null, tempFor: '',
sources: SOURCES0, srcSel: 'nas', patterns: PATTERNS0, tagPrio: 'tags', writeTpl: DEF_TPL, tplChecked: false, testing: false, testRes: null, dragIdx: null,
scan: { pct: 62, running: true }, ana: 8912,
uploads: [], batchN: 0, proposals: PROP0, dragOver: false,
qGroups: qg, qSel: {}, qFilter: 'all', qGroup: 'q1', qPlay: null, rejectIds: [],
adm: { limit: 10, perUser: false, perUserLimit: 3, maxFile: 2, defSrc: 'nas', upgrade: false },
toast: null
});
}
tick() {
const s = this.state, D = window.PIPU; if (!s.ready) return;
const u = {};
if (s.playing && !s.auth) { const t = D.TR[s.trackId]; const np = s.pos + 0.25; if (np >= t.dur) { this.next(); return; } u.pos = np; }
if (s.page === 'library' && s.scan.running) { const p = s.scan.pct + 0.12; u.scan = p >= 100 ? { pct: 100, running: false } : { pct: p, running: true }; u.ana = Math.min(11284, s.ana + (Math.random() < 0.6 ? 1 : 0)); }
if (s.uploads.some(x => x.status === 'uploading' || x.status === 'processing')) {
let active = 0;
u.uploads = s.uploads.map(x => {
if (x.status === 'uploading') { if (active++ >= 4) return x; return x.progress >= 100 ? { ...x, status: 'processing', pt: 0 } : { ...x, progress: Math.min(100, x.progress + 4 + Math.random() * 9) }; }
if (x.status === 'processing') return x.pt >= 6 ? { ...x, status: 'pending_review' } : { ...x, pt: x.pt + 1 };
return x;
});
}
if (Object.keys(u).length) this.setState(u);
this._t = (this._t || 0) + 1;
if (this._t % 4 === 0) { try { localStorage.setItem('pipu-web-player', JSON.stringify({ trackId: s.trackId, pos: s.pos, queue: s.queue.slice(0, 60), qi: s.qi, source: s.source, waveOn: s.waveOn })); } catch (e) {} }
}
onKey(e) {
const s = this.state; if (!s.ready || s.auth) return;
const tag = (e.target.tagName || '').toLowerCase(), typing = tag === 'input' || tag === 'textarea' || tag === 'select';
if ((e.key === '/' && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k')) { e.preventDefault(); this.searchRef.current && this.searchRef.current.focus(); return; }
if (typing) return;
if (e.code === 'Space') { e.preventDefault(); this.togglePlay(); }
else if (e.key === 'ArrowRight') { e.shiftKey ? this.next() : this.seekBy(10); }
else if (e.key === 'ArrowLeft') { e.shiftKey ? this.prev() : this.seekBy(-10); }
else if (e.key === 'l' || e.key === 'L' || e.key === 'д') this.toggleLike(s.trackId);
else if (e.key === 'Escape') this.setState({ queueOpen: false, profileOpen: false, dlg: null, uMenu: null });
}
toastMsg(m) { clearTimeout(this._tt); this.setState({ toast: m }); this._tt = setTimeout(() => this.setState({ toast: null }), 2800); }
go(page, params = {}) { this.setState({ page, params, sOpen: false, profileOpen: false, queueOpen: false, uMenu: null }); if (this.mainRef.current) this.mainRef.current.scrollTop = 0; }
fh(k) { return this._fh[k] || (this._fh[k] = e => this.setState(s => ({ f: { ...s.f, [k]: e.target.value }, err: { ...s.err, [k]: null } }))); }
addHist(h, id) { if (h[0] && h[0].id === id) return h; const d = new Date(); return [{ id, day: 'Сегодня', time: String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') }, ...h]; }
setTrack(qi, queue, extra = {}) { const id = queue[qi]; this.setState(s => ({ queue, qi, trackId: id, pos: 0, playing: true, qPlay: null, history: this.addHist(s.history, id), ...extra })); }
playList(list, i, label) { this.setTrack(i, list, { waveOn: false, source: { type: 'list', label } }); }
wopts(extra = {}) { const s = this.state; return { activity: s.activity, mood: s.mood, dislikes: s.dislikes, likes: s.likes, ...extra }; }
startWave(artistId) { const q = window.PIPU.waveBatch(12, this.wopts({ artistId })); this.setTrack(0, q, { waveOn: true, source: { type: 'wave', artistId: artistId || null } }); }
next() {
const s = this.state, D = window.PIPU; let q = s.queue, i = s.qi + 1;
if (s.waveOn && i >= q.length - 2) q = [...q, ...D.waveBatch(8, this.wopts({ recent: q.slice(-20), artistId: s.source.artistId }))];
if (i >= q.length) { this.setState({ playing: false, pos: 0 }); return; }
this.setTrack(i, q);
}
prev() { const s = this.state; if (s.pos > 3 || s.qi === 0) { this.setState({ pos: 0 }); return; } this.setTrack(s.qi - 1, s.queue); }
togglePlay() { this.setState(s => ({ playing: !s.playing, qPlay: null })); }
seekBy(d) { const D = window.PIPU; this.setState(s => ({ pos: D.clamp(s.pos + d, 0, D.TR[s.trackId].dur - 1) })); }
onWave() { const s = this.state; if (s.waveOn && !s.source.artistId) this.togglePlay(); else this.startWave(); }
setCtx(k, v) {
this.setState(s => ({ [k]: s[k] === v ? null : v }), () => {
const s = this.state; if (!s.waveOn) return;
const keep = s.queue.slice(0, s.qi + 1);
this.setState({ queue: [...keep, ...window.PIPU.waveBatch(10, this.wopts({ recent: keep.slice(-20) }))] });
});
}
toggleLike(id) { this.setState(s => ({ likes: s.likes.includes(id) ? s.likes.filter(x => x !== id) : [id, ...s.likes], dislikes: s.dislikes.filter(x => x !== id) })); }
toggleDislike(id) {
const s = this.state, was = s.dislikes.includes(id);
this.setState({ dislikes: was ? s.dislikes.filter(x => x !== id) : [id, ...s.dislikes], likes: s.likes.filter(x => x !== id) });
if (!was && id === s.trackId && s.waveOn) { setTimeout(() => this.next(), 0); this.toastMsg('Больше не попадётся в волне — включаем следующий'); }
else if (!was) this.toastMsg('Трек скрыт из рекомендаций');
else this.toastMsg('Дизлайк снят — трек снова может попасть в волну');
}
openSearch() { const q = this.state.q.trim(); if (!q) return; this.go('search', { q }); this.setState({ searchTab: 'all' }); this.searchRef.current && this.searchRef.current.blur(); }
doLogin() {
const f = this.state.f, l = (f.login || '').trim(), p = f.pass || '';
if (!l || !p) return this.setState({ err: { login: 'Введите логин и пароль' } });
if (l === 'admin' && p === 'admin') return this.setState({ auth: 'setupAdmin', f: {}, err: {} });
if (p === 'reset') return this.setState({ auth: 'setupPassword', f: {}, err: {} });
if (p.length < 4) return this.setState({ err: { login: 'Неверный логин или пароль' } });
this.setState({ auth: null, f: {}, err: {}, page: 'home' });
}
doSetup() {
const s = this.state, f = s.f, e = {};
if (s.auth === 'setupAdmin') { if (!(f.nlogin || '').trim()) e.nlogin = 'Придумайте логин'; else if (f.nlogin.trim() === 'admin') e.nlogin = 'Стандартный логин нужно сменить'; }
if ((f.np || '').length < 8) e.np = 'Минимум 8 символов';
if (f.np !== f.np2) e.np2 = 'Пароли не совпадают';
if (Object.keys(e).length) return this.setState({ err: e });
this.setState({ auth: null, f: {}, err: {}, page: 'home' }); this.toastMsg('Готово — данные сохранены');
}
strength(p) {
p = p || ''; let sc = 0; if (p.length >= 8) sc++; if (p.length >= 12) sc++; if (/[a-zа-я]/.test(p) && /[A-ZА-Я]/.test(p)) sc++; if (/\d/.test(p) && /[^\w]/.test(p)) sc++;
const lv = [['Слишком короткий', 'var(--text3)'], ['Слабый', 'var(--danger)'], ['Средний', 'var(--warn)'], ['Хороший', 'var(--accent2)'], ['Надёжный', 'var(--ok)']][p ? Math.max(1, sc) : 0];
return { label: p ? lv[0] : '', color: lv[1], bars: [0, 1, 2, 3].map(i => ({ bg: p && i < Math.max(1, sc) ? lv[1] : 'var(--line2)' })) };
}
qPath(g, tr) {
const D = window.PIPU, s = this.state;
const p = D.fillTpl(s.writeTpl, { albumartist: g.artist, artist: g.artist, year: g.year, album: g.album, disc: '', track: tr.n, title: tr.title, ext: g.ext }).split('/');
const A = Object.values(D.ART).find(a => a.name.toLowerCase() === (p[0] || '').toLowerCase());
if (A) p[0] = A.name;
const albEx = !!A && p.length > 2 && A.albums.some(id => (D.ALB[id].year + ' - ' + D.ALB[id].title) === p[1]);
return { parts: p, artEx: !!A, albEx };
}
approve(ids, verb) {
const set = new Set(ids);
this.setState(s => ({ qGroups: s.qGroups.map(g => ({ ...g, tracks: g.tracks.filter(t => !set.has(t.id)) })).filter(g => g.tracks.length), qSel: {}, dlg: null, f: { ...s.f, reason: '' } }));
const n = ids.length, P = window.PIPU.plural;
this.toastMsg(verb === 'reject' ? `${n} ${P(n, ['трек отклонён', 'трека отклонены', 'треков отклонено'])} — автор увидит причину` : `${n} ${P(n, ['трек одобрен', 'трека одобрено', 'треков одобрено'])} — файлы раскладываются в хранилище`);
}
addBatch() {
const s = this.state, b = BATCHES[s.batchN % BATCHES.length];
const ups = b.map(([path, mb, dup], i) => ({ id: 'u' + Date.now() + i, path, size: mb, status: dup ? 'duplicate' : 'uploading', progress: 0, dupOf: dup || '' }));
const d = ups.filter(u => u.status === 'duplicate').length;
this.setState({ uploads: [...ups, ...s.uploads], batchN: s.batchN + 1, dragOver: false });
this.toastMsg(`Проверено ${ups.length} файлов: ${ups.length - d} загружаются, ${d} — уже есть, место не занято`);
}
adminVals({ D, s, P, isAdmin, isMod, seg, plural }) {
const o = {};
const box = (on) => ({ selBg: on ? 'var(--accent)' : 'transparent', selBorder: on ? 'var(--accent)' : 'var(--line2)', selIcon: on ? 'check' : '' });
const tg = (on) => ({ bg: on ? 'var(--accent)' : 'var(--line2)', x: on ? '21px' : '3px' });
const stb = k => ({ stLabel: ST[k][0], stColor: ST[k][1], stBg: ST[k][2] });
const pg = s.page;
if (pg === 'users') {
const canManage = u => u.role !== 'admin' && (isAdmin || u.role === 'user');
const list = s.users.filter(u => (s.uRole === 'all' || u.role === s.uRole) && (!s.uq || (u.name + ' ' + u.login).toLowerCase().includes(s.uq.toLowerCase())));
o.uu = {
q: s.uq, onQ: e => this.setState({ uq: e.target.value }),
tabs: [['all', 'Все'], ['admin', 'Админы'], ['moderator', 'Модераторы'], ['user', 'Пользователи']].map(([k, l]) => ({ label: l, count: k === 'all' ? s.users.length : s.users.filter(u => u.role === k).length, onClick: () => this.setState({ uRole: k }), bg: s.uRole === k ? 'var(--surface3)' : 'transparent', color: s.uRole === k ? 'var(--text)' : 'var(--text2)' })),
hint: isAdmin ? 'Создание пользователей и модераторов, роли, сброс пароля, блокировка' : 'Как модератор вы можете создавать обычных пользователей, сбрасывать им пароль и блокировать',
onCreate: () => this.setState({ dlg: 'create', nuRole: 'user', f: { ...s.f, nuLogin: '', nuName: '' }, err: {} }),
rows: list.map(u => {
const bl = u.status === 'blocked', acts = [
{ icon: 'lock_reset', label: 'Сбросить пароль', color: 'var(--text)', onClick: () => this.setState({ users: s.users.map(x => x.id === u.id ? { ...x, status: 'mustchange' } : x), dlg: 'temp', tempPw: genPw(), tempFor: u.name, uMenu: null }) },
{ icon: bl ? 'lock_open' : 'block', label: bl ? 'Разблокировать' : 'Заблокировать', color: bl ? 'var(--text)' : 'var(--danger)', onClick: () => { this.setState({ users: s.users.map(x => x.id === u.id ? { ...x, status: bl ? 'active' : 'blocked' } : x), uMenu: null }); this.toastMsg(bl ? u.name + ' снова может входить' : u.name + ' заблокирован, сессии завершены'); } }
];
if (isAdmin) acts.splice(1, 0, { icon: 'shield_person', label: u.role === 'moderator' ? 'Снять роль модератора' : 'Сделать модератором', color: 'var(--text)', onClick: () => { this.setState({ users: s.users.map(x => x.id === u.id ? { ...x, role: u.role === 'moderator' ? 'user' : 'moderator' } : x), uMenu: null }); } });
return { name: u.name, login: u.login, ini: ini(u.name), last: u.last, roleLabel: ROLE[u.role][0], roleColor: ROLE[u.role][1], roleBg: ROLE[u.role][2], stLabel: USTAT[u.status][0], stColor: USTAT[u.status][1], op: bl ? 0.55 : 1, canManage: canManage(u), menuOpen: s.uMenu === u.id, onMenu: e => { e.stopPropagation(); this.setState({ uMenu: s.uMenu === u.id ? null : u.id }); }, acts };
})
};
}
if (pg === 'sources') {
const src = s.sources.find(x => x.id === s.srcSel) || s.sources[0];
const upd = (k, v) => this.setState(st => ({ sources: st.sources.map(x => x.id === src.id ? { ...x, [k]: v } : x), testRes: null }));
const res = s.patterns.map(p => D.compile(p));
const prev = PREVIEW.map(path => {
for (let k = 0; k < res.length; k++) {
const re = res[k]; if (re instanceof Error) continue;
const m = re.exec(path);
if (m) { const g = m.groups || {}; const skip = g.skip != null; return { path, g, k, segs: D.segments(path, m), skip, badge: skip ? 'Пропуск' : 'Выражение #' + (k + 1), bc: skip ? 'var(--text2)' : 'var(--ok)', bb: skip ? 'var(--hover)' : 'var(--ok-soft)', fields: skip ? [] : Object.entries(g).filter(([, v]) => v != null).map(([n, v]) => ({ k: D.GL[n] || n, v, c: D.GC[n] || 'var(--text)' })) }; }
}
return { path, segs: [{ t: path, c: 'var(--danger)' }], badge: 'Не совпало', bc: 'var(--danger)', bb: 'var(--danger-soft)', fields: [], none: true };
});
const chk = prev.filter(r => !r.none && !r.skip).map(r => { const gen = D.fillTpl(s.writeTpl, { ...r.g, ext: r.path.split('.').pop() }); return { real: r.path, gen, ok: gen === r.path, icon: gen === r.path ? 'check_circle' : 'cancel', c: gen === r.path ? 'var(--ok)' : 'var(--warn)' }; });
const okN = chk.filter(c => c.ok).length, pct = chk.length ? Math.round(okN / chk.length * 100) : 0;
o.src = {
list: s.sources.map(x => { const sel = x.id === src.id; return { name: x.name, addr: x.type === 'nfs' ? x.server + ':' + x.export : '/srv/pipu/library-host/' + x.path, typeLabel: x.type === 'nfs' ? 'NFS' : 'Папка на сервере', tracks: x.tracks + ' треков', stLabel: x.status === 'ok' ? 'Подключено' : 'Ошибка', stColor: x.status === 'ok' ? 'var(--ok)' : 'var(--danger)', accepts: x.accepts, err: x.status === 'error' ? x.msg : '', bg: sel ? 'var(--accent-soft)' : 'var(--surface2)', border: sel ? 'var(--accent)' : 'var(--line)', onClick: () => this.setState({ srcSel: x.id, testRes: null }) }; }),
name: src.name, server: src.server, exp: src.export, opts: src.opts, path: src.path, isNfs: src.type === 'nfs', isLocal: src.type === 'local',
onName: e => upd('name', e.target.value), onServer: e => upd('server', e.target.value), onExp: e => upd('export', e.target.value), onOpts: e => upd('opts', e.target.value), onPath: e => upd('path', e.target.value),
types: [seg(src.type === 'nfs', 'Сетевая папка NFS', () => upd('type', 'nfs')), seg(src.type === 'local', 'Папка на сервере', () => upd('type', 'local'))],
acc: tg(src.accepts), onAcc: () => upd('accepts', !src.accepts),
testLabel: s.testing ? 'Проверяем…' : 'Проверить подключение',
onTest: () => { this.setState({ testing: true, testRes: null }); setTimeout(() => { const ok = src.status === 'ok'; this.setState({ testing: false, testRes: ok ? { icon: 'check_circle', c: 'var(--ok)', b: 'var(--ok-soft)', text: 'Подключено: чтение OK' + (src.accepts ? ', запись OK' : '') + ' · в первых 50 каталогах найдено 1 248 аудиофайлов' } : { icon: 'error', c: 'var(--danger)', b: 'var(--danger-soft)', text: 'Не удалось смонтировать ' + src.server + ':' + src.export + ' — сервер не отвечает (timeo=150)' } }); }, 1100); },
testRes: s.testRes,
patterns: s.patterns.map((p, i) => {
const re = res[i];
return { n: i + 1, text: p, tokens: D.tokens(p), err: re instanceof Error ? re.message.replace(/^Invalid regular expression: /, '') : '', border: s.dragIdx === i ? 'var(--accent)' : (re instanceof Error ? 'var(--danger)' : 'var(--line2)'),
onChange: e => { const v = e.target.value; this.setState(st => ({ patterns: st.patterns.map((x, j) => j === i ? v : x), tplChecked: false })); },
onDel: () => this.setState(st => ({ patterns: st.patterns.filter((x, j) => j !== i) })),
onDragStart: e => { e.dataTransfer.effectAllowed = 'move'; this.setState({ dragIdx: i }); }, onDragOver: e => e.preventDefault(), onDragEnd: () => this.setState({ dragIdx: null }),
onDrop: e => { e.preventDefault(); const from = this.state.dragIdx; if (from == null || from === i) return this.setState({ dragIdx: null }); const a = [...this.state.patterns]; const [x] = a.splice(from, 1); a.splice(i, 0, x); this.setState({ patterns: a, dragIdx: null }); } };
}),
onAddPattern: () => this.setState(st => ({ patterns: [...st.patterns, '^(?P<artist>[^/]+)/(?P<album>[^/]+)/(?P<title>[^/]+)\\.\\w+$'] })),
prev, prevSummary: (prev.length - prev.filter(r => r.none).length) + ' из ' + prev.length + ' путей распознаны',
tags: [seg(s.tagPrio === 'tags', 'Теги файла важнее', () => this.setState({ tagPrio: 'tags' })), seg(s.tagPrio === 'path', 'Структура папок важнее', () => this.setState({ tagPrio: 'path' }))],
tpl: s.writeTpl, onTpl: e => this.setState({ writeTpl: e.target.value, tplChecked: false }),
onAutoTpl: () => { this.setState({ writeTpl: DEF_TPL, tplChecked: false }); this.toastMsg('Шаблон подобран: выражение #1 описывает 94% файлов хранилища'); },
onCheckTpl: () => this.setState({ tplChecked: true }), checked: s.tplChecked, chk,
chkSummary: okN + ' из ' + chk.length + ' путей совпали (' + pct + '%)', chkColor: pct >= 90 ? 'var(--ok)' : 'var(--warn)', chkBg: pct >= 90 ? 'var(--ok-soft)' : 'var(--warn-soft)',
chkNote: pct >= 90 ? 'Новые файлы лягут так же, как существующие' : 'Ниже порога 90%: часть хранилища разложена по другой схеме — новые файлы пойдут по шаблону выше',
onSave: () => { this.setState({ scan: { pct: 0, running: true } }); this.toastMsg('Сохранено — запущено полное пересканирование «' + src.name + '»'); }
};
}
if (pg === 'library') {
const sc = s.scan, tot = 11284;
o.lib = {
stats: [['Треков', '11 284', '+38 за неделю'], ['Исполнителей', '1 046', ''], ['Альбомов', '2 318', ''], ['Объём', '412 ГБ', 'в 3 хранилищах']].map(([l, v, n]) => ({ l, v, n })),
scanPct: sc.pct.toFixed(1) + '%', scanW: sc.pct + '%', scanFiles: Math.round(sc.pct / 100 * tot).toLocaleString('ru') + ' / ' + tot.toLocaleString('ru') + ' файлов', scanRunning: sc.running,
scanLabel: sc.running ? 'Сканирование «NAS — музыка»' : 'Сканирование завершено', scanEta: sc.running ? 'осталось ~' + Math.max(1, Math.round((100 - sc.pct) / 8)) + ' мин' : 'новых: 38 · переименовано: 4 · пропало: 0',
anaPct: Math.round(s.ana / tot * 100) + '%', anaW: (s.ana / tot * 100) + '%', anaFiles: s.ana.toLocaleString('ru') + ' / ' + tot.toLocaleString('ru') + ' треков', anaEta: '~1 ч 10 мин · 4 воркера',
onRescan: () => this.setState({ scan: { pct: 0, running: true } }),
metrics: [['Дослушивания', '74%', 'цель > 70%'], ['Ранние пропуски', '17%', 'цель < 20%'], ['Лайков на 100 треков', '6,2', 'растёт'], ['Открытия', '28%', 'цель 20–40%'], ['Медиана сессии', '42 мин', 'растёт']].map(([l, v, n]) => ({ l, v, n })),
errors: [['Radiohead/2003 - Hail to the Thief/07 - Sit Down. Stand Up.flac', 'ffprobe: повреждён заголовок FLAC', 'Сканер'], ['Земфира/2002 - 14 недель тишины/05 - Infinity.mp3', 'Теги ID3v2.4 не читаются — взяты из пути', 'Сканер'], ['Сплин/1999 - Альтависта/09 - Романс.flac', 'Таймаут анализа (120 с)', 'Аудиоанализ'], ['Старый NAS', 'Хранилище недоступно: 2 140 треков скрыты', 'Хранилище']].map(([p, e, w]) => ({ p, e, w }))
};
}
if (pg === 'upload') {
const act = s.uploads.filter(u => ['uploading', 'processing', 'pending_review'].includes(u.status));
const used = 6.2 + act.reduce((a, u) => a + u.size, 0) / 1024, lim = s.adm.limit, pct = Math.min(100, used / lim * 100);
o.up = {
usedStr: used.toFixed(1).replace('.', ','), limStr: String(lim), freeStr: Math.max(0, lim - used).toFixed(1).replace('.', ','), pct: pct + '%', barC: pct > 85 ? 'var(--warn)' : 'var(--accent)',
dzBorder: s.dragOver ? 'var(--accent)' : 'var(--line2)', dzBg: s.dragOver ? 'var(--accent-soft)' : 'var(--surface2)',
onDragOver: e => { e.preventDefault(); if (!this.state.dragOver) this.setState({ dragOver: true }); }, onDragLeave: () => this.setState({ dragOver: false }), onDrop: e => { e.preventDefault(); this.addBatch(); }, onPick: () => this.addBatch(),
hasCur: s.uploads.length > 0,
cur: s.uploads.map(u => ({ name: u.path.split('/').pop(), folder: u.path.includes('/') ? u.path.split('/')[0] : 'Отдельный файл', size: u.size + ' МБ', ...stb(u.status), isUp: u.status === 'uploading', w: u.progress + '%', note: u.dupOf, hasNote: !!u.dupOf, op: u.status === 'duplicate' ? 0.7 : 1,
canW: u.status === 'pending_review', onW: () => this.setState(st => ({ uploads: st.uploads.map(x => x.id === u.id ? { ...x, status: 'withdrawn' } : x) })) })),
props: s.proposals.map(p => ({ name: p.name, meta: p.files + ' ' + plural(p.files, ['файл', 'файла', 'файлов']) + ' · ' + p.size + ' · ' + p.date, ...stb(p.status), reason: p.reason || '', hasReason: !!p.reason, canW: p.status === 'pending_review',
onW: () => { this.setState(st => ({ proposals: st.proposals.map(x => x.id === p.id ? { ...x, status: 'withdrawn' } : x) })); this.toastMsg('Предложение отозвано — место в очереди освобождено'); } }))
};
}
if (pg === 'queue') {
const flt = s.qFilter, all = s.qGroups.flatMap(g => g.tracks);
const groups = s.qGroups.map(g => ({ ...g, vis: g.tracks.filter(t => flt === 'all' || t.flag === flt) })).filter(g => g.vis.length);
const sel = Object.keys(s.qSel).filter(k => s.qSel[k]);
const gUpd = (gid, k) => e => { const v = e.target.value; this.setState(st => ({ qGroups: st.qGroups.map(x => x.id === gid ? { ...x, [k]: v } : x) })); };
const FL = { dup: ['Возможно, уже есть', 'var(--warn)', 'var(--warn-soft)', 'content_copy'], better: ['Лучшее качество', 'var(--ok)', 'var(--ok-soft)', 'high_quality'] };
const cur = groups.find(g => g.id === s.qGroup) || groups[0];
const mapG = g => {
const ids = g.vis.map(t => t.id), allOn = ids.every(id => s.qSel[id]);
const size = g.tracks.reduce((a, t) => a + t.size, 0);
const tracks = g.vis.map(t => {
const qp = this.qPath(g, t), on = !!s.qSel[t.id], pl = s.qPlay === t.id, fl = FL[t.flag];
return { id: t.id, n: String(t.n).padStart(2, '0'), title: t.title, size: t.size + ' МБ', ...box(on), onSel: () => this.setState(st => ({ qSel: { ...st.qSel, [t.id]: !st.qSel[t.id] } })),
onTitle: e => { const v = e.target.value; this.setState(st => ({ qGroups: st.qGroups.map(x => x.id === g.id ? { ...x, tracks: x.tracks.map(y => y.id === t.id ? { ...y, title: v } : y) } : x) })); },
hasFlag: !!fl, flagLabel: fl ? fl[0] : '', flagColor: fl ? fl[1] : '', flagBg: fl ? fl[2] : '', flagIcon: fl ? fl[3] : '', note: t.note, hasNote: !!t.note,
playIcon: pl ? 'pause' : 'play_arrow', playBg: pl ? 'var(--accent)' : 'var(--surface3)', playC: pl ? '#fff' : 'var(--text)', onPlay: () => this.setState({ qPlay: pl ? null : t.id, playing: false }),
segs: [{ t: '…/', c: 'var(--text3)' }, ...qp.parts.slice(0, -1).map((x, i) => ({ t: x + '/', c: (i === 0 ? qp.artEx : qp.albEx) ? 'var(--text2)' : 'var(--accent2)' })), { t: qp.parts[qp.parts.length - 1], c: 'var(--text)' }],
onApprove: () => this.approve([t.id]), onReject: () => this.setState({ dlg: 'reject', rejectIds: [t.id] }) };
});
const qp0 = this.qPath(g, g.tracks[0]);
const tree = [{ pad: '0px', icon: 'hard_drive', t: 'NAS — музыка · /volume1/music', c: 'var(--text3)', note: '' }];
qp0.parts.slice(0, -1).forEach((x, i) => { const ex = i === 0 ? qp0.artEx : qp0.albEx; tree.push({ pad: (i + 1) * 20 + 'px', icon: ex ? 'folder' : 'create_new_folder', t: x + '/', c: ex ? 'var(--text2)' : 'var(--accent2)', note: ex ? 'уже есть' : 'будет создана' }); });
g.tracks.slice(0, 4).forEach(t => { const p = this.qPath(g, t).parts; tree.push({ pad: qp0.parts.length * 20 + 'px', icon: 'music_note', t: p[p.length - 1], c: 'var(--text)', note: '' }); });
if (g.tracks.length > 4) tree.push({ pad: qp0.parts.length * 20 + 'px', icon: 'more_horiz', t: 'ещё ' + (g.tracks.length - 4), c: 'var(--text3)', note: '' });
const isSel = cur && cur.id === g.id;
return { id: g.id, artist: g.artist, album: g.album, year: g.year, aw: 'calc(' + Math.max(3, g.artist.length + 1) + 'ch + 20px)', alw: 'calc(' + Math.max(3, g.album.length + 1) + 'ch + 20px)', cover: `radial-gradient(circle at 28% 22%, rgba(255,255,255,.3), rgba(255,255,255,0) 55%), linear-gradient(140deg, ${g.c1}, ${g.c2})`, by: g.by, when: g.when, note: g.note, quality: g.fmt + ' · ' + g.q,
count: g.tracks.length + ' ' + plural(g.tracks.length, ['трек', 'трека', 'треков']), sizeStr: size >= 1024 ? (size / 1024).toFixed(1) + ' ГБ' : size + ' МБ',
onArtist: gUpd(g.id, 'artist'), onAlbum: gUpd(g.id, 'album'), onYear: gUpd(g.id, 'year'), ...box(allOn),
onSelAll: () => this.setState(st => { const q = { ...st.qSel }; ids.forEach(id => { q[id] = !allOn; }); return { qSel: q }; }),
onApprove: () => this.approve(ids), onReject: () => this.setState({ dlg: 'reject', rejectIds: ids }),
tracks, tree, bg: isSel ? 'var(--accent-soft)' : 'transparent', border: isSel ? 'var(--accent)' : 'var(--line)', onPick: () => this.setState({ qGroup: g.id }) };
};
const mg = groups.map(mapG);
o.qq = {
grouped: (P.queueLayout || 'grouped') === 'grouped', split: P.queueLayout === 'split',
filters: [['all', 'Все', all.length], ['dup', 'Возможные дубли', all.filter(t => t.flag === 'dup').length], ['better', 'Лучшее качество', all.filter(t => t.flag === 'better').length]].map(([k, l, c]) => ({ label: l, count: c, onClick: () => this.setState({ qFilter: k }), bg: flt === k ? 'var(--text)' : 'transparent', color: flt === k ? 'var(--bg)' : 'var(--text)', border: flt === k ? 'var(--text)' : 'var(--line2)' })),
summary: all.length + ' ' + plural(all.length, ['трек', 'трека', 'треков']) + ' · ' + s.qGroups.length + ' ' + plural(s.qGroups.length, ['пачка', 'пачки', 'пачек']) + ' на рассмотрении',
groups: mg, cur: mg.find(g => g.id === (cur && cur.id)) || null, hasCur: !!cur, empty: !mg.length,
hasSel: sel.length > 0, selLabel: 'Выбрано: ' + sel.length,
onBulkApprove: () => this.approve(sel), onBulkReject: () => this.setState({ dlg: 'reject', rejectIds: sel }), onClearSel: () => this.setState({ qSel: {} })
};
}
if (pg === 'aset') {
const a = s.adm, upd = (k, v) => this.setState(st => ({ adm: { ...st.adm, [k]: v } }));
o.as = {
limit: a.limit, onLimit: e => upd('limit', Math.max(1, +e.target.value || 1)), perUser: tg(a.perUser), onPerUser: () => upd('perUser', !a.perUser), showPer: a.perUser,
perUserLimit: a.perUserLimit, onPerUserLimit: e => upd('perUserLimit', Math.max(1, +e.target.value || 1)), maxFile: a.maxFile, onMaxFile: e => upd('maxFile', Math.max(1, +e.target.value || 1)),
upgrade: tg(a.upgrade), onUpgrade: () => upd('upgrade', !a.upgrade), defSrc: a.defSrc, onDefSrc: e => upd('defSrc', e.target.value),
srcOpts: s.sources.filter(x => x.accepts || x.id === a.defSrc).map(x => ({ v: x.id, l: x.name })),
onSave: () => this.toastMsg('Настройки сохранены')
};
}
const f = s.f;
o.dlg = { create: s.dlg === 'create', temp: s.dlg === 'temp', reject: s.dlg === 'reject', any: !!s.dlg, close: () => this.setState({ dlg: null }), stop: e => e.stopPropagation() };
if (s.dlg === 'create') {
o.nu = { roles: [seg((s.nuRole || 'user') === 'user', 'Пользователь', () => this.setState({ nuRole: 'user' })), seg(s.nuRole === 'moderator', 'Модератор', () => this.setState({ nuRole: 'moderator' }))], canRole: isAdmin,
err: s.err.nuLogin || '', border: s.err.nuLogin ? 'var(--danger)' : 'var(--line2)',
onCreate: () => {
const l = (f.nuLogin || '').trim();
if (!/^[a-z0-9._-]{2,}$/.test(l)) return this.setState({ err: { nuLogin: 'Латиница, цифры и . _ - — минимум 2 символа' } });
if (s.users.some(u => u.login === l)) return this.setState({ err: { nuLogin: 'Такой логин уже есть' } });
const name = (f.nuName || '').trim() || l;
this.setState({ users: [...s.users, { id: Date.now(), name, login: l, role: isAdmin ? (s.nuRole || 'user') : 'user', status: 'mustchange', last: '—' }], dlg: 'temp', tempPw: genPw(), tempFor: name, err: {} });
} };
}
if (s.dlg === 'temp') o.tp = { pw: s.tempPw, who: s.tempFor, onCopy: () => { try { navigator.clipboard.writeText(s.tempPw); } catch (e) {} this.toastMsg('Временный пароль скопирован'); } };
if (s.dlg === 'reject') {
const n = s.rejectIds.length;
o.rj = { title: 'Отклонить ' + n + ' ' + plural(n, ['трек', 'трека', 'треков']), reason: f.reason || '',
quick: ['Низкое качество', 'Уже есть в библиотеке', 'Неполный альбом', 'Неверные метаданные'].map(r => ({ label: r, onClick: () => this.setState({ f: { ...f, reason: r } }), bg: f.reason === r ? 'var(--accent-soft)' : 'transparent', border: f.reason === r ? 'var(--accent)' : 'var(--line2)' })),
onConfirm: () => this.approve(s.rejectIds, 'reject') };
}
o.closeMenus = () => { if (s.uMenu) this.setState({ uMenu: null }); };
return o;
}
ambient(c3) {
if (!c3) return null;
const blob = (k, bg, size, anim, pos) => React.createElement('div', { key: k, style: { position: 'absolute', ...pos, width: size, height: size, borderRadius: '50%', background: `radial-gradient(circle, ${bg} 0%, transparent 62%)`, filter: 'blur(30px)', animation: anim, willChange: 'transform' } });
return React.createElement('div', { 'aria-hidden': true, style: { position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none', opacity: 'var(--amb-op)', zIndex: 0, animation: 'pipuHue 30s linear infinite' } }, [
blob('a', 'rgba(47,128,255,.75)', '70vmax', 'pipuDrift1 16s ease-in-out infinite alternate', { left: '-22vmax', top: '-28vmax' }),
blob('b', 'rgba(124,92,255,.6)', '60vmax', 'pipuDrift2 21s ease-in-out infinite alternate', { right: '-20vmax', top: '8vh' }),
blob('c', c3 + '99', '55vmax', 'pipuDrift3 26s ease-in-out infinite alternate', { left: '22vw', bottom: '-32vmax' })
]);
}
renderVals() {
const D = window.PIPU, s = this.state, P = this.props;
const theme = s.theme || P.theme || 'dark';
const base = { theme, shell: P.shell || 'classic', playerV: P.player || 'bar', waveVariant: P.waveVariant || 'orb', ambient: this.ambient(P.ambient === false ? null : (s.ready && D && s.playing ? D.ALB[D.TR[s.trackId].albumId].c1 : '#35D3FF')) };
if (!s.ready || !D) return { ...base, isAuth: false, isApp: false };
const { ART, ALB, TR, fmt, plural } = D;
const role = P.role || 'admin', isAdmin = role === 'admin', isMod = role === 'moderator';
const me = isAdmin ? { name: 'Алексей Морозов', login: 'admin', ini: 'АМ', role: 'Администратор' } : isMod ? { name: 'Илья Сафонов', login: 'ilya', ini: 'ИС', role: 'Модератор' } : { name: 'Анна Белова', login: 'anna', ini: 'АБ', role: 'Пользователь' };
const t = TR[s.trackId], al = ALB[t.albumId], ar = ART[t.artistId];
const eq = React.createElement('div', { style: { display: 'flex', gap: 2, alignItems: 'flex-end', height: 14 } }, [0, 1, 2].map(i => React.createElement('span', { key: i, style: { width: 3, height: 14, borderRadius: 2, background: 'var(--accent2)', transformOrigin: 'bottom', animation: `pipuEq ${0.55 + i * 0.17}s ease-in-out ${i * 0.1}s infinite alternate`, animationPlayState: s.playing ? 'running' : 'paused' } })));
const row = (id, i, list, label, extra = {}) => {
const tr = TR[id], isCur = s.trackId === id, liked = s.likes.includes(id);
return {
id, num: extra.num ?? i + 1, title: tr.title, artist: ART[tr.artistId].name, album: ALB[tr.albumId].title, dur: fmt(tr.dur), cover: ALB[tr.albumId].cover,
isCur, notCur: !isCur, eq, op: extra.op ?? 1, titleColor: isCur ? 'var(--accent2)' : 'var(--text)', likeColor: liked ? 'var(--accent2)' : 'var(--text3)', likeFill: liked ? F1 : F0,
bg: isCur ? 'var(--accent-soft)' : 'transparent',
onPlay: extra.onPlay || (() => this.playList(list, i, label)),
onLike: e => { e.stopPropagation(); this.toggleLike(id); },
onUndo: e => { e.stopPropagation(); this.toggleDislike(id); },
onArtist: e => { e.stopPropagation(); this.go('artist', { id: tr.artistId }); },
onAlbum: e => { e.stopPropagation(); this.go('album', { id: tr.albumId }); }
};
};
const seg = (on, label, onClick, extra = {}) => ({ label, onClick, bg: on ? 'var(--accent)' : 'transparent', color: on ? '#fff' : 'var(--text2)', ...extra });
const langs = [seg(s.lang === 'ru', 'RU', () => this.setState({ lang: 'ru' }), { full: 'Русский' }), seg(s.lang === 'en', 'EN', () => this.setState({ lang: 'en' }), { full: 'English' })];
const err = s.err || {}, errB = {};
['nlogin', 'np', 'np2', 'cur', 'sp', 'sp2'].forEach(k => { errB[k] = err[k] ? 'var(--danger)' : 'var(--line2)'; });
const fh = {}; ['login', 'pass', 'nlogin', 'np', 'np2', 'cur', 'sp', 'sp2', 'nuLogin', 'nuName', 'reason'].forEach(k => { fh[k] = this.fh(k); });
const common = { ...base, langs, f: s.f, fh, err, errB, onLogout: () => this.setState({ auth: 'login', playing: false, profileOpen: false, f: {}, err: {} }) };
if (s.auth) {
const adm = s.auth === 'setupAdmin';
return {
...common, isAuth: true, isApp: false,
auth: { login: s.auth === 'login', setup: s.auth !== 'login', setupAdmin: adm },
pwType: s.showPw ? 'text' : 'password', pwIcon: s.showPw ? 'visibility_off' : 'visibility', toggleShowPw: () => this.setState({ showPw: !s.showPw }),
onLogin: () => this.doLogin(), onLoginKey: e => { if (e.key === 'Enter') this.doLogin(); },
setupBadge: adm ? 'Первый вход администратора' : 'Пароль сброшен', setupBadgeIcon: adm ? 'shield_person' : 'lock_reset',
setupTitle: adm ? 'Смените стандартные логин и пароль' : 'Задайте новый пароль',
setupText: adm ? 'Без этого шага доступ к серверу закрыт. Стандартные данные admin / admin знает кто угодно.' : 'Администратор сбросил ваш пароль. Придумайте новый — временный больше не подойдёт.',
strength: this.strength(s.f.np), onSetup: () => this.doSetup()
};
}
const pg = { [s.page]: true }; pg.collection = ['history', 'likes', 'dislikes'].includes(s.page);
const navItem = (key, label, icon, extra = {}) => { const on = s.page === key; return { label, icon, count: '', badge: null, onClick: () => this.go(key), bg: on ? 'var(--accent-soft)' : 'transparent', color: on ? 'var(--text)' : 'var(--text2)', ic: on ? 'var(--accent2)' : 'var(--text3)', fill: on ? F1 : F0, ...extra }; };
const pending = s.qGroups.reduce((a, g) => a + g.tracks.length, 0);
const navAdm = [navItem('users', 'Пользователи', 'group')];
if (isAdmin) navAdm.push(navItem('sources', 'Хранилища', 'hard_drive'));
navAdm.push(navItem('library', 'Библиотека', 'monitoring'), navItem('upload', 'Предложить треки', 'upload'));
if (isAdmin) navAdm.push(navItem('queue', 'Очередь', 'inbox', { badge: pending || null }), navItem('aset', 'Настройки', 'tune'));
const actLbl = s.activity && D.ACT.find(a => a[0] === s.activity)[1], moodRec = s.mood && D.MOOD.find(m => m[0] === s.mood);
const ctxLabel = [actLbl, moodRec && moodRec[1]].filter(Boolean).join(' · ') || 'Под ваш вкус';
const waveIsPlaying = s.waveOn && s.playing;
const wc = moodRec ? { c1: moodRec[4], c2: moodRec[5], c3: moodRec[6] } : { c1: '#2F80FF', c2: '#7C5CFF', c3: '#35D3FF' };
if (waveIsPlaying) wc.c2 = al.c1;
const chip = (on, icon, label, onClick) => ({ label, icon, onClick, bg: on ? 'var(--accent)' : 'var(--surface2)', color: on ? '#fff' : 'var(--text)', border: on ? 'var(--accent)' : 'var(--line)', ic: on ? '#fff' : 'var(--text2)', shadow: on ? 'var(--glow)' : 'none' });
const srcLabel = s.waveOn ? (s.source.artistId ? 'Волна по исполнителю · ' + ART[s.source.artistId].name : 'Моя волна · ' + ctxLabel) : s.source.label;
const liked = s.likes.includes(t.id), dis = s.dislikes.includes(t.id);
const vol = s.muted ? 0 : s.vol;
const out = {
...common, isAuth: false, isApp: true, me, pg, showAdmin: isAdmin || isMod,
homeCenter: (P.homeLayout || 'center') === 'center', homeSplit: P.homeLayout === 'split',
isBar: (P.player || 'bar') === 'bar', isIsland: P.player === 'island',
nav: { home: () => this.go('home'), settings: () => this.go('settings') },
navTop: [navItem('home', 'Главная', 'home')],
navCol: [navItem('history', 'История', 'history'), navItem('likes', 'Лайки', 'favorite', { count: s.likes.length }), navItem('dislikes', 'Дизлайки', 'heart_broken', { count: s.dislikes.length })],
navAdm,
cur: { title: t.title, artist: ar.name, cover: al.cover, bpm: t.bpm, energy: t.energy, likeColor: liked ? 'var(--accent2)' : 'var(--text2)', likeFill: liked ? F1 : F0, disColor: dis ? 'var(--danger)' : 'var(--text2)', disFill: dis ? F1 : F0,
onLike: () => this.toggleLike(t.id), onDislike: () => this.toggleDislike(t.id), onArtist: () => this.go('artist', { id: ar.id }), onAlbum: () => this.go('album', { id: al.id }) },
waveOn: s.waveOn, waveIsPlaying, waveC: wc, waveIcon: waveIsPlaying ? 'pause' : 'play_arrow', ctxLabel, hasCtx: !!(s.activity || s.mood),
clearCtx: () => { this.setState({ activity: null, mood: null }); },
onWave: () => this.onWave(),
activities: D.ACT.map(a => chip(s.activity === a[0], a[2], a[1], () => this.setCtx('activity', a[0]))),
moods: D.MOOD.map(m => chip(s.mood === m[0], m[2], m[1], () => this.setCtx('mood', m[0]))),
playIcon: s.playing ? 'pause' : 'play_arrow', togglePlay: () => this.togglePlay(), next: () => this.next(), prev: () => this.prev(),
posStr: fmt(s.pos), durStr: fmt(t.dur), pct: (s.pos / t.dur * 100).toFixed(2) + '%',
onSeek: e => { const b = e.currentTarget.getBoundingClientRect(); this.setState({ pos: D.clamp((e.clientX - b.left) / b.width, 0, 0.999) * t.dur }); },
onVol: e => { const b = e.currentTarget.getBoundingClientRect(); this.setState({ vol: D.clamp((e.clientX - b.left) / b.width, 0, 1), muted: false }); },
volPct: vol * 100 + '%', volIcon: vol === 0 ? 'volume_off' : vol < 0.5 ? 'volume_down' : 'volume_up', toggleMute: () => this.setState({ muted: !s.muted }),
srcLabel, srcIcon: s.waveOn ? 'graphic_eq' : 'album', srcColor: s.waveOn ? 'var(--accent2)' : 'var(--text3)',
queueOpen: s.queueOpen, toggleQueue: () => this.setState({ queueOpen: !s.queueOpen, profileOpen: false }), queueColor: s.queueOpen ? 'var(--accent2)' : 'var(--text2)',
drawerBottom: (P.player || 'bar') === 'bar' ? '100px' : '8px', toastBottom: (P.player || 'bar') === 'bar' ? '112px' : '120px',
queueRows: s.queue.slice(s.qi, s.qi + 40).map((id, k) => row(id, k, s.queue, '', { onPlay: () => this.setTrack(s.qi + k, s.queue) })),
profileOpen: s.profileOpen, toggleProfile: () => this.setState({ profileOpen: !s.profileOpen, queueOpen: false }),
toggleTheme: () => this.setState({ theme: theme === 'dark' ? 'light' : 'dark' }), themeIcon: theme === 'dark' ? 'light_mode' : 'dark_mode', themeLabel: theme === 'dark' ? 'Светлая тема' : 'Тёмная тема',
themes: [seg(theme === 'dark', 'Тёмная', () => this.setState({ theme: 'dark' })), seg(theme === 'light', 'Светлая', () => this.setState({ theme: 'light' }))],
crossfade: s.crossfade, onCrossfade: e => this.setState({ crossfade: +e.target.value }),
normT: { bg: s.normalize ? 'var(--accent)' : 'var(--line2)', x: s.normalize ? '21px' : '3px' }, toggleNormalize: () => this.setState({ normalize: !s.normalize }),
onChangePw: () => { const f = s.f; if (!f.cur) return this.setState({ err: { sp: 'Введите текущий пароль', cur: 1 } }); if ((f.sp || '').length < 8) return this.setState({ err: { sp: 'Новый пароль — минимум 8 символов', sp: 'Новый пароль — минимум 8 символов' } }); if (f.sp !== f.sp2) return this.setState({ err: { sp: 'Пароли не совпадают', sp2: 1 } }); this.setState({ f: { ...f, cur: '', sp: '', sp2: '' }, err: {} }); this.toastMsg('Пароль изменён. Другие сессии завершены'); },
toast: s.toast, mainRef: this.mainRef, searchRef: this.searchRef, q: s.q
};
// search dropdown
const sr = s.q.trim() ? D.search(s.q) : null, items = [];
const mk = (title, sub, img, act, extra = {}) => { const it = { title, sub, img, act: e => { e && e.preventDefault && e.preventDefault(); act(); this.setState({ sOpen: false }); this.searchRef.current && this.searchRef.current.blur(); }, ...extra }; items.push(it); return it; };
const sArtists = sr ? sr.artists.slice(0, 3).map(a => mk(a.name, 'Исполнитель · ' + a.genre, a.avatar, () => this.go('artist', { id: a.id }), { ini: ini(a.name) })) : [];
const sAlbums = sr ? sr.albums.slice(0, 3).map(a => mk(a.title, ART[a.artistId].name + ' · ' + a.year, a.cover, () => this.go('album', { id: a.id }))) : [];
const sTracks = sr ? sr.tracks.slice(0, 5).map(id => mk(TR[id].title, ART[TR[id].artistId].name, ALB[TR[id].albumId].cover, () => this.playList(sr.tracks, sr.tracks.indexOf(id), 'Поиск · ' + s.q), { dur: fmt(TR[id].dur) })) : [];
items.forEach((it, i) => { it.bg = i === s.sIdx ? 'var(--hover)' : 'transparent'; });
this._sItems = items;
Object.assign(out, {
showDrop: s.sOpen && !!s.q.trim(), sArtists, sAlbums, sTracks, sHas: { artists: sArtists.length > 0, albums: sAlbums.length > 0, tracks: sTracks.length > 0 }, sNone: !!sr && !items.length, sFixed: sr && sr.fixed,
sBorder: s.sOpen ? 'var(--accent)' : 'var(--line)',
onSearchInput: e => this.setState({ q: e.target.value, sOpen: true, sIdx: -1 }), onSearchFocus: () => this.setState({ sOpen: true, profileOpen: false }), onSearchBlur: () => this.setState({ sOpen: false }),
onSearchKey: e => {
if (e.key === 'ArrowDown') { e.preventDefault(); this.setState(x => ({ sIdx: Math.min(items.length - 1, x.sIdx + 1) })); }
else if (e.key === 'ArrowUp') { e.preventDefault(); this.setState(x => ({ sIdx: Math.max(-1, x.sIdx - 1) })); }
else if (e.key === 'Enter') { if (s.sIdx >= 0 && items[s.sIdx]) items[s.sIdx].act(); else this.openSearch(); }
else if (e.key === 'Escape') { e.target.blur(); }
},
openSearch: e => { e && e.preventDefault(); this.openSearch(); }
});
if (pg.search) {
const q = s.params.q || '', r = D.search(q), tab = s.searchTab;
const tabs = [['all', 'Всё', ''], ['artists', 'Исполнители', r.artists.length], ['albums', 'Альбомы', r.albums.length], ['tracks', 'Треки', r.tracks.length]].map(([k, l, c]) => ({ label: l, count: c, onClick: () => this.setState({ searchTab: k }), bg: tab === k ? 'var(--text)' : 'transparent', color: tab === k ? 'var(--bg)' : 'var(--text)', border: tab === k ? 'var(--text)' : 'var(--line2)' }));
out.sp = {
q, fixed: r.fixed, tabs,
showArtists: (tab === 'all' || tab === 'artists') && r.artists.length > 0, showAlbums: (tab === 'all' || tab === 'albums') && r.albums.length > 0, showTracks: (tab === 'all' || tab === 'tracks') && r.tracks.length > 0,
none: !r.artists.length && !r.albums.length && !r.tracks.length,
artists: r.artists.map(a => ({ name: a.name, sub: a.genre, img: a.avatar, ini: ini(a.name), onClick: () => this.go('artist', { id: a.id }) })),
albums: r.albums.map(a => ({ title: a.title, sub: ART[a.artistId].name + ' · ' + a.year, img: a.cover, onClick: () => this.go('album', { id: a.id }) })),
tracks: r.tracks.slice(0, tab === 'tracks' ? 50 : 8).map((id, i) => row(id, i, r.tracks, 'Поиск · ' + q))
};
}
if (pg.artist) {
const A = ART[s.params.id] || ar, top = [...A.tracks].sort((a, b) => TR[b].pop - TR[a].pop).slice(0, 6);
out.ap = {
name: A.name, genre: A.genre, img: A.avatar, banner: A.banner, ini: ini(A.name), meta: A.albums.length + ' ' + plural(A.albums.length, ['альбом', 'альбома', 'альбомов']) + ' · ' + A.tracks.length + ' ' + plural(A.tracks.length, ['трек', 'трека', 'треков']),
onWave: () => { this.startWave(A.id); this.toastMsg('Волна по исполнителю ' + A.name); }, onPlay: () => this.playList(top, 0, 'Исполнитель · ' + A.name),
onShuffle: () => { const l = [...A.tracks].sort(() => Math.random() - 0.5); this.playList(l, 0, 'Исполнитель · ' + A.name); },
tracks: top.map((id, i) => row(id, i, top, 'Исполнитель · ' + A.name)),
albums: A.albums.map(id => ({ title: ALB[id].title, sub: ALB[id].year + ' · Альбом', img: ALB[id].cover, onClick: () => this.go('album', { id }) }))
};
}
if (pg.album) {
const B = ALB[s.params.id] || al, A = ART[B.artistId], tot = B.tracks.reduce((x, id) => x + TR[id].dur, 0);
const isThis = !s.waveOn && B.tracks.includes(s.trackId) && s.source.label === 'Альбом · ' + B.title;
out.alp = {
title: B.title, cover: B.cover, artist: A.name, artistImg: A.avatar, year: B.year, tint: `linear-gradient(180deg, ${B.c1}66, transparent)`,
meta: B.tracks.length + ' ' + plural(B.tracks.length, ['трек', 'трека', 'треков']) + ', ' + Math.round(tot / 60) + ' мин',
playIcon: isThis && s.playing ? 'pause' : 'play_arrow',
onPlay: () => isThis ? this.togglePlay() : this.playList(B.tracks, 0, 'Альбом · ' + B.title),
onShuffle: () => { const l = [...B.tracks].sort(() => Math.random() - 0.5); this.playList(l, 0, 'Альбом · ' + B.title); },
onArtist: () => this.go('artist', { id: A.id }),
tracks: B.tracks.map((id, i) => row(id, i, B.tracks, 'Альбом · ' + B.title))
};
}
if (pg.collection) {
const tabs = [['history', 'История'], ['likes', 'Лайки'], ['dislikes', 'Дизлайки']].map(([k, l]) => ({ label: l, onClick: () => this.go(k), color: s.page === k ? 'var(--text)' : 'var(--text2)', border: s.page === k ? 'var(--accent)' : 'transparent' }));
let groups = [], c;
if (s.page === 'history') {
const list = s.history.map(h => h.id), by = [];
s.history.forEach((h, i) => { let g = by.find(x => x.day === h.day); if (!g) { g = { day: h.day, rows: [] }; by.push(g); } g.rows.push(row(h.id, i, list, 'История', { num: h.time })); });
groups = by;
c = { title: 'История', icon: 'history', tile: 'linear-gradient(140deg,#35D3FF,#2F80FF 60%,#1B3FA8)', sub: 'Всё, что вы слушали, по дням. Нажмите на трек, чтобы включить.', canPlay: false, emptyText: 'Здесь появятся прослушанные треки' };
} else if (s.page === 'likes') {
groups = [{ day: '', rows: s.likes.map((id, i) => row(id, i, s.likes, 'Лайки')) }];
c = { title: 'Лайки', icon: 'favorite', tile: 'linear-gradient(140deg,#5E9BFF,#2F80FF 50%,#7C5CFF)', sub: s.likes.length + ' ' + plural(s.likes.length, ['трек', 'трека', 'треков']) + ' · влияют на «Мою волну» сильнее всего', canPlay: s.likes.length > 0, emptyText: 'Ставьте ♥ трекам, чтобы собрать их здесь',
onPlay: () => this.playList(s.likes, 0, 'Лайки'), onShuffle: () => this.playList([...s.likes].sort(() => Math.random() - 0.5), 0, 'Лайки · вперемешку') };
} else {
groups = [{ day: '', rows: s.dislikes.map((id, i) => row(id, i, s.dislikes, 'Дизлайки', { op: 0.6 })) }];
c = { title: 'Дизлайки', icon: 'heart_broken', tile: 'linear-gradient(140deg,#4A5572,#1A2236)', sub: 'Эти треки не попадают в волну и рекомендации. Снимите дизлайк, если передумали.', canPlay: false, emptyText: 'Нет скрытых треков' };
}
out.col = { ...c, tabs, groups: groups.filter(g => g.rows.length), empty: !groups.some(g => g.rows.length), isDis: s.page === 'dislikes', notDis: s.page !== 'dislikes' };
}
if (this.adminVals) Object.assign(out, this.adminVals({ D, s, P, isAdmin, isMod, seg, fh, plural, fmt, eq }));
return out;
}
}
</script>
</body>
</html>