- 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>
791 lines
95 KiB
HTML
791 lines
95 KiB
HTML
<!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="{"theme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"string","section":"Вид"},"waveVariant":{"editor":"enum","options":["orb","rings","liquid"],"default":"orb","tsType":"string","section":"Вид"},"playerLayout":{"editor":"enum","options":["cover","wave"],"default":"cover","tsType":"string","section":"Варианты"},"ambient":{"editor":"boolean","default":true,"tsType":"boolean","section":"Вид"},"offline":{"editor":"boolean","default":false,"tsType":"boolean","section":"Прототип"},"role":{"editor":"enum","options":["user","moderator","admin"],"default":"user","tsType":"string","section":"Прототип"},"startScreen":{"editor":"enum","options":["app","connect"],"default":"app","tsType":"string","section":"Прототип"}}">
|
||
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>
|