Files
pipu-music/docs/Разработка дизайна приложения/Pipu Android.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

791 lines
95 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;--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,.6);--shadow:0 24px 64px rgba(0,0,0,.55);--glass:rgba(14,19,32,.82);--glow:0 8px 28px rgba(47,128,255,.38);--amb-op:.75}
[data-theme="light"]{--bg:#F3F5FA;--bg2:#EBEEF5;--surface:#FFFFFF;--surface2:#E9EDF5;--surface3:#DDE3EE;--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,.11);--violet:#6A47F0;--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,.35);--shadow:0 24px 64px rgba(20,30,60,.18);--glass:rgba(255,255,255,.86);--glow:0 8px 24px rgba(31,111,242,.28);--amb-op:.42}
@keyframes pipuDrift1{0%{transform:translate(-10%,-6%) 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(8px)}to{opacity:1;transform:none}}
@keyframes pipuUp{from{transform:translateY(100%)}to{transform:none}}
@keyframes pipuSpin{to{transform:rotate(360deg)}}
body{margin:0;background:#05070D;font-family:Onest,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
a{color:#5E9BFF;text-decoration:none}a:hover{color:#EDF1FF}
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:24px;display:inline-block;-webkit-font-smoothing:antialiased;font-feature-settings:'liga';user-select:none;flex-shrink:0}
</style>
</helmet>
<div style="min-height:100vh;display:flex;gap:48px;justify-content:center;align-items:flex-start;padding:40px 32px;box-sizing:border-box;flex-wrap:wrap;font-family:Onest,system-ui,sans-serif;color:#EDF1FF;background:radial-gradient(ellipse at 70% 0%, #0C1428, #05070D 60%)">
<div style="width:300px;display:flex;flex-direction:column;gap:20px;position:sticky;top:40px">
<div style="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 <span style="color:#737F9C;font-weight:500">· Android</span></div>
</div>
<div style="font-size:14px;line-height:1.55;color:#A0ABC6;text-wrap:pretty">Кликабельный прототип. Внутри телефона всё работает: волна, плеер, загрузки, обновление. Слева — быстрый переход к экранам.</div>
<div style="display:flex;flex-direction:column;gap:2px">
<sc-for list="{{ jumps }}" as="j" hint-placeholder-count="12">
<button onClick="{{ j.onClick }}" style="display:flex;align-items:center;gap:12px;height:38px;padding:0 12px;border-radius:10px;font-size:14px;font-weight:500;text-align:left;background:{{ j.bg }};color:{{ j.color }}" style-hover="background:rgba(120,150,255,.08);color:#EDF1FF"><span style="width:20px;font-family:'JetBrains Mono';font-size:12px;color:#737F9C">{{ j.n }}</span>{{ j.label }}</button>
</sc-for>
</div>
<sc-if value="{{ note }}">
<div style="padding:14px 16px;border-radius:14px;background:rgba(47,128,255,.10);border:1px solid rgba(47,128,255,.25);font-size:13px;line-height:1.55;color:#C4CEE6;text-wrap:pretty">{{ note }}</div>
</sc-if>
</div>
<x-import component-from-global-scope="AndroidDevice" from="./android-frame.jsx" dark="{{ isDark }}" bg="{{ deviceBg }}" hint-size="412px,892px">
<div data-theme="{{ theme }}" style="position:absolute;inset:0;background:var(--bg);color:var(--text);font-family:Onest,system-ui,sans-serif;overflow:hidden;display:flex;flex-direction:column">
{{ ambient }}
<sc-if value="{{ scr.connect }}">
<div style="position:relative;z-index:1;flex:1;display:flex;flex-direction:column;gap:24px;padding:40px 24px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:16px 0 8px">
<div style="width:72px;height:72px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8);box-shadow:0 0 40px rgba(47,128,255,.55)"></div>
<div style="font-family:Unbounded;font-weight:600;font-size:28px;letter-spacing:-0.02em">pipu</div>
<div style="font-size:15px;color:var(--text2);text-align:center;text-wrap:pretty">Подключитесь к своему серверу Pipu Music</div>
</div>
<label style="display:flex;flex-direction:column;gap:8px">
<span style="font-size:13px;font-weight:500;color:var(--text2)">Адрес сервера</span>
<div style="display:flex;align-items:center;gap:10px;height:56px;padding:0 8px 0 16px;border-radius:16px;background:var(--surface2);border:1px solid {{ srv.border }}">
<i style="font-size:22px;color:var(--text3)">dns</i>
<input value="{{ srv.addr }}" onChange="{{ srv.onAddr }}" placeholder="music.example.ru" style="flex:1;min-width:0;height:100%;background:none;border:0;outline:none;font-size:16px;font-family:'JetBrains Mono'">
</div>
</label>
<sc-if value="{{ srv.checking }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--surface2);font-size:14px;color:var(--text2)"><i style="font-size:20px;animation:pipuSpin 1s linear infinite">progress_activity</i>Проверяем сервер…</div>
</sc-if>
<sc-if value="{{ srv.ok }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--ok-soft);animation:pipuIn .2s ease-out"><i style="color:var(--ok)">check_circle</i><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px">Pipu Music · сервер 0.6.1</span><span style="font-size:13px;color:var(--text2)">HTTPS · версия приложения поддерживается</span></div></div>
</sc-if>
<sc-if value="{{ srv.err }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--danger-soft);color:var(--danger);font-size:14px;font-weight:500"><i>error</i>Сервер не отвечает. Проверьте адрес и сеть.</div>
</sc-if>
<div style="display:flex;flex-direction:column;gap:8px">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Сохранённые серверы</span>
<sc-for list="{{ srv.profiles }}" as="p" hint-placeholder-count="2">
<button onClick="{{ p.onClick }}" style="display:flex;align-items:center;gap:14px;min-height:60px;padding:8px 14px;border-radius:14px;background:{{ p.bg }};border:1px solid {{ p.border }};text-align:left">
<i style="color:var(--text2)">{{ p.icon }}</i>
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500;font-size:15px">{{ p.name }}</span><span style="font-size:13px;color:var(--text2);font-family:'JetBrains Mono'">{{ p.addr }}</span></div>
</button>
</sc-for>
</div>
<div style="flex:1"></div>
<div style="display:flex;gap:10px">
<button onClick="{{ srv.onCheck }}" style="flex:1;height:52px;border-radius:26px;border:1px solid var(--line2);font-weight:600;font-size:15px">Проверить</button>
<button onClick="{{ srv.onNext }}" style="flex:1;height:52px;border-radius:26px;background:{{ srv.nextBg }};color:{{ srv.nextColor }};font-weight:600;font-size:15px">Далее</button>
</div>
</div>
</sc-if>
<sc-if value="{{ scr.login }}">
<div style="position:relative;z-index:1;flex:1;display:flex;flex-direction:column;gap:22px;padding:20px 24px 24px;overflow:auto">
<div style="display:flex;align-items:center;justify-content:space-between">
<button onClick="{{ toConnect }}" style="display:flex;align-items:center;gap:6px;height:36px;padding:0 12px 0 8px;border-radius:18px;background:var(--surface2);font-size:13px;font-family:'JetBrains Mono';color:var(--text2)"><i style="font-size:18px">dns</i>{{ srv.addr }}</button>
<div style="display:flex;gap:2px;padding:3px;border-radius:12px;background:var(--surface2)">
<sc-for list="{{ langs }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 12px;border-radius:9px;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;padding-top:36px">
<div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Вход</div>
<div style="font-size:15px;line-height:1.5;color:var(--text2)">Учётную запись выдаёт администратор сервера</div>
</div>
<label style="display:flex;flex-direction:column;gap:8px"><span style="font-size:13px;font-weight:500;color:var(--text2)">Логин</span><input value="{{ f.login }}" onChange="{{ fh.login }}" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px" style-focus="border-color:var(--accent)"></label>
<label style="display:flex;flex-direction:column;gap:8px"><span style="font-size:13px;font-weight:500;color:var(--text2)">Пароль</span><input type="password" value="{{ f.pass }}" onChange="{{ fh.pass }}" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px" style-focus="border-color:var(--accent)"></label>
<sc-if value="{{ loginErr }}"><div style="display:flex;align-items:center;gap:8px;padding:12px 14px;border-radius:12px;background:var(--danger-soft);color:var(--danger);font-size:14px;font-weight:500"><i style="font-size:20px">error</i>{{ loginErr }}</div></sc-if>
<div style="flex:1"></div>
<button onClick="{{ onLogin }}" style="height:56px;border-radius:28px;background:var(--accent);color:#fff;font-weight:600;font-size:16px;box-shadow:var(--glow)">Войти</button>
</div>
</sc-if>
<sc-if value="{{ scr.app }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;z-index:1;flex:1;min-height:0;display:flex;flex-direction:column">
<sc-if value="{{ offline }}">
<div style="display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--warn-soft);color:var(--warn);font-size:13px;font-weight:500"><i style="font-size:20px">cloud_off</i><span style="flex:1">Нет сети — играет скачанное. {{ pendingEvents }} прослушиваний отправятся позже</span></div>
</sc-if>
<div ref="{{ scrollRef }}" style="flex:1;min-height:0;overflow-y:auto;overflow-x:hidden">
<sc-if value="{{ pg.home }}" hint-placeholder-val="{{ true }}">
<div style="min-height:100%;display:flex;flex-direction:column;gap:20px;padding:12px 0 24px;box-sizing:border-box">
<div style="display:flex;align-items:center;gap:10px;padding:0 20px;height:48px">
<div style="width:24px;height:24px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8)"></div>
<div style="flex:1;font-family:Unbounded;font-weight:600;font-size:18px;letter-spacing:-0.02em">pipu</div>
</div>
<div style="display:flex;justify-content:center;padding:28px 0 12px">
<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="{{ 250 }}" onClick="{{ onWave }}" hint-size="250px,250px">
<i style="font-size:48px;color:#fff;font-variation-settings:'FILL' 1">{{ waveIcon }}</i>
<div style="font-family:Unbounded;font-weight:600;font-size:21px;color:#fff;margin-top:8px">Моя волна</div>
<div style="font-size:13px;color:rgba(255,255,255,.8);margin-top:5px">{{ ctxLabel }}</div>
</x-import>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:16px">
<span style="padding:0 20px;font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Занятие</span>
<div style="display:flex;gap:8px;overflow-x:auto;padding:0 20px 4px;scrollbar-width:none">
<sc-for list="{{ activities }}" as="c" hint-placeholder-count="7">
<button onClick="{{ c.onClick }}" style="flex-shrink:0;display:flex;align-items:center;gap:8px;height:44px;padding:0 16px 0 12px;border-radius:22px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }}"><i style="font-size:20px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
</sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<span style="padding:0 20px;font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Настроение</span>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:0 20px">
<sc-for list="{{ moods }}" as="c" hint-placeholder-count="4">
<button onClick="{{ c.onClick }}" style="display:flex;align-items:center;gap:8px;height:48px;padding:0 14px;border-radius:16px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }}"><i style="font-size:20px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
</sc-for>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.search }}">
<div style="display:flex;flex-direction:column;gap:16px;padding:12px 16px 24px">
<div style="display:flex;align-items:center;gap:12px;height:56px;padding:0 8px 0 18px;border-radius:28px;background:var(--surface2)">
<i style="color:var(--text2)">search</i>
<input value="{{ q }}" onChange="{{ onQ }}" placeholder="Исполнители, альбомы, треки" style="flex:1;min-width:0;height:100%;background:none;border:0;outline:none;font-size:16px">
<sc-if value="{{ hasQ }}"><button onClick="{{ clearQ }}" aria-label="Очистить" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)"><i>close</i></button></sc-if>
</div>
<sc-if value="{{ sEmpty }}">
<div style="display:flex;flex-direction:column;gap:12px;padding:8px 4px">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Недавние запросы</span>
<div style="display:flex;flex-wrap:wrap;gap:8px"><sc-for list="{{ recent }}" as="r"><button onClick="{{ r.onClick }}" style="display:flex;align-items:center;gap:6px;height:40px;padding:0 14px 0 10px;border-radius:20px;border:1px solid var(--line2);font-size:14px"><i style="font-size:18px;color:var(--text3)">history</i>{{ r.label }}</button></sc-for></div>
<span style="font-size:13px;line-height:1.5;color:var(--text3)">Опечатки и неправильная раскладка не помеха: «ршту» найдёт «Кино».</span>
</div>
</sc-if>
<sc-if value="{{ sr.fixed }}"><span style="font-size:13px;color:var(--text2);padding:0 4px">Исправили раскладку: «{{ sr.fixed }}»</span></sc-if>
<sc-if value="{{ sr.hasArtists }}">
<div style="display:flex;gap:16px;overflow-x:auto;padding:4px 4px;scrollbar-width:none">
<sc-for list="{{ sr.artists }}" as="a"><button onClick="{{ a.onClick }}" style="flex-shrink:0;width:96px;display:flex;flex-direction:column;align-items:center;gap:8px"><div style="width:84px;height:84px;border-radius:50%;background:{{ a.img }};display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:22px;color:#fff">{{ a.ini }}</div><span style="font-size:13px;font-weight:500;text-align:center">{{ a.name }}</span></button></sc-for>
</div>
</sc-if>
<sc-if value="{{ sr.hasAlbums }}">
<div style="display:flex;flex-direction:column;gap:4px">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);padding:0 4px 6px">Альбомы</span>
<sc-for list="{{ sr.albums }}" as="a"><button onClick="{{ a.onClick }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:4px;border-radius:12px;text-align:left"><div style="width:56px;height:56px;border-radius:10px;background:{{ a.img }};flex-shrink:0"></div><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">{{ a.title }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div></button></sc-for>
</div>
</sc-if>
<sc-if value="{{ sr.hasTracks }}">
<div style="display:flex;flex-direction:column">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);padding:0 4px 6px">Треки</span>
<sc-for list="{{ sr.tracks }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:4px;border-radius:12px;cursor:pointer;opacity:{{ r.op }}">
<div style="width:48px;height:48px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center;flex-shrink:0"><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:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="display:flex;align-items:center;gap:4px;font-size:13px;color:var(--text2)"><sc-if value="{{ r.dl }}"><i style="font-size:15px;color:var(--accent2)">download_done</i></sc-if>{{ r.artist }}</span></div>
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ sr.none }}"><div style="padding:40px 0;text-align:center;color:var(--text2);font-size:15px">Ничего не нашлось</div></sc-if>
</div>
</sc-if>
<sc-if value="{{ pg.collection }}">
<div style="display:flex;flex-direction:column;gap:14px;padding:12px 0 24px">
<div style="padding:16px 20px 4px;font-family:Unbounded;font-size:28px;font-weight:600;letter-spacing:-0.02em">Коллекция</div>
<div style="display:flex;gap:8px;overflow-x:auto;padding:0 16px;scrollbar-width:none">
<sc-for list="{{ colTabs }}" as="t" hint-placeholder-count="4">
<button onClick="{{ t.onClick }}" style="flex-shrink:0;display:flex;align-items:center;gap:6px;height:40px;padding:0 16px 0 12px;border-radius:20px;font-size:14px;font-weight:500;background:{{ t.bg }};color:{{ t.color }};border:1px solid {{ t.border }}"><i style="font-size:19px;font-variation-settings:{{ t.fill }}">{{ t.icon }}</i>{{ t.label }}</button>
</sc-for>
</div>
<sc-if value="{{ col.isDl }}">
<div style="display:flex;flex-direction:column;gap:14px;padding:4px 16px">
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:18px;background:var(--surface2)">
<div style="display:flex;align-items:baseline;justify-content:space-between"><span style="font-weight:600;font-size:15px">Занято на устройстве</span><span style="font-size:14px;color:var(--text2)"><span style="font-family:Unbounded;font-weight:600;color:var(--text);font-size:17px">{{ dl.usedStr }}</span> · {{ dl.count }}</span></div>
<div style="height:8px;border-radius:4px;background:var(--line2);overflow:hidden;display:flex"><div style="height:100%;width:{{ dl.pct }};background:var(--accent)"></div></div>
<span style="font-size:12px;color:var(--text3)">Свободно в телефоне: 41,2 ГБ · файлы видны только приложению</span>
</div>
<div style="display:flex;flex-direction:column;border-radius:18px;background:var(--surface2);overflow:hidden">
<div onClick="{{ dl.onAuto }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:8px 16px;cursor:pointer;border-bottom:1px solid var(--line)">
<i style="color:var(--text2)">favorite</i>
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Скачивать новые лайки</span><span style="font-size:13px;color:var(--text2)">Сразу после ♥</span></div>
<div style="width:52px;height:32px;border-radius:16px;background:{{ dl.auto.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:4px;left:{{ dl.auto.x }};width:24px;height:24px;border-radius:50%;background:#fff;transition:left .2s"></div></div>
</div>
<button onClick="{{ dl.onAllLikes }}" style="display:flex;align-items:center;gap:14px;min-height:56px;padding:8px 16px;text-align:left"><i style="color:var(--accent2)">download</i><span style="flex:1;font-size:15px;font-weight:500;color:var(--accent2)">Скачать все лайки</span><span style="font-size:13px;color:var(--text3)">{{ dl.likesLeft }}</span></button>
</div>
<sc-if value="{{ dl.hasQueue }}">
<div style="display:flex;flex-direction:column;gap:8px">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Загружается · по Wi-Fi</span>
<sc-for list="{{ dl.queue }}" as="r">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:40px;height:40px;border-radius:8px;background:{{ r.cover }};flex-shrink:0"></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><div style="height:4px;border-radius:2px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ r.w }};background:var(--accent)"></div></div></div>
<span style="width:36px;text-align:right;font-size:12px;color:var(--text3)">{{ r.pctStr }}</span>
</div>
</sc-for>
</div>
</sc-if>
<sc-for list="{{ dl.albums }}" as="a">
<div style="display:flex;flex-direction:column;gap:2px">
<div style="display:flex;align-items:center;gap:12px;padding:8px 0">
<div style="width:44px;height:44px;border-radius:8px;background:{{ a.cover }}"></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px">{{ a.title }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div>
<button onClick="{{ a.onDel }}" aria-label="Удалить альбом" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text3)"><i>delete</i></button>
</div>
<sc-for list="{{ a.rows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:12px;min-height:48px;padding-left:56px;cursor:pointer">
<div style="flex:1;min-width:0;display:flex;align-items:center;gap:8px"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><span style="font-size:14px;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span></div>
<span style="font-size:12px;color:var(--text3)">{{ r.size }}</span>
<button onClick="{{ r.onDel }}" aria-label="Удалить" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text3)"><i style="font-size:20px">close</i></button>
</div>
</sc-for>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ col.isList }}">
<div style="display:flex;flex-direction:column;padding:0 12px">
<sc-if value="{{ col.canPlay }}">
<div style="display:flex;gap:8px;padding:4px 4px 10px">
<button onClick="{{ col.onPlay }}" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;height:48px;border-radius:24px;background:var(--accent);color:#fff;font-weight:600;font-size:15px"><i style="font-variation-settings:'FILL' 1">play_arrow</i>Слушать</button>
<button onClick="{{ col.onShuffle }}" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;height:48px;border-radius:24px;border:1px solid var(--line2);font-weight:600;font-size:15px"><i>shuffle</i>Перемешать</button>
</div>
</sc-if>
<sc-if value="{{ col.isDis }}"><span style="padding:0 8px 10px;font-size:13px;line-height:1.5;color:var(--text2)">Эти треки не попадают в волну. Снимите дизлайк, если передумали.</span></sc-if>
<sc-for list="{{ col.groups }}" as="g">
<sc-if value="{{ g.day }}"><div style="padding:12px 8px 6px;font-size:14px;font-weight:600;color:var(--text2)">{{ g.day }}</div></sc-if>
<sc-for list="{{ g.rows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:4px 8px;border-radius:12px;cursor:pointer;opacity:{{ r.op }}">
<div style="width:48px;height:48px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center;flex-shrink:0"><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:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="display:flex;align-items:center;gap:4px;font-size:13px;color:var(--text2);white-space:nowrap;overflow:hidden"><sc-if value="{{ r.dl }}"><i style="font-size:15px;color:var(--accent2)">download_done</i></sc-if>{{ r.artist }}<sc-if value="{{ r.time }}"><span style="color:var(--text3)">· {{ r.time }}</span></sc-if></span></div>
<sc-if value="{{ col.isDis }}"><button onClick="{{ r.onUndo }}" aria-label="Убрать дизлайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)"><i>undo</i></button></sc-if>
<sc-if value="{{ col.notDis }}"><button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button></sc-if>
</div>
</sc-for>
</sc-for>
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ pg.album }}">
<div style="display:flex;flex-direction:column;gap:18px;padding:8px 0 24px;background:{{ alb.tint }}">
<div style="display:flex;align-items:center;height:56px;padding:0 4px"><button onClick="{{ back }}" aria-label="Назад" style="width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i>arrow_back</i></button></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:0 24px;text-align:center">
<div style="width:220px;height:220px;border-radius:18px;background:{{ alb.cover }};box-shadow:0 24px 56px rgba(0,0,0,.5)"></div>
<div style="font-family:Unbounded;font-size:24px;font-weight:600;letter-spacing:-0.02em;text-wrap:balance">{{ alb.title }}</div>
<button onClick="{{ alb.onArtist }}" style="font-size:15px;color:var(--text2)"><span style="color:var(--text);font-weight:600">{{ alb.artist }}</span> · {{ alb.year }} · {{ alb.meta }}</button>
</div>
<div style="display:flex;align-items:center;justify-content:center;gap:16px">
<button onClick="{{ alb.onDl }}" aria-label="Скачать альбом" style="width:52px;height:52px;border-radius:50%;border:1px solid var(--line2);display:flex;align-items:center;justify-content:center;color:{{ alb.dlColor }}"><i>{{ alb.dlIcon }}</i></button>
<button onClick="{{ alb.onPlay }}" aria-label="Слушать" style="width:68px;height:68px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;box-shadow:var(--glow)"><i style="font-size:38px;font-variation-settings:'FILL' 1">{{ alb.playIcon }}</i></button>
<button onClick="{{ alb.onShuffle }}" aria-label="Перемешать" style="width:52px;height:52px;border-radius:50%;border:1px solid var(--line2);display:flex;align-items:center;justify-content:center"><i>shuffle</i></button>
</div>
<span style="text-align:center;font-size:13px;color:var(--text2)">{{ alb.dlLabel }}</span>
<div style="display:flex;flex-direction:column;padding:0 12px">
<sc-for list="{{ alb.rows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:14px;min-height:56px;padding:4px 8px;border-radius:12px;cursor:pointer;opacity:{{ r.op }}">
<div style="width:24px;display:flex;justify-content:center;font-size:14px;color:var(--text3)"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
<div style="flex:1;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="display:flex;align-items:center;gap:4px;font-size:13px;color:var(--text2)"><sc-if value="{{ r.dl }}"><i style="font-size:15px;color:var(--accent2)">download_done</i></sc-if><sc-if value="{{ r.dling }}"><i style="font-size:15px;color:var(--text3);animation:pipuSpin 1s linear infinite">progress_activity</i></sc-if>{{ r.dur }}</span></div>
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
</div>
</sc-for>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.artist }}">
<div style="display:flex;flex-direction:column;gap:18px;padding:8px 0 24px;background:{{ art.banner }}">
<div style="display:flex;align-items:center;height:56px;padding:0 4px"><button onClick="{{ back }}" aria-label="Назад" style="width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i>arrow_back</i></button></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:0 24px;text-align:center">
<div style="width:150px;height:150px;border-radius:50%;background:{{ art.img }};box-shadow:0 20px 48px rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:42px;color:#fff">{{ art.ini }}</div>
<div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">{{ art.name }}</div>
<span style="font-size:14px;color:var(--text2)">{{ art.meta }}</span>
</div>
<div style="display:flex;gap:8px;padding:0 16px">
<button onClick="{{ art.onWave }}" style="flex:1.4;display:flex;align-items:center;justify-content:center;gap:8px;height:48px;border-radius:24px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)"><i style="font-size:22px">graphic_eq</i>Волна по исполнителю</button>
<button onClick="{{ art.onPlay }}" aria-label="Слушать" style="width:48px;height:48px;border-radius:50%;border:1px solid var(--line2);display:flex;align-items:center;justify-content:center"><i style="font-variation-settings:'FILL' 1">play_arrow</i></button>
</div>
<div style="display:flex;flex-direction:column;padding:0 12px">
<span style="padding:6px 8px;font-size:17px;font-weight:600">Популярные треки</span>
<sc-for list="{{ art.rows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:4px 8px;border-radius:12px;cursor:pointer;opacity:{{ r.op }}">
<div style="width:48px;height:48px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center;flex-shrink:0"><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: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.album }}</span></div>
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
</div>
</sc-for>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<span style="padding:0 20px;font-size:17px;font-weight:600">Альбомы</span>
<div style="display:flex;gap:12px;overflow-x:auto;padding:0 20px;scrollbar-width:none">
<sc-for list="{{ art.albums }}" as="a"><button onClick="{{ a.onClick }}" style="flex-shrink:0;width:140px;display:flex;flex-direction:column;gap:8px;text-align:left"><div style="width:140px;height:140px;border-radius:12px;background:{{ a.img }}"></div><span style="font-size:14px;font-weight:600">{{ a.title }}</span><span style="font-size:12px;color:var(--text2);margin-top:-4px">{{ a.year }}</span></button></sc-for>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.profile }}">
<div style="display:flex;flex-direction:column;gap:16px;padding:12px 16px 24px">
<div style="display:flex;align-items:center;gap:14px;padding:16px 4px 8px">
<div style="width:60px;height:60px;border-radius:50%;background:linear-gradient(140deg,#2F80FF,#7C5CFF);display:flex;align-items:center;justify-content:center;font-size:20px;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:18px">{{ me.name }}</span><span style="font-size:14px;color:var(--text2)">{{ me.login }} · {{ me.role }}</span></div>
</div>
<div style="display:flex;flex-direction:column;border-radius:20px;background:var(--surface2);overflow:hidden">
<button onClick="{{ openPw }}" style="display:flex;align-items:center;gap:16px;min-height:60px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">key</i><span style="flex:1;font-size:15px;font-weight:500">Сменить пароль</span><i style="color:var(--text3)">chevron_right</i></button>
<div style="display:flex;align-items:center;gap:16px;min-height:60px;padding:8px 16px"><i style="color:var(--text2)">translate</i><span style="flex:1;font-size:15px;font-weight:500">Язык</span><div style="display:flex;gap:2px;padding:3px;border-radius:12px;background:var(--surface3)"><sc-for list="{{ langs }}" as="l"><button onClick="{{ l.onClick }}" style="height:34px;padding:0 12px;border-radius:9px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for></div></div>
</div>
<span style="padding:6px 8px 0;font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Качество звука</span>
<div style="display:flex;flex-direction:column;border-radius:20px;background:var(--surface2);overflow:hidden">
<sc-for list="{{ qualRows }}" as="r" hint-placeholder-count="3">
<button onClick="{{ r.onClick }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">{{ r.icon }}</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">{{ r.label }}</span><span style="font-size:13px;color:var(--accent2)">{{ r.value }}</span></div></button>
</sc-for>
<div onClick="{{ toggleWifiOnly }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;cursor:pointer;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">wifi</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Скачивать только по Wi-Fi</span></div><div style="width:52px;height:32px;border-radius:16px;background:{{ wifiOnly.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:4px;left:{{ wifiOnly.x }};width:24px;height:24px;border-radius:50%;background:#fff;transition:left .2s"></div></div></div>
<div style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px"><i style="color:var(--text2)">cached</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Кэш недавних треков</span><span style="font-size:13px;color:var(--text2)">{{ cacheStr }} из 500 МБ</span></div><button onClick="{{ clearCache }}" style="height:40px;padding:0 14px;border-radius:20px;border:1px solid var(--line2);font-size:13px;font-weight:600">Очистить</button></div>
</div>
<span style="padding:6px 8px 0;font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Приложение</span>
<div style="display:flex;flex-direction:column;border-radius:20px;background:var(--surface2);overflow:hidden">
<button onClick="{{ checkUpdate }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">system_update</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Проверить обновления</span><span style="font-size:13px;color:var(--text2)">Версия {{ appVer }}</span></div><sc-if value="{{ updDot }}"><span style="height:24px;padding:0 9px;border-radius:12px;background:var(--accent);color:#fff;font-size:12px;font-weight:700;display:flex;align-items:center">0.6.2</span></sc-if></button>
<button onClick="{{ toConnect }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">dns</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Сервер</span><span style="font-size:13px;color:var(--text2);font-family:'JetBrains Mono'">{{ srv.addr }}</span></div><i style="color:var(--text3)">chevron_right</i></button>
<sc-if value="{{ isStaff }}"><button onClick="{{ openAdmin }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">shield_person</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Админка</span><span style="font-size:13px;color:var(--text2)">Откроется в браузере</span></div><i style="color:var(--text3)">open_in_new</i></button></sc-if>
<button onClick="{{ onLogout }}" style="display:flex;align-items:center;gap:16px;min-height:60px;padding:8px 16px;text-align:left;color:var(--danger)"><i>logout</i><span style="font-size:15px;font-weight:500">Выйти</span></button>
</div>
</div>
</sc-if>
</div>
<div onClick="{{ openFull }}" style="margin:0 8px 8px;display:flex;align-items:center;gap:12px;height:62px;padding:0 6px 0 8px;border-radius:16px;background:var(--glass);backdrop-filter:blur(20px);border:1px solid var(--line);position:relative;overflow:hidden;cursor:pointer;flex-shrink:0">
<div style="width:46px;height:46px;border-radius:10px;background:{{ cur.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">{{ cur.title }}</span><span style="font-size:12px;color:{{ srcColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ miniSub }}</span></div>
<button onClick="{{ miniLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ cur.likeColor }};font-variation-settings:{{ cur.likeFill }}">favorite</i></button>
<button onClick="{{ miniPlay }}" aria-label="Пауза" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:32px;font-variation-settings:'FILL' 1">{{ playIcon }}</i></button>
<div style="position:absolute;left:8px;right:8px;bottom:0;height:2px;background:var(--line2)"><div style="height:100%;width:{{ pct }};background:var(--accent2)"></div></div>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);height:76px;background:var(--surface);border-top:1px solid var(--line);flex-shrink:0">
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="4">
<button onClick="{{ t.onClick }}" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px">
<div style="width:60px;height:32px;border-radius:16px;background:{{ t.bg }};display:flex;align-items:center;justify-content:center"><i style="color:{{ t.ic }};font-variation-settings:{{ t.fill }}">{{ t.icon }}</i></div>
<span style="font-size:12px;font-weight:{{ t.w }};color:{{ t.color }}">{{ t.label }}</span>
</button>
</sc-for>
</div>
</div>
<sc-if value="{{ full }}">
<div style="position:absolute;inset:0;z-index:50;display:flex;flex-direction:column;background:var(--bg);animation:pipuUp .28s cubic-bezier(.2,.8,.2,1)">
<div style="position:absolute;inset:0;background:{{ fp.tint }};pointer-events:none"></div>
<div style="position:relative;flex:1;display:flex;flex-direction:column;padding:8px 20px 20px;min-height:0">
<div style="display:flex;align-items:center;gap:8px;height:56px">
<button onClick="{{ closeFull }}" aria-label="Свернуть" style="width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-left:-8px"><i style="font-size:30px">keyboard_arrow_down</i></button>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:0"><span style="font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text2)">{{ fp.srcKind }}</span><span style="font-size:13px;font-weight:600;color:{{ srcColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%">{{ fp.srcName }}</span></div>
<button onClick="{{ openQueue }}" aria-label="Очередь" style="width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-right:-8px"><i>queue_music</i></button>
</div>
<sc-if value="{{ fpCover }}" hint-placeholder-val="{{ true }}">
<div style="flex:1;display:flex;align-items:center;justify-content:center;min-height:0;padding:16px 0">
<div onClick="{{ cur.onAlbum }}" style="width:100%;aspect-ratio:1;max-height:100%;border-radius:22px;background:{{ cur.cover }};box-shadow:0 30px 70px rgba(0,0,0,.5);cursor:pointer"></div>
</div>
</sc-if>
<sc-if value="{{ fpWave }}">
<div style="flex:1;display:flex;align-items:center;justify-content:center;min-height:0">
<x-import component="WaveButton" from="./wave.js" variant="{{ waveVariant }}" playing="{{ playing }}" bpm="{{ cur.bpm }}" energy="{{ cur.energy }}" color="{{ waveC.c1 }}" color2="{{ fp.c1 }}" color3="{{ waveC.c3 }}" theme="{{ theme }}" size="{{ 230 }}" onClick="{{ miniPlay }}" hint-size="230px,230px">
<div style="width:104px;height:104px;border-radius:50%;background:{{ cur.cover }};box-shadow:0 10px 30px rgba(0,0,0,.45);border:2px solid rgba(255,255,255,.25)"></div>
</x-import>
</div>
</sc-if>
<div style="display:flex;align-items:center;gap:12px;padding:8px 0 16px">
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><span style="font-size:22px;font-weight:700;letter-spacing:-0.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ cur.title }}</span><span onClick="{{ cur.onArtist }}" style="font-size:16px;color:var(--text2);cursor:pointer">{{ cur.artist }}</span></div>
<sc-if value="{{ cur.dl }}"><i style="font-size:22px;color:var(--accent2)">download_done</i></sc-if>
</div>
<div onClick="{{ onSeek }}" style="height:24px;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:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;background:#fff;box-shadow:0 0 0 4px rgba(47,128,255,.3)"></div>
</div>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text2);font-variant-numeric:tabular-nums;margin-top:2px"><span>{{ posStr }}</span><span>{{ durStr }}</span></div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:18px 0 10px">
<button onClick="{{ cur.onDislike }}" aria-label="Дизлайк" style="width:52px;height:52px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:26px;color:{{ cur.disColor }};font-variation-settings:{{ cur.disFill }}">thumb_down</i></button>
<button onClick="{{ prev }}" aria-label="Назад" style="width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:40px;font-variation-settings:'FILL' 1">skip_previous</i></button>
<button onClick="{{ togglePlay }}" aria-label="Пауза" style="width:76px;height:76px;border-radius:50%;background:var(--text);color:var(--bg);display:flex;align-items:center;justify-content:center"><i style="font-size:44px;font-variation-settings:'FILL' 1">{{ playIcon }}</i></button>
<button onClick="{{ next }}" aria-label="Вперёд" style="width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:40px;font-variation-settings:'FILL' 1">skip_next</i></button>
<button onClick="{{ cur.onLike }}" aria-label="Лайк" style="width:52px;height:52px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:26px;color:{{ cur.likeColor }};font-variation-settings:{{ cur.likeFill }}">favorite</i></button>
</div>
<div style="display:flex;align-items:center;justify-content:center;gap:8px;font-size:13px;color:var(--text3)"><i style="font-size:18px">phone_android</i>{{ fp.output }}</div>
</div>
</div>
</sc-if>
</sc-if>
<sc-if value="{{ sheet.any }}">
<div onClick="{{ closeSheet }}" style="position:absolute;inset:0;z-index:80;background:var(--overlay);display:flex;flex-direction:column;justify-content:flex-end;animation:pipuIn .15s ease-out">
<div onClick="{{ stop }}" style="display:flex;flex-direction:column;gap:16px;max-height:85%;padding:12px 20px 24px;border-radius:28px 28px 0 0;background:var(--elev);box-shadow:var(--shadow);animation:pipuUp .25s cubic-bezier(.2,.8,.2,1);overflow:auto">
<div style="align-self:center;width:36px;height:4px;border-radius:2px;background:var(--line2)"></div>
<sc-if value="{{ sheet.qual }}">
<div style="font-size:20px;font-weight:600">{{ qs.title }}</div>
<div style="display:flex;flex-direction:column">
<sc-for list="{{ qs.opts }}" as="o">
<button onClick="{{ o.onClick }}" style="display:flex;align-items:center;gap:16px;min-height:56px;text-align:left"><div style="width:22px;height:22px;border-radius:50%;border:2px solid {{ o.ring }};display:flex;align-items:center;justify-content:center;box-sizing:border-box"><div style="width:10px;height:10px;border-radius:50%;background:{{ o.dot }}"></div></div><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">{{ o.label }}</span><span style="font-size:13px;color:var(--text2)">{{ o.sub }}</span></div></button>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ sheet.pw }}">
<div style="font-size:20px;font-weight:600">Смена пароля</div>
<input type="password" value="{{ f.cur }}" onChange="{{ fh.cur }}" placeholder="Текущий пароль" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px">
<input type="password" value="{{ f.np }}" onChange="{{ fh.np }}" placeholder="Новый пароль, от 8 символов" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px">
<input type="password" value="{{ f.np2 }}" onChange="{{ fh.np2 }}" placeholder="Ещё раз" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px">
<sc-if value="{{ pwErr }}"><span style="font-size:13px;color:var(--danger)">{{ pwErr }}</span></sc-if>
<button onClick="{{ onPw }}" style="height:52px;border-radius:26px;background:var(--accent);color:#fff;font-weight:600;font-size:15px">Сохранить</button>
</sc-if>
<sc-if value="{{ sheet.queue }}">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-size:20px;font-weight:600">Очередь</span><span style="font-size:13px;color:var(--text2)">{{ srcLabel }}</span></div>
<div style="display:flex;flex-direction:column">
<sc-for list="{{ queueRows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:12px;min-height:56px;padding:4px;border-radius:12px;cursor:pointer;background:{{ r.bg }}">
<div style="width:44px;height:44px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center;flex-shrink:0"><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: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>
<span style="font-size:12px;color:var(--text3)">{{ r.dur }}</span>
</div>
</sc-for>
</div>
<sc-if value="{{ waveOn }}"><div style="display:flex;align-items:center;gap:10px;padding:12px;border-radius:14px;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>
</sc-if>
<sc-if value="{{ sheet.update }}">
<div style="display:flex;align-items:center;gap:14px">
<div style="width:52px;height:52px;border-radius:16px;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8);display:flex;align-items:center;justify-content:center"><i style="color:#fff">system_update</i></div>
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-size:20px;font-weight:600">{{ up.title }}</span><span style="font-size:13px;color:var(--text2)">{{ up.sub }}</span></div>
</div>
<sc-if value="{{ up.isAvail }}">
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:16px;background:var(--surface2)">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Что нового</span>
<sc-for list="{{ up.notes }}" as="n"><div style="display:flex;gap:10px;font-size:14px;line-height:1.45"><span style="color:var(--accent2)">•</span><span>{{ n }}</span></div></sc-for>
</div>
<div style="display:flex;gap:10px"><button onClick="{{ closeSheet }}" style="flex:1;height:52px;border-radius:26px;border:1px solid var(--line2);font-weight:600;font-size:15px">Позже</button><button onClick="{{ up.onUpdate }}" style="flex:1;height:52px;border-radius:26px;background:var(--accent);color:#fff;font-weight:600;font-size:15px">Обновить</button></div>
</sc-if>
<sc-if value="{{ up.isPerm }}">
<div style="display:flex;gap:12px;padding:16px;border-radius:16px;background:var(--warn-soft);font-size:14px;line-height:1.5"><i style="color:var(--warn)">shield</i><span>Приложение ставится не из Google Play. Android один раз попросит разрешить установку из этого источника.</span></div>
<button onClick="{{ up.onPerm }}" style="height:52px;border-radius:26px;background:var(--accent);color:#fff;font-weight:600;font-size:15px">Открыть настройки и продолжить</button>
</sc-if>
<sc-if value="{{ up.isDl }}">
<div style="display:flex;flex-direction:column;gap:10px">
<div style="height:8px;border-radius:4px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ up.w }};background:linear-gradient(90deg,var(--accent),#35D3FF)"></div></div>
<div style="display:flex;justify-content:space-between;font-size:13px;color:var(--text2)"><span>{{ up.dlStr }}</span><span>{{ up.pctStr }}</span></div>
</div>
</sc-if>
<sc-if value="{{ up.isVerify }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--surface2);font-size:14px"><i style="animation:pipuSpin 1s linear infinite;color:var(--accent2)">progress_activity</i>Проверяем контрольную сумму SHA-256…</div>
</sc-if>
<sc-if value="{{ up.isReady }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--ok-soft);font-size:14px"><i style="color:var(--ok)">verified</i><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Подпись и SHA-256 совпали</span><span style="font-family:'JetBrains Mono';font-size:11px;color:var(--text2)">sha256 9f2c…e41a</span></div></div>
<button onClick="{{ up.onInstall }}" style="height:52px;border-radius:26px;background:var(--accent);color:#fff;font-weight:600;font-size:15px">Установить</button>
</sc-if>
</sc-if>
</div>
</div>
</sc-if>
<sc-if value="{{ toast }}">
<div style="position:absolute;left:12px;right:12px;bottom:{{ toastBottom }};z-index:90;display:flex;align-items:center;gap:10px;padding:14px 16px;border-radius:12px;background:var(--text);color:var(--bg);font-size:14px;font-weight:500;box-shadow:var(--shadow);animation:pipuIn .18s ease-out">{{ toast }}</div>
</sc-if>
</div>
</x-import>
</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;playerLayout&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;cover&quot;,&quot;wave&quot;],&quot;default&quot;:&quot;cover&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;offline&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Прототип&quot;},&quot;role&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;user&quot;,&quot;moderator&quot;,&quot;admin&quot;],&quot;default&quot;:&quot;user&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;app&quot;,&quot;connect&quot;],&quot;default&quot;:&quot;app&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Прототип&quot;}}">
const F1 = "'FILL' 1", F0 = "'FILL' 0";
const QOPT = [['low', 'Низкое', '96 кбит/с · экономит трафик'], ['normal', 'Нормальное', '160 кбит/с Opus'], ['high', 'Высокое', '320 кбит/с Opus'], ['orig', 'Оригинал', 'Без перекодирования, FLAC — как в библиотеке']];
const QROWS = [['wifi', 'По Wi-Fi', 'wifi'], ['cell', 'В мобильной сети', 'signal_cellular_alt'], ['dl', 'Для загрузок', 'download']];
const NOTES = {
connect: 'Адрес сервера вводится один раз; можно хранить несколько профилей серверов. «Проверить» обращается к /server-info. Попробуйте адрес со словом bad — увидите ошибку.',
login: 'Без регистрации. Язык переключается прямо здесь. Любой логин и пароль от 4 символов — вход.',
home: 'Нажмите волну — заиграет, пульс в такт BPM трека. Занятие и настроение меняют поток без остановки музыки.',
search: 'Ищет на лету по исполнителям, альбомам и трекам; прощает опечатки и раскладку.',
collection: 'История по дням, лайки, дизлайки (с отменой) и скачанное.',
downloads: 'Занятое место, авто-скачивание лайков, очередь загрузок (2 параллельно, только Wi-Fi), удаление по треку или альбомом.',
album: 'Кнопка со стрелкой скачивает альбом для офлайна — прогресс виден по каждому треку.',
artist: '«Волна по исполнителю» — поток, построенный вокруг артиста и похожих.',
profile: 'Качество для Wi-Fi, мобильной сети и загрузок раздельно, кэш, обновления, сервер. Админка — только ссылкой в браузер.',
player: 'Свайп/тап по мини-плееру. Два варианта полноэкранного плеера — переключаются в Tweaks (playerLayout).',
update: 'Самообновление через сервер Pipu: changelog → разрешение на установку → загрузка APK → проверка SHA-256 → установка.',
offline: 'Без сети играет только скачанное, волна строится из локальных треков, прослушивания копятся и уйдут на сервер позже.'
};
class Component extends DCLogic {
state = { ready: false };
scrollRef = React.createRef();
_fh = {};
componentDidMount() {
const wait = () => (window.PIPU ? this.init() : setTimeout(wait, 40)); wait();
this.iv = setInterval(() => this.tick(), 250);
}
componentWillUnmount() { clearInterval(this.iv); }
init() {
const D = window.PIPU;
let sv = null; try { sv = JSON.parse(localStorage.getItem('pipu-android-player') || 'null'); } catch (e) {}
const ok = sv && D.TR[sv.trackId];
const alb = D.ALB['ma-mz'].tracks;
const hist = []; let k = 5;
[['Сегодня', 5, 9], ['Вчера', 6, 21]].forEach(([d, n, h]) => { let m = 50; for (let j = 0; j < n; j++) { k = (k * 7 + 3) % D.ALL.length; hist.push({ id: D.ALL[k], day: d, time: String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') }); m -= 12; if (m < 0) { m += 60; h--; } } });
this.setState({
ready: true, auth: this.props.startScreen === 'connect' ? 'connect' : null,
tab: 'home', sub: null, colTab: 'history', full: false, sheet: null,
queue: ok && sv.queue ? sv.queue.filter(id => D.TR[id]) : alb, qi: ok ? sv.qi || 0 : 2, trackId: ok ? sv.trackId : alb[2], pos: ok ? sv.pos || 0 : 64, playing: false,
source: (ok && sv.source) || { label: 'Альбом · Mezzanine' }, waveOn: ok ? !!sv.waveOn : 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', 'rh-ir-3'], dislikes: ['dp-ram-1', 'spl-ga-3'], history: hist,
downloads: [...D.ALB['rh-ir'].tracks, ...D.ALB['ma-mz'].tracks, 'kino-gk-0', 'kino-zs-4', 'bon-mg-2', 'mod-2-1'], dlQueue: [], autoLikes: true, wifiOnly: true,
qual: { wifi: 'high', cell: 'normal', dl: 'high' }, cache: 312,
srvAddr: 'music.home.lan', srvState: 'ok', f: {}, loginErr: '', pwErr: '', lang: 'ru', q: '',
upd: 'available', updPct: 0, updSeen: false, offline: null, pending: 3, 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.dlQueue.length) {
const q = s.dlQueue.map((x, i) => i < 2 ? { ...x, p: x.p + 6 + Math.random() * 10 } : x);
const done = q.filter(x => x.p >= 100).map(x => x.id);
u.dlQueue = q.filter(x => x.p < 100);
if (done.length) u.downloads = [...s.downloads, ...done.filter(id => !s.downloads.includes(id))];
}
if (s.upd === 'downloading') { const p = s.updPct + 3 + Math.random() * 4; if (p >= 100) { u.updPct = 100; u.upd = 'verify'; setTimeout(() => this.setState({ upd: 'ready' }), 1200); } else u.updPct = p; }
if (Object.keys(u).length) this.setState(u);
this._t = (this._t || 0) + 1;
if (this._t % 4 === 0) { try { localStorage.setItem('pipu-android-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) {} }
}
isOff() { return this.state.offline ?? !!this.props.offline; }
toastMsg(m) { clearTimeout(this._tt); this.setState({ toast: m }); this._tt = setTimeout(() => this.setState({ toast: null }), 2600); }
fh(k) { return this._fh[k] || (this._fh[k] = e => this.setState(s => ({ f: { ...s.f, [k]: e.target.value } }))); }
goTab(tab) { this.setState({ tab, sub: null, full: false }); if (this.scrollRef.current) this.scrollRef.current.scrollTop = 0; }
goSub(sub) { this.setState({ sub, full: false }); if (this.scrollRef.current) this.scrollRef.current.scrollTop = 0; }
canPlay(id) { if (this.isOff() && !this.state.downloads.includes(id)) { this.toastMsg('Нет сети — этот трек не скачан'); return false; } return true; }
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, history: this.addHist(s.history, id), pending: this.isOff() ? s.pending + 1 : s.pending, ...extra })); }
playList(list, i, label) {
if (this.isOff()) { const dl = this.state.downloads; const id = list[i]; if (!this.canPlay(id)) return; list = list.filter(x => dl.includes(x)); i = list.indexOf(id); }
this.setTrack(i, list, { waveOn: false, source: { label } });
}
wopts(extra = {}) { const s = this.state; return { activity: s.activity, mood: s.mood, dislikes: s.dislikes, likes: s.likes, ...extra }; }
batch(n, extra) { const D = window.PIPU; let b = D.waveBatch(this.isOff() ? 200 : n, this.wopts(extra)); if (this.isOff()) b = b.filter(id => this.state.downloads.includes(id)).slice(0, n); return b; }
startWave(artistId) { const q = this.batch(12, { artistId }); if (!q.length) return this.toastMsg('Нет скачанных треков для офлайн-волны'); this.setTrack(0, q, { waveOn: true, source: { artistId: artistId || null } }); }
next() {
const s = this.state; let q = s.queue, i = s.qi + 1;
if (s.waveOn && i >= q.length - 2) q = [...q, ...this.batch(8, { 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) return this.setState({ pos: 0 }); this.setTrack(s.qi - 1, s.queue); }
togglePlay() { this.setState(s => ({ playing: !s.playing })); }
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, ...this.batch(10, { recent: keep.slice(-20) })] });
});
}
toggleLike(id) {
const s = this.state, on = s.likes.includes(id);
this.setState({ likes: on ? s.likes.filter(x => x !== id) : [id, ...s.likes], dislikes: s.dislikes.filter(x => x !== id) });
if (!on && s.autoLikes && !s.downloads.includes(id) && !this.isOff()) { this.setState(st => ({ dlQueue: [...st.dlQueue, { id, p: 0 }] })); this.toastMsg('Лайк · трек скачивается для офлайна'); }
}
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 this.toastMsg(was ? 'Дизлайк снят' : 'Трек скрыт из рекомендаций');
}
jump(k) {
const D = window.PIPU, base = { full: false, sheet: null, offline: null, sub: null };
const m = {
connect: { auth: 'connect', srvState: null }, login: { auth: 'login' },
home: { auth: null, tab: 'home' }, search: { auth: null, tab: 'search', q: 'кино' }, collection: { auth: null, tab: 'collection', colTab: 'history' },
downloads: { auth: null, tab: 'collection', colTab: 'downloads' }, album: { auth: null, tab: 'search', sub: { type: 'album', id: 'mod-2' } },
artist: { auth: null, tab: 'search', sub: { type: 'artist', id: 'radiohead' } }, profile: { auth: null, tab: 'profile' },
player: { auth: null, tab: 'home', full: true }, update: { auth: null, tab: 'profile', sheet: 'update', upd: 'available', updPct: 0 },
offline: { auth: null, tab: 'home', offline: true }
}[k];
this.setState({ ...base, ...m, jumpKey: k });
if (k === 'player' && !this.state.playing) this.setState({ playing: true });
if (this.scrollRef.current) this.scrollRef.current.scrollTop = 0;
}
ambient(c3) {
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(24px)', animation: anim } });
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,.6)', '560px', 'pipuDrift1 16s ease-in-out infinite alternate', { left: '-260px', top: '-240px' }),
blob('b', 'rgba(124,92,255,.45)', '480px', 'pipuDrift2 21s ease-in-out infinite alternate', { right: '-240px', top: '200px' }),
blob('c', c3 + '88', '460px', 'pipuDrift3 26s ease-in-out infinite alternate', { left: '-60px', bottom: '-280px' })
]);
}
renderVals() {
const D = window.PIPU, s = this.state, P = this.props;
const theme = P.theme || 'dark';
const base = { theme, isDark: theme === 'dark', deviceBg: theme === 'dark' ? '#05070D' : '#F3F5FA', waveVariant: P.waveVariant || 'orb' };
const jk = s.jumpKey || (s.auth || (s.full ? 'player' : s.sub ? s.sub.type : s.tab === 'collection' && s.colTab === 'downloads' ? 'downloads' : s.tab));
const JL = [['connect', 'Подключение к серверу'], ['login', 'Вход'], ['home', 'Главная · Моя волна'], ['search', 'Поиск'], ['collection', 'Коллекция'], ['downloads', 'Скачанное'], ['album', 'Альбом'], ['artist', 'Исполнитель'], ['profile', 'Профиль'], ['player', 'Полноэкранный плеер'], ['update', 'Обновление приложения'], ['offline', 'Без сети']];
base.jumps = JL.map(([k, l], i) => ({ n: String(i + 1).padStart(2, '0'), label: l, onClick: () => this.jump(k), bg: jk === k ? 'rgba(47,128,255,.16)' : 'transparent', color: jk === k ? '#EDF1FF' : '#A0ABC6' }));
base.note = NOTES[jk] || '';
if (!s.ready || !D) return { ...base, scr: {}, ambient: null };
const { ART, ALB, TR, fmt, plural } = D;
const off = this.isOff();
const t = TR[s.trackId], al = ALB[t.albumId], ar = ART[t.artistId];
base.ambient = P.ambient === false ? null : this.ambient(s.playing ? al.c1 : '#35D3FF');
const role = P.role || 'user';
const me = role === 'admin' ? { name: 'Алексей Морозов', login: 'admin', ini: 'АМ', role: 'Администратор' } : role === 'moderator' ? { name: 'Илья Сафонов', login: 'ilya', ini: 'ИС', role: 'Модератор' } : { name: 'Анна Белова', login: 'anna', ini: 'АБ', role: 'Пользователь' };
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: '#fff', boxShadow: '0 0 4px rgba(0,0,0,.4)', 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 eqA = 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), dl = s.downloads.includes(id);
return { id, 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, size: (tr.dur * 0.04).toFixed(1).replace('.', ',') + ' МБ',
isCur, notCur: !isCur, eq: extra.eqA ? eqA : eq, dl, dling: s.dlQueue.some(x => x.id === id), time: extra.time || '', op: off && !dl ? 0.38 : (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); },
onDel: e => { e.stopPropagation(); this.setState(st => ({ downloads: st.downloads.filter(x => x !== id) })); this.toastMsg('Удалено с устройства'); } };
};
const seg = (on, label, onClick) => ({ label, onClick, bg: on ? 'var(--accent)' : 'transparent', color: on ? '#fff' : 'var(--text2)' });
const fh = {}; ['login', 'pass', 'cur', 'np', 'np2'].forEach(k => { fh[k] = this.fh(k); });
const srv = {
addr: s.srvAddr, onAddr: e => this.setState({ srvAddr: e.target.value, srvState: null }),
checking: s.srvState === 'checking', ok: s.srvState === 'ok', err: s.srvState === 'err', border: s.srvState === 'err' ? 'var(--danger)' : s.srvState === 'ok' ? 'var(--ok)' : 'var(--line2)',
onCheck: () => { this.setState({ srvState: 'checking' }); setTimeout(() => this.setState(st => ({ srvState: /bad|^\s*$/.test(st.srvAddr) ? 'err' : 'ok' })), 900); },
onNext: () => { if (s.srvState === 'ok') this.setState({ auth: 'login', jumpKey: null }); else this.toastMsg('Сначала проверьте сервер'); },
nextBg: s.srvState === 'ok' ? 'var(--accent)' : 'var(--surface3)', nextColor: s.srvState === 'ok' ? '#fff' : 'var(--text3)',
profiles: [['Дом', 'music.home.lan', 'home'], ['Дача', '10.8.0.2:8443', 'cottage']].map(([n, a, ic]) => ({ name: n, addr: a, icon: ic, bg: s.srvAddr === a ? 'var(--accent-soft)' : 'var(--surface2)', border: s.srvAddr === a ? 'var(--accent)' : 'transparent', onClick: () => this.setState({ srvAddr: a, srvState: null }) }))
};
const common = { ...base, f: s.f, fh, srv, langs: [seg(s.lang === 'ru', 'RU', () => this.setState({ lang: 'ru' })), seg(s.lang === 'en', 'EN', () => this.setState({ lang: 'en' }))], toast: s.toast, stop: e => e.stopPropagation(),
toConnect: () => this.setState({ auth: 'connect', srvState: null, jumpKey: null }), onLogout: () => this.setState({ auth: 'login', playing: false, jumpKey: null }) };
const sheet = { any: !!s.sheet, qual: !!s.sheet && s.sheet.startsWith('q-'), pw: s.sheet === 'pw', queue: s.sheet === 'queue', update: s.sheet === 'update' };
const out = { ...common, sheet, closeSheet: () => this.setState({ sheet: null, pwErr: '' }) };
if (s.auth) {
return { ...out, scr: { connect: s.auth === 'connect', login: s.auth === 'login' }, loginErr: s.loginErr, toastBottom: '24px',
onLogin: () => { const l = (s.f.login || '').trim(), p = s.f.pass || ''; if (!l || p.length < 4) return this.setState({ loginErr: !l || !p ? 'Введите логин и пароль' : 'Неверный логин или пароль' }); this.setState({ auth: null, loginErr: '', f: {}, tab: 'home', jumpKey: null }); } };
}
const page = s.sub ? s.sub.type : s.tab;
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(' · ') || (off ? 'Из скачанного' : 'Под ваш вкус');
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)' });
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 tabDef = [['home', 'Главная', 'home'], ['search', 'Поиск', 'search'], ['collection', 'Коллекция', 'library_music'], ['profile', 'Профиль', 'person']];
Object.assign(out, {
scr: { app: true }, me, offline: off, pendingEvents: s.pending, isStaff: role !== 'user',
pg: { [page]: true }, back: () => this.setState({ sub: null }),
tabs: tabDef.map(([k, l, ic]) => { const on = s.tab === k; return { label: l, icon: ic, onClick: () => this.goTab(k), bg: on ? 'var(--accent-soft)' : 'transparent', ic: on ? 'var(--accent2)' : 'var(--text2)', fill: on ? F1 : F0, color: on ? 'var(--text)' : 'var(--text2)', w: on ? '600' : '500' }; }),
cur: { title: t.title, artist: ar.name, cover: al.cover, bpm: t.bpm, energy: t.energy, dl: s.downloads.includes(t.id), 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.goSub({ type: 'artist', id: ar.id }), onAlbum: () => this.goSub({ type: 'album', id: al.id }) },
playing: s.playing, waveOn: s.waveOn, waveIsPlaying, waveC: wc, waveIcon: waveIsPlaying ? 'pause' : 'play_arrow', ctxLabel,
onWave: () => { if (s.waveOn && !s.source.artistId) this.togglePlay(); else this.startWave(); },
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(),
miniPlay: e => { e && e.stopPropagation && e.stopPropagation(); this.togglePlay(); }, miniLike: e => { e.stopPropagation(); this.toggleLike(t.id); },
miniSub: s.waveOn ? srcLabel : ar.name, srcLabel, srcColor: s.waveOn ? 'var(--accent2)' : 'var(--text2)',
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 }); },
full: s.full, openFull: () => this.setState({ full: true, jumpKey: null }), closeFull: () => this.setState({ full: false, jumpKey: null }),
fpCover: (P.playerLayout || 'cover') === 'cover', fpWave: P.playerLayout === 'wave',
fp: { tint: `linear-gradient(180deg, ${al.c1}55 0%, ${al.c2}44 45%, transparent 85%)`, c1: al.c1, srcKind: s.waveOn ? 'Играет волна' : 'Играет', srcName: s.waveOn ? ctxLabel : s.source.label, output: off || s.downloads.includes(t.id) ? 'Динамик телефона · из памяти' : 'Динамик телефона · ' + QOPT.find(q => q[0] === s.qual.wifi)[1].toLowerCase() + ' качество' },
openQueue: () => this.setState({ sheet: 'queue' }),
queueRows: s.queue.slice(s.qi, s.qi + 30).map((id, k) => row(id, k, s.queue, '', { onPlay: () => { this.setTrack(s.qi + k, s.queue); this.setState({ sheet: null }); } })),
toastBottom: s.full ? '40px' : '160px'
});
if (page === 'search') {
const q = s.q.trim(), r = q ? D.search(q) : null;
Object.assign(out, { q: s.q, hasQ: !!s.q, onQ: e => this.setState({ q: e.target.value }), clearQ: () => this.setState({ q: '' }), sEmpty: !q,
recent: ['Кино', 'Moderat', 'Teardrop', 'ршту'].map(l => ({ label: l, onClick: () => this.setState({ q: l }) })),
sr: r ? { fixed: r.fixed, hasArtists: r.artists.length > 0, hasAlbums: r.albums.length > 0, hasTracks: r.tracks.length > 0, none: !r.artists.length && !r.albums.length && !r.tracks.length,
artists: r.artists.map(a => ({ name: a.name, img: a.avatar, ini: a.name.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase(), onClick: () => this.goSub({ type: 'artist', id: a.id }) })),
albums: r.albums.slice(0, 4).map(a => ({ title: a.title, sub: 'Альбом · ' + ART[a.artistId].name + ' · ' + a.year, img: a.cover, onClick: () => this.goSub({ type: 'album', id: a.id }) })),
tracks: r.tracks.slice(0, 10).map((id, i) => row(id, i, r.tracks, 'Поиск · ' + q)) } : {} });
}
if (page === 'collection') {
const ct = s.colTab;
out.colTabs = [['history', 'История', 'history'], ['likes', 'Лайки', 'favorite'], ['dislikes', 'Дизлайки', 'heart_broken'], ['downloads', 'Скачанное', 'download_done']].map(([k, l, ic]) => ({ label: l, icon: ic, onClick: () => this.setState({ colTab: k, jumpKey: null }), bg: ct === k ? 'var(--accent)' : 'transparent', color: ct === k ? '#fff' : 'var(--text)', border: ct === k ? 'var(--accent)' : 'var(--line2)', fill: ct === k ? F1 : F0 }));
let groups = [];
if (ct === 'history') { const list = s.history.map(h => h.id); s.history.forEach((h, i) => { let g = groups.find(x => x.day === h.day); if (!g) { g = { day: h.day, rows: [] }; groups.push(g); } g.rows.push(row(h.id, i, list, 'История', { time: h.time })); }); }
if (ct === 'likes') groups = [{ day: '', rows: s.likes.map((id, i) => row(id, i, s.likes, 'Лайки')) }];
if (ct === 'dislikes') groups = [{ day: '', rows: s.dislikes.map((id, i) => row(id, i, s.dislikes, 'Дизлайки', { op: 0.6 })) }];
out.col = { isDl: ct === 'downloads', isList: ct !== 'downloads', isDis: ct === 'dislikes', notDis: ct !== 'dislikes', canPlay: ct === 'likes' && s.likes.length > 0, groups,
onPlay: () => this.playList(s.likes, 0, 'Лайки'), onShuffle: () => this.playList([...s.likes].sort(() => Math.random() - 0.5), 0, 'Лайки · вперемешку') };
if (ct === 'downloads') {
const mb = s.downloads.reduce((a, id) => a + TR[id].dur * 0.04, 0);
const byAlb = []; s.downloads.forEach(id => { const a = TR[id].albumId; let g = byAlb.find(x => x.id === a); if (!g) { g = { id: a, ids: [] }; byAlb.push(g); } g.ids.push(id); });
const left = s.likes.filter(id => !s.downloads.includes(id) && !s.dlQueue.some(x => x.id === id));
out.dl = {
usedStr: mb >= 1024 ? (mb / 1024).toFixed(1).replace('.', ',') + ' ГБ' : Math.round(mb) + ' МБ', count: s.downloads.length + ' ' + plural(s.downloads.length, ['трек', 'трека', 'треков']), pct: Math.min(100, mb / 4096 * 100) + '%',
auto: { bg: s.autoLikes ? 'var(--accent)' : 'var(--line2)', x: s.autoLikes ? '24px' : '4px' }, onAuto: () => this.setState({ autoLikes: !s.autoLikes }),
likesLeft: left.length ? 'ещё ' + left.length : 'всё скачано', onAllLikes: () => { if (!left.length) return this.toastMsg('Все лайки уже скачаны'); if (off) return this.toastMsg('Нет сети'); this.setState({ dlQueue: [...s.dlQueue, ...left.map(id => ({ id, p: 0 }))] }); },
hasQueue: s.dlQueue.length > 0, queue: s.dlQueue.map(x => ({ title: TR[x.id].title + ' — ' + ART[TR[x.id].artistId].name, cover: ALB[TR[x.id].albumId].cover, w: Math.min(100, x.p) + '%', pctStr: x.p > 0 ? Math.round(Math.min(100, x.p)) + '%' : 'ждёт' })),
albums: byAlb.map(g => { const A = ALB[g.id]; const m2 = g.ids.reduce((a, id) => a + TR[id].dur * 0.04, 0); return { title: A.title, cover: A.cover, sub: ART[A.artistId].name + ' · ' + g.ids.length + ' из ' + A.tracks.length + ' · ' + Math.round(m2) + ' МБ',
onDel: () => { this.setState(st => ({ downloads: st.downloads.filter(x => !g.ids.includes(x)) })); this.toastMsg('Альбом удалён с устройства'); }, rows: g.ids.map((id, i) => row(id, i, g.ids, 'Скачанное · ' + A.title, { eqA: true })) }; })
};
}
}
if (page === 'album') {
const B = ALB[s.sub.id], A = ART[B.artistId], tot = B.tracks.reduce((x, id) => x + TR[id].dur, 0);
const have = B.tracks.filter(id => s.downloads.includes(id)).length, inQ = B.tracks.some(id => s.dlQueue.some(x => x.id === id)), all = have === B.tracks.length;
const isThis = !s.waveOn && B.tracks.includes(s.trackId) && s.source.label === 'Альбом · ' + B.title;
out.alb = { title: B.title, cover: B.cover, artist: A.name, year: B.year, tint: `linear-gradient(180deg, ${B.c1}55, transparent 420px)`, meta: Math.round(tot / 60) + ' мин',
playIcon: isThis && s.playing ? 'pause' : 'play_arrow', onPlay: () => isThis ? this.togglePlay() : this.playList(B.tracks, 0, 'Альбом · ' + B.title),
onShuffle: () => this.playList([...B.tracks].sort(() => Math.random() - 0.5), 0, 'Альбом · ' + B.title), onArtist: () => this.goSub({ type: 'artist', id: A.id }),
dlIcon: all ? 'download_done' : inQ ? 'downloading' : 'download', dlColor: all || inQ ? 'var(--accent2)' : 'var(--text)',
dlLabel: all ? 'Альбом скачан · доступен без сети' : inQ ? 'Скачивается: ' + have + ' из ' + B.tracks.length : have ? 'Скачано ' + have + ' из ' + B.tracks.length : 'Скачайте, чтобы слушать без сети',
onDl: () => { if (all) { this.setState({ downloads: s.downloads.filter(x => !B.tracks.includes(x)) }); return this.toastMsg('Альбом удалён с устройства'); } if (off) return this.toastMsg('Нет сети'); this.setState({ dlQueue: [...s.dlQueue, ...B.tracks.filter(id => !s.downloads.includes(id) && !s.dlQueue.some(x => x.id === id)).map(id => ({ id, p: 0 }))] }); this.toastMsg(s.wifiOnly ? 'Скачиваем по Wi-Fi' : 'Скачиваем'); },
rows: B.tracks.map((id, i) => row(id, i, B.tracks, 'Альбом · ' + B.title, { eqA: true })) };
}
if (page === 'artist') {
const A = ART[s.sub.id], top = [...A.tracks].sort((a, b) => TR[b].pop - TR[a].pop).slice(0, 5);
out.art = { name: A.name, img: A.avatar, banner: `linear-gradient(180deg, ${A.c1}55, transparent 420px)`, ini: A.name.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase(), meta: A.genre + ' · ' + A.tracks.length + ' ' + plural(A.tracks.length, ['трек', 'трека', 'треков']),
onWave: () => { this.startWave(A.id); }, onPlay: () => this.playList(top, 0, 'Исполнитель · ' + A.name),
rows: top.map((id, i) => row(id, i, top, 'Исполнитель · ' + A.name)), albums: A.albums.map(id => ({ title: ALB[id].title, year: ALB[id].year, img: ALB[id].cover, onClick: () => this.goSub({ type: 'album', id }) })) };
}
if (page === 'profile') {
Object.assign(out, {
openPw: () => this.setState({ sheet: 'pw', f: {}, pwErr: '' }),
qualRows: QROWS.map(([k, l, ic]) => { const o = QOPT.find(q => q[0] === s.qual[k]); return { label: l, icon: ic, value: o[1] + ' · ' + o[2].split(' · ')[0], onClick: () => this.setState({ sheet: 'q-' + k }) }; }),
wifiOnly: { bg: s.wifiOnly ? 'var(--accent)' : 'var(--line2)', x: s.wifiOnly ? '24px' : '4px' }, toggleWifiOnly: () => this.setState({ wifiOnly: !s.wifiOnly }),
cacheStr: s.cache + ' МБ', clearCache: () => { this.setState({ cache: 0 }); this.toastMsg('Кэш очищен — скачанное не тронуто'); },
appVer: '0.6.1 · ' + (s.updSeen ? 'доступна 0.6.2' : 'проверено сегодня'), updDot: s.upd !== 'done',
checkUpdate: () => { if (s.upd === 'done') return this.toastMsg('У вас последняя версия'); this.setState({ sheet: 'update', updSeen: true }); },
openAdmin: () => this.toastMsg('Открываем https://' + s.srvAddr + '/admin в браузере')
});
}
if (sheet.qual) {
const k = s.sheet.slice(2), def = QROWS.find(r => r[0] === k);
out.qs = { title: 'Качество · ' + def[1].toLowerCase(), opts: QOPT.map(([v, l, sub]) => ({ label: l, sub, ring: s.qual[k] === v ? 'var(--accent)' : 'var(--line2)', dot: s.qual[k] === v ? 'var(--accent)' : 'transparent', onClick: () => this.setState({ qual: { ...s.qual, [k]: v }, sheet: null }) })) };
}
if (sheet.pw) {
out.pwErr = s.pwErr;
out.onPw = () => { const f = s.f; if (!f.cur) return this.setState({ pwErr: 'Введите текущий пароль' }); if ((f.np || '').length < 8) return this.setState({ pwErr: 'Новый пароль — минимум 8 символов' }); if (f.np !== f.np2) return this.setState({ pwErr: 'Пароли не совпадают' }); this.setState({ sheet: null, f: {} }); this.toastMsg('Пароль изменён'); };
}
if (sheet.update) {
const u = s.upd;
out.up = { isAvail: u === 'available', isPerm: u === 'perm', isDl: u === 'downloading', isVerify: u === 'verify', isReady: u === 'ready',
title: u === 'available' || u === 'perm' ? 'Доступна версия 0.6.2' : u === 'ready' ? 'Готово к установке' : 'Скачиваем обновление', sub: 'Сейчас 0.6.1 · APK 38,4 МБ · через сервер ' + s.srvAddr,
notes: ['Офлайн-волна учитывает выбранное настроение', 'Плавная докачка альбомов после обрыва сети', 'Исправлено: мини-плеер мигал при смене трека', 'Поддержка пользовательского CA для self-signed сертификатов'],
w: s.updPct + '%', pctStr: Math.round(s.updPct) + '%', dlStr: (s.updPct / 100 * 38.4).toFixed(1).replace('.', ',') + ' из 38,4 МБ',
onUpdate: () => this.setState({ upd: 'perm' }), onPerm: () => this.setState({ upd: 'downloading', updPct: 0 }),
onInstall: () => { this.setState({ sheet: null, upd: 'done' }); this.toastMsg('Открыт установщик Android — после установки приложение перезапустится'); } };
}
return out;
}
}
</script>
</body>
</html>