feat(upload): enhance audio upload handling and user feedback
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 45s
ci / recommender / recommender (push) Successful in 1m50s
ci / ts / ts (packages + web) (push) Failing after 6m53s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m13s

- Introduced a new warning for users uploading non-FLAC or WAV formats, indicating that these formats should only be used as a last resort.
- Added functionality to highlight files needing format attention in the upload queue.
- Updated the UI to provide clearer guidance on acceptable audio formats during uploads.
- Improved documentation to reflect changes in upload requirements and user interface updates.
This commit is contained in:
mrleo1nid committed 2026-10-21 10:07:43 +03:00
1 parent 74da457362
commit 1e3b350dfc
49 files changed
+147 -5572

No files matched your search

+1 -4
View File
@@ -1,7 +1,6 @@
# Pipu Music — исходная идея продукта
> Документ описывает **что** должно быть в приложении с точки зрения пользователя.
> Используется как входные данные для создания дизайна. Технические решения — в [README документации](README.md).
> Как продукт выглядит для пользователя. Устройство системы — в [README документации](README.md).
## Суть
@@ -165,5 +164,3 @@
- Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки.
- Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок.
- Один репозиторий: веб, мобильное приложение, бэкенд, тесты, CI/CD.
- CI/CD на Gitea: по тегу собирается релиз.
+3 -8
View File
@@ -6,10 +6,9 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| Документ | О чём |
|---|---|
| [IDEA.md](IDEA.md) | Исходная идея продукта глазами пользователя (вход для дизайна) |
| [design.md](design.md) | Дизайн-система (токены, шрифты, анимация), кнопка волны, решения по вариантам прототипа, ссылки на макеты |
| [IDEA.md](IDEA.md) | Продукт глазами пользователя |
| [design.md](design.md) | Дизайн-система: токены, шрифты, анимация, кнопка волны, каркас экранов |
| [architecture.md](architecture.md) | Общая архитектура, компоненты, потоки данных, структура репозитория |
| [decisions.md](decisions.md) | ADR — принятые архитектурные решения и их обоснование |
| [data-model.md](data-model.md) | Схема БД PostgreSQL |
| [api.md](api.md) | REST API: соглашения, эндпоинты, ошибки |
| [auth-and-roles.md](auth-and-roles.md) | Аутентификация, токены, роли и права, первый запуск |
@@ -20,17 +19,13 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [web.md](web.md) | Веб-клиент |
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
| [testing.md](testing.md) | Стратегия тестирования |
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные |
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
## Стек в одном абзаце
Backend API — **Go**; рекомендации и аудиоанализ — **Python**; БД — **PostgreSQL + pgvector**;
поиск — **Meilisearch**; кэш / очереди событий — **Redis**; раздача аудио — **nginx (X-Accel-Redirect)**;
веб — **React + TypeScript + Vite**; мобильное — **React Native (Expo, Android)**;
монорепо — **pnpm + Turborepo**; CI/CD — **Gitea Actions**; деплой — **Docker Compose**.
деплой — **Docker Compose**.
-1
View File
@@ -114,7 +114,6 @@ Android-приложение обновляется само: при запус
| Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`) |
| Место на диске | `df -h /srv/pipu`; больше всего занимает `cache/transcode` (чистится сам) |
| Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» |
| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) |
## 8. Частые ситуации
+1 -1
View File
@@ -136,7 +136,7 @@
| GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` |
| POST | `/uploads/precheck` | moderator, admin | `{files:[{client_id, size, sha256, tags:{artist,title,album,duration_ms}}]}` → для каждого `accepted` / `duplicate` (+ `track`) / `too_large`; ничего не резервирует |
| POST | `/uploads` | moderator, admin | Создать пачку `{comment, files:[{client_id, name, rel_path, size, sha256}]}` → резерв квоты; `{batch_id, files:[{client_id, upload_file_id, tus_url} | {client_id, rejected: code}]}`; при нехватке места — `uploads.queue_full` для не поместившихся |
| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`, ядро tus 1.0.0 — ADR-015) |
| tus | `/uploads/files/{upload_file_id}` | автор | Докачка (`HEAD`/`PATCH`, ядро tus 1.0.0) |
| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения (`status` — через запятую) |
| DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения |
| GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам |
+2 -6
View File
@@ -101,7 +101,7 @@ pipu-music/
│ └── recommender/ # Python (uv)
│ ├── pipu_rec/
│ │ ├── api/ # FastAPI
│ │ ├── analysis/ # Essentia
│ │ ├── analysis/ # признаки и эмбеддинги (numpy)
│ │ ├── candidates/ ranking/ wave/ training/
│ │ └── storage/ # доступ к Postgres / Redis
│ └── tests/
@@ -111,10 +111,6 @@ pipu-music/
│ ├── compose.dev.yaml
│ ├── nginx/
│ └── .env.example
├── tests/
│ ├── e2e/ # Playwright
│ └── fixtures/music/ # генерируемые тестовые аудиофайлы
├── .gitea/workflows/ # ci-*.yml, docker.yml (по областям), release.yml
├── docs/
├── turbo.json pnpm-workspace.yaml package.json
└── README.md
@@ -127,7 +123,7 @@ pipu-music/
не лезут в чужие таблицы напрямую.
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
CI падает, если сгенерированный код не совпадает с контрактом.
Сгенерированный код совпадает с контрактом.
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
описан отдельными `internal-*.yaml` спеками.
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
-138
View File
@@ -1,138 +0,0 @@
# CI/CD (Gitea Actions)
Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner`
с доступом к Docker.
Сборке APK нужно ~15 ГБ свободного места на хосте раннера (Android SDK, NDK ~2.5 ГБ, кэш Gradle,
C++-сборка React Native). APK собирается только под `arm64-v8a`. Если job падает с `No space left on device`,
освободите место на хосте раннера: `docker system prune -af` и старые кэши act в `~/.cache/act`.
## Версионирование
- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их
предлагает только при включённой настройке «бета-версии»).
- Одна версия на весь продукт: образы, APK и веб собираются с одним номером.
- Android `versionCode = MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N)`,
так что `1.2.0-rc.1 < 1.2.0-rc.2 < 1.2.0 < 1.2.1`; MINOR и PATCH ≤ 99. Считает `scripts/version.mjs`
(тесты — `pnpm test:scripts`).
- Changelog — `git-cliff` из Conventional Commits.
## CI — на каждый push в `main` и PR
Проверки разбиты на workflow по областям, у каждого фильтр путей (`on.push.paths`): запускается только то,
что затронуто коммитом. Коммиты только в `docs/` и `*.md` CI не запускают вовсе.
| Workflow | Job | Что делает | Когда |
|---|---|---|---|
| `ci-api.yml` | `api` | gofmt, go vet, `go test -race`, сборка | `services/api/**`, `api/openapi.yaml` |
| `ci-contract.yml` | `contract` | регенерация Go-сервера, TS-клиента и токенов; всё ли закоммичено | `openapi.yaml`, `gen/`, `packages/api-client`, `packages/design-tokens`, общие файлы JS |
| `ci-recommender.yml` | `recommender` | ruff, mypy, pytest | `services/recommender/**` |
| `ci-ts.yml` | `ts` | i18n, тесты скриптов, typecheck (вкл. mobile), vitest, сборка веба | `apps/**`, `packages/**`, `scripts/**`, общие файлы JS |
| `ci-mobile.yml` | `mobile` | `expo-doctor`, Metro-бандл Android | `apps/mobile/**`, `packages/**`, общие файлы JS |
| `docker.yml` | `docker` | сборка всех 4 образов без push | `Dockerfile`, `.dockerignore`, `deploy/**`, манифесты зависимостей |
Общие файлы JS: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`.
Изменение самого workflow запускает его. У каждого workflow своя группа `concurrency`: новый push в ту же
ветку отменяет устаревший прогон.
`docker` собирает образы обычным `docker build` через Docker хоста раннера, а не в buildx-контейнере:
кэш слоёв переживает job, и повторная сборка занимает секунды. Кэш растёт — на хосте раннера раз в сутки:
`docker builder prune -af --filter until=72h` (см. [настройку раннера](#настройка-раннера)).
### Кэши
| Что | Где кэшируется | Чем |
|---|---|---|
| pnpm store | все job с `pnpm install` | `setup-node` `cache: pnpm` (кэш actions раннера) |
| Go-модули и build cache | `api`, `contract` | `setup-go` |
| uv | `recommender` | `setup-uv` `enable-cache` |
| Результаты turbo (typecheck, test, build) | `ts` | `actions/cache` для `.turbo/cache` |
| `npx expo-doctor`, трансформации Metro | `mobile` | `actions/cache` для `~/.npm/_npx` и `/tmp/metro-cache` |
| Слои Docker и `--mount=type=cache` | `docker`, сборка на сервере | Docker хоста; манифесты копируются раньше кода |
| Gradle (дистрибутив, зависимости, build cache), Android SDK и NDK | `android` в `release.yml` | тома `/opt/ci-cache/*` |
| Node, Go, Java, скачанные setup-action'ами | все job | том `act-toolcache` раннера |
Кэш actions работает, только если он включён на раннере ([настройка](#настройка-раннера)); без него в логах
`Cache action is only supported on GHES…`, и всё скачивается заново.
## `release.yml` — по тегу
```yaml
on:
push:
tags: ['v*.*.*']
jobs:
release: # версия из тега (scripts/version.mjs) и сразу Gitea Release (scripts/release.mjs create)
android: # needs: release — собирает APK и докладывает его в релиз
```
Тесты в релизе не повторяются: тег ставится на коммит, который CI уже проверил в `main`. **Ставьте тег
после зелёного CI** — релиз сам этого не проверяет.
Релиз появляется в первую же минуту, не дожидаясь APK. Текст строит `scripts/release.mjs`:
- **статус Android** вверху: «⏳ собирается» со ссылкой на ход сборки → «✅ собран» (имя файла, размер,
versionCode, SHA-256; предупреждение, если подпись отладочная) или «❌ не удалась» со ссылкой на лог;
- **изменения** — коммиты (Conventional Commits) с прошлого тега по разделам «Новое», «Исправления»,
«Сборка и CI» и т. д., отдельно — несовместимые (`feat!:`); для финального релиза отсчёт от прошлого
финального, а не от последнего `-rc`; ссылка на полный diff;
- **обновление сервера** — `./update.sh vX.Y.Z`, проверка версии; **установка APK** и проверка SHA-256.
Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease --build-cache` (только arm64-v8a), подпись `apksigner`,
затем `release.mjs android success <apk>` прикладывает APK и `.sha256` и меняет статус. При любом сбое —
`release.mjs android failure`. Перезапуск job безопасен: статус снова «собирается», файлы заменяются.
Перезапуск всего workflow обновляет текст существующего релиза, а не создаёт второй.
Docker-образы **не публикуются**: сервер собирает их сам из клонированного репозитория (ADR-013).
Чтобы сборка на сервере не ломалась, job `docker` в `ci.yml` собирает все образы без push на каждый PR.
## Настройка раннера
Раннер — `act_runner` на отдельной VM. Что ускоряет CI/CD (`config.yaml` раннера, затем перезапуск сервиса):
```yaml
runner:
capacity: 2 # два job параллельно; только если у VM ≥ 8 ГБ RAM (иначе APK-сборка упирается в OOM)
cache:
enabled: true # кэш actions/cache: pnpm store, Go-модули, uv
host: 10.10.1.x # IP хоста раннера, доступный из job-контейнеров (не 127.0.0.1)
port: 8088 # фиксированный порт; открыть его для docker-сетей
container:
valid_volumes:
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
```
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=7g` без свопа, Gradle `-Xmx2g`, Metro `--max-old-space-size=1536`, таймаут 60 мин, CMake / ninja / Gradle
по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
Без `valid_volumes` раннер пропускает тома из `release.yml`, и каждый релиз заново качает Gradle
и NDK (~4 мин). Признак выключенного кэша actions в логах — `Cache action is only supported on GHES…`.
Обслуживание хоста раннера (cron, раз в сутки):
```
0 4 * * * docker image prune -af --filter "until=72h"; docker builder prune -af --filter "until=72h"
```
Кэши в `/opt/ci-cache` занимают ~5–6 ГБ (Gradle, SDK, NDK); если место кончается — их можно удалить целиком,
следующий релиз скачает всё заново.
## Секреты
| Секрет | Назначение |
|---|---|
| `RELEASE_TOKEN` | Необязательно. Создание и редактирование релиза, загрузка ассетов (scope `write:repository`). Без него используется встроенный токен job (`GITHUB_TOKEN`), у которого есть права на запись в этот репозиторий |
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
Переменная репозитория `PUBLIC_URL` (Settings → Actions → Variables) — внешний адрес Gitea для ссылок
в тексте релиза, по умолчанию `https://gitea.hsrv.site`. Нужна потому, что job обращается к API по внутреннему
адресу, и ссылки из ответа API ведут туда же.
## Деплой
Автодеплой не обязателен. На сервере в клоне репозитория: `deploy/update.sh [vX.Y.Z]` — checkout тега,
`docker compose build` и `up -d`; миграции применяются при старте `api` ([install-guide.md](install-guide.md)).
Опционально — job `deploy` в `release.yml` по SSH на сервер для не-pre-release тегов (с ручным approval через environment).
-42
View File
@@ -1,42 +0,0 @@
# Соглашения
## Git
- Trunk-based: короткие ветки от `main`, merge через PR (squash).
- Имена веток: `feat/wave-moods`, `fix/scan-rename`, `chore/ci-cache`.
- Conventional Commits: `feat(wave): add running profile`, `fix(api): ...`. Скоупы: `api`, `agent`, `rec`, `web`,
`mobile`, `core`, `i18n`, `ci`, `deploy`, `docs`. Breaking change — `!` и раздел `BREAKING CHANGE:`.
- Изменение API = изменение `openapi.yaml` + регенерация клиентов в том же PR.
- Существенные архитектурные решения — новый ADR в [decisions.md](decisions.md).
## Go (`services/api`)
- `gofumpt`, `golangci-lint` (конфиг в репо). Ошибки оборачиваются `fmt.Errorf("...: %w", err)`;
доменные ошибки — типизированные (`ErrForbidden`, `ErrNotFound`) и маппятся в problem+json в одном месте.
- Контекст первым аргументом, без глобального состояния, зависимости через конструкторы (`NewService(repo, clock, ...)`).
- `clock` и генератор ID — интерфейсы (детерминированные тесты).
- SQL — только в `queries/*.sql` (sqlc), без ORM.
## Python (`services/recommender`)
- `uv`, `ruff` (lint + format), `mypy --strict`, Pydantic v2 для схем.
- Тяжёлые вычисления — `numpy`, без циклов Python по трекам.
- Конфиги контекстов и весов — YAML, валидируются Pydantic при старте.
## TypeScript
- `strict`, ESLint (flat config) + Prettier, без `any` (кроме сгенерированного кода).
- Общая логика — в `packages/core`, платформенная — только адаптеры в `apps/*`.
- Все строки UI — через i18n, хардкод текста ловит `eslint-plugin-i18next`.
## API
- `snake_case` в JSON, курсорная пагинация, problem+json с `code`.
- Новые поля — только дополнительные; удаление / переименование — через новую версию эндпоинта или депрекацию на один минорный релиз.
## Безопасность
- Секреты только через env / docker secrets, никогда в репозитории.
- Любой ввод, попадающий в команды (`mount`, `ffmpeg`), — только как отдельные аргументы `exec.Command`, без shell,
с whitelist-валидацией.
- Пути файлов — проверка, что итоговый путь внутри корня источника (`filepath.Rel` без `..`).
-205
View File
@@ -1,205 +0,0 @@
# Архитектурные решения (ADR)
Формат: контекст → решение → последствия. Новое решение — новый раздел с номером; старые не переписываются,
а помечаются `Заменено ADR-NNN`.
---
## ADR-001. Монорепозиторий
**Контекст.** Веб, мобильное, бэкенд, тесты и CI должны жить в одном репозитории; у веба и мобильного много общего
(API-клиент, логика очереди и событий, словари i18n).
**Решение.** Монорепо: pnpm workspaces + Turborepo для TS-части; Go-модуль в `services/api`; Python-проект
(uv) в `services/recommender`. Единый `api/openapi.yaml`.
**Последствия.** Атомарные изменения контракта и клиентов в одном PR. CI использует фильтры путей,
чтобы не гонять всё на каждый коммит.
---
## ADR-002. Backend API на Go
**Контекст.** Нагрузка — IO (стриминг, NFS, БД), десятки одновременных слушателей; важна простота деплоя и
низкое потребление памяти.
**Решение.** Go 1.23+: роутер `chi`, `oapi-codegen` (strict server), `pgx` + `sqlc`, миграции `goose`,
фоновые задачи `River` (очередь в Postgres), логирование `log/slog`, конфиг через env (`caarlos0/env`).
**Последствия.** Один статический бинарник на сервис, быстрые холодные старты, тайпсейф SQL.
ML-код в Go не пишем — для этого отдельный сервис (ADR-003).
---
## ADR-003. Рекомендации и аудиоанализ — отдельный Python-сервис
**Контекст.** Нужны: аудиоанализ (Essentia и её предобученные модели), коллаборативная фильтрация (implicit ALS),
learning-to-rank (LightGBM). Экосистема есть только в Python.
**Решение.** `services/recommender`: FastAPI (онлайн-инференс), отдельный процесс `analyzer` (тот же образ),
обучение по расписанию (APScheduler внутри сервиса). Python 3.12, `uv`, `numpy`, `implicit`, `lightgbm`,
`essentia-tensorflow`, `psycopg`, `redis`.
**Последствия.** Два языка на бэкенде. Граница чёткая: Python не обслуживает пользователей напрямую,
только внутренние вызовы `api`. При падении сервиса работает fallback в `api`.
⚠️ Предобученные модели Essentia распространяются под CC BY-NC-SA 4.0 — допустимо для частного
некоммерческого self-hosted использования. При коммерческом использовании нужно заменить модели.
---
## ADR-004. Мобильное приложение — React Native (Expo), только Android
**Контекст.** Только Android, самообновление APK из релизов, фоновое воспроизведение, офлайн, анимированная кнопка волны.
Рассматривались Flutter и React Native.
**Решение.** React Native + Expo SDK (prebuild / dev client, не Expo Go), `expo-router`,
`react-native-track-player` (ExoPlayer под капотом, MediaSession, уведомление, кэш), `@shopify/react-native-skia`
+ `react-native-reanimated` для волны, `expo-sqlite` для офлайн-данных, `expo-file-system` для загрузок.
**Почему не Flutter.** Flutter не хуже по возможностям, но React Native даёт общий TypeScript-код с вебом:
API-клиент, типы, логику очереди / событий / офлайн-синхронизации, словари i18n, дизайн-токены.
Для команды из одного-двух человек это существенно сокращает объём работы.
**Последствия.** iOS не поддерживается (самообновление в обход стора там невозможно); при необходимости
iOS добавляется позже через TestFlight без смены стека.
---
## ADR-005. Раздача аудио через nginx X-Accel-Redirect
**Контекст.** Нужна перемотка (HTTP Range), высокая пропускная способность, минимум нагрузки на бэкенд.
**Решение.** Клиент обращается к `api`, `api` проверяет права и подпись ссылки и отвечает заголовком
`X-Accel-Redirect` на `internal` location nginx. Файл отдаёт nginx.
**Последствия.** Go не держит долгих соединений со стримами. Тома библиотеки и кэша транскодов
должны быть смонтированы и в `api`, и в `nginx` по одинаковым путям.
---
## ADR-006. Подписанные ссылки на стрим
**Контекст.** `<audio>` в браузере и ExoPlayer не всегда умеют передавать заголовок `Authorization`.
**Решение.** В DTO трека отдаётся `stream_url` с параметрами `exp` и `sig` = HMAC-SHA256(user_id, track_id, quality, exp).
Срок жизни — 12 ч. Для офлайн-загрузки — отдельная ссылка со сроком 1 ч.
**Последствия.** Ссылки нельзя использовать после блокировки пользователя дольше срока жизни — при блокировке
дополнительно проверяется статус пользователя в `api` (кэш в Redis, 60 с).
---
## ADR-007. Поиск — Meilisearch
**Контекст.** Мгновенный автокомплит по трём типам сущностей, опечатки, русский и английский, 11k–100k документов.
**Решение.** Meilisearch, три индекса (`artists`, `albums`, `tracks`), `multi-search` для выпадающего списка.
Postgres остаётся источником истины, индексы перестраиваемы.
**Альтернатива.** `pg_trgm` в Postgres — меньше компонентов, но хуже автокомплит и ранжирование. Оставлено как fallback.
---
## ADR-008. Подключение NFS из админки через storage-agent
**Контекст.** Админ должен подключать хранилища из UI. Монтирование требует `CAP_SYS_ADMIN`,
давать его публичному `api` небезопасно.
**Решение.** Отдельный минимальный сервис `storage-agent` с `CAP_SYS_ADMIN`, без публичных портов.
Монтирует в `/library/<source_id>` внутри общего bind-тома с `propagation: rshared`;
`api`, `nginx`, `analyzer` видят точки монтирования через `rslave`. Поддерживаются типы
`nfs` (монтирует агент) и `local` (папка, уже смонтированная на хосте).
**Последствия.** Требуется Linux-хост (mount propagation) и `nfs-common` в образе агента.
При рестарте агент перемонтирует все активные источники из БД. Дополнено ADR-012 (запись).
---
## ADR-009. Хранение векторов — pgvector
**Контекст.** Эмбеддинги треков (~11k–100k × 256 float) и векторы вкусов (~100). Нужен kNN.
**Решение.** pgvector, HNSW-индекс, косинусное расстояние. Отдельная векторная БД не нужна при таком объёме.
---
## ADR-010. Аутентификация — свои JWT + ротируемые refresh-токены
**Решение.** Пароли — argon2id. Access JWT (EdDSA, 15 мин), refresh — непрозрачный токен (хэш в БД), 30 дней,
ротация при каждом использовании с детекцией повторного использования (отзыв всей «семьи»).
Веб хранит refresh в `HttpOnly; Secure; SameSite=Strict` cookie, мобильное — в `expo-secure-store` (Android Keystore).
Подробно — [auth-and-roles.md](auth-and-roles.md).
---
## ADR-011. Ошибки API — RFC 9457 + коды для i18n
**Решение.** Ответы об ошибках — `application/problem+json` с машинным полем `code` (`auth.invalid_credentials`,
`validation.password_too_short`, …). Клиенты переводят `code` через словари; сервер не возвращает локализованных текстов.
---
## ADR-012. Очередь на добавление треков и запись в хранилище
**Контекст.** Модераторы предлагают новые треки; очередь ограничена по размеру (настраивает админ); дубли отсекаются;
одобренные файлы должны лечь на файловый сервер в принятой там структуре папок. До этого библиотека была строго read-only.
**Решение.**
- Загрузка по протоколу **tus** (resumable) в локальный staging, квота — счётчик в Postgres, резервируется до загрузки.
- Дубли — три уровня: SHA-256 (предпроверка на клиенте), нормализованные метаданные, акустический отпечаток **Chromaprint**.
- Запись в хранилище — **только `storage-agent`**, только в источники с `accepts_uploads`, через отдельную `rw`-точку
монтирования, невидимую другим контейнерам; только создание новых файлов (tmp → fsync → hash → rename).
- Путь — по `write_template` хранилища; шаблон автоподбирается по существующей структуре и проверяется на обратимость
регэкспами хранилища.
**Последствия.** Публичный `api` по-прежнему не может изменить библиотеку. Нужны права на запись в NFS-экспорт
для UID агента. Подробно — [uploads.md](uploads.md).
---
## ADR-013. Образы собираются на сервере, реестр не используется
**Контекст.** Публикация образов в Gitea Container Registry требует настройки реестра, токенов на push и pull,
`docker login` на сервере. Сервер один, и он сам может собрать образы.
**Решение.** На сервере клонируется репозиторий; `deploy/update.sh [vX.Y.Z]` переключается на тег и выполняет
`docker compose build` + `up -d` (`build:` у сервисов в `deploy/compose.yaml`). Релиз в Gitea содержит только APK.
CI на каждый PR собирает все образы без push, чтобы сборка на сервере не ломалась.
**Последствия.** Серверу нужен доступ к репозиторию и интернет на время сборки; первая сборка — 5–15 минут,
нужно ~5–10 ГБ под кэш сборки. Откат — `update.sh` на предыдущий тег (пересборка из кэша). Отпадают секреты
`REGISTRY_USER` / `REGISTRY_TOKEN`. Заменяет часть ADR-001 / ci-cd о публикации образов.
---
## ADR-014. Анализатор v1 — признаки на numpy, Essentia позже
**Контекст.** Нейросетевые модели Essentia (Discogs-EffNet, жанры, настроения) требуют essentia-tensorflow:
тяжёлый образ, ограниченный набор платформ и версий Python. Волне v1 нужны звучание и базовые признаки уже сейчас.
**Решение.** Анализатор `basic-1` (`services/recommender/pipu_rec/analysis/features.py`) считает признаки сам,
на numpy поверх декодирования ffmpeg: 3 фрагмента по 30 с; эмбеддинг 256 = форма спектра, разброс, динамика
и ритмичность по 64 mel-полосам (рекомендатель стандартизирует его по библиотеке); темп по автокорреляции онсетов;
тональность по хромаграмме; громкость, энергия, танцевальность, акустичность, длина вступления.
Valence — грубая оценка (лад + яркость); `mood_*`, `arousal`, жанры анализа не заполняются.
**Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность
и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия
`analyzer_ver`: воркер сам ставит пересчёт всех треков.
---
## ADR-015. Приём загрузок — ядро tus своими силами, без tusd
**Контекст.** ADR-012 предполагал `tusd` как библиотеку. Но файл очереди создаёт `POST /uploads` вместе с резервом
квоты и проверкой дублей, а права (только автор, только `uploading`) проверяет `api`. От tus остаётся только ядро
протокола: `HEAD` (сколько принято) и `PATCH` (следующий кусок с `Upload-Offset`).
**Решение.** Ядро tus 1.0.0 — в `internal/uploads/tus.go` (~200 строк): смещение — фактический размер файла в staging,
один `PATCH` на файл одновременно, данные сверх заявленного размера не принимаются, куски до 64 МБ. Расширения
creation / termination / checksum не нужны. Клиент — `tus-js-client` с готовым `uploadUrl` из ответа `POST /uploads`.
**Последствия.** Нет новой зависимости и отдельного хранилища состояния tus; квота и права — в одной транзакции
с созданием файла. Целостность проверяется после приёма: SHA-256 файла сверяется с заявленным клиентом.
+1 -2
View File
@@ -101,5 +101,4 @@ cd /opt/pipu-music/deploy && ./update.sh # последний рели
```
Скрипт: `git fetch` → checkout тега → `docker compose build --pull` → `up -d` → удаление старых образов →
ожидание `/readyz`; миграции применяются при старте api.
Конкретная версия или откат: `./update.sh v1.2.4` (миграции пишутся обратно-совместимыми в пределах минорной версии: сначала добавить,
потом — в следующем релизе — удалить).
Конкретная версия или откат кода: `./update.sh v1.2.4`. Откат тега не откатывает уже применённые миграции базы.
+7 -23
View File
@@ -1,29 +1,14 @@
# Дизайн и дизайн-система
Источник — кликабельные прототипы в [`Разработка дизайна приложения/`](Разработка%20дизайна%20приложения/):
| Файл | Что внутри |
|---|---|
| `Pipu Web.dc.html` | Веб: вход, setup-экраны, главная, поиск, исполнитель, альбом, коллекция, профиль, вся админка, диалоги, очередь плеера |
| `Pipu Android.dc.html` | Android: подключение к серверу, вход, главная, поиск, коллекция (со «Скачанным»), альбом, исполнитель, профиль, полноэкранный плеер, листы (качество, пароль, очередь, обновление), офлайн-режим |
| `Wave Options.dc.html` | Три варианта кнопки «Моя волна» |
| `wave.js` | Реализация кнопки волны (Canvas 2D, React) — **референс для продакшен-компонента** |
| `pipu-data.js` | Демо-данные прототипа |
| `support.js`, `android-frame.jsx` | Рантайм прототипа и рамка устройства — в продукт не переносятся |
Просмотр прототипов: прототипам нужен HTTP-сервер (из `file://` скрипты не работают).
В репозитории есть конфиг `.claude/launch.json` (`python -m http.server 8765 --directory docs`),
вручную — та же команда, затем `http://localhost:8765/`.
## 1. Принятые решения по вариантам прототипа
## 1. Внешний вид
| Развилка | Решение |
|---|---|
| Кнопка «Моя волна» | **Сфера с шумом (`orb`)** и **Жидкая форма (`liquid`)** — пользователь выбирает в профиле, по умолчанию `orb`. Вариант «Кольца» не реализуем. |
| Кнопка «Моя волна» | **Сфера с шумом (`orb`)** и **Жидкая форма (`liquid`)** — пользователь выбирает в профиле, по умолчанию `orb` |
| Каркас веба | **Classic**: сайдбар 240 px, топбар 60 px, разделители-линии, без «парящих» панелей |
| Плеер веба | **Оба**: `bar` (полоса 88 px внизу, по умолчанию) и `island` (плавающий остров) — выбор в профиле |
| Главная | **Center**: волна по центру, под ней чипы «Занятие» и «Настроение» |
| Очередь на добавление (админ) | `grouped` — группировка по альбомам (вариант прототипа по умолчанию) |
| Очередь на добавление (админ) | Группировка по пачкам (обычно альбом) |
| Полноэкранный плеер Android | `cover` (крупная обложка) по умолчанию, `wave` (визуализация волны) — переключатель в профиле |
| Тема | Тёмная (по умолчанию) и светлая, переключатель в профиле; `system` — следовать ОС |
| Ambient-фон | Включён: размытые дрейфующие пятна, третье пятно окрашено в цвет обложки текущего трека; отключается в профиле и при `prefers-reduced-motion` |
@@ -34,7 +19,6 @@
## 2. Токены
Источник истины — `packages/design-tokens/tokens.json`; из него генерируются CSS-переменные (веб) и TS-объект (React Native).
Значения взяты из прототипа без изменений.
### Цвета
@@ -91,12 +75,12 @@
| Появление контента | `pipuIn`: opacity + translateY(6px), 200 мс |
| Эквалайзер текущего трека | `pipuEq`: scaleY 0.25 → 1 |
| Ambient-фон | 3 пятна 55–70vmax, blur 30 px, дрейф 16 / 21 / 26 с, `hue-rotate` ±35° за 30 с, прозрачность `amb-op` (0.8 тёмная / 0.45 светлая) |
| Пружины (волна, нажатия) | Демпфированная пружина как в `wave.js` (`v += (target − x)·0.35; v *= 0.62`) |
| Пружины (волна, нажатия) | Демпфированная пружина (`v += (target − x)·0.35; v *= 0.62`) |
| `prefers-reduced-motion` | Ambient статичен, волна замедлена ×0.15 без пульса |
## 3. Кнопка «Моя волна»
Продакшен-компонент — порт `wave.js` на TypeScript, **Canvas 2D** (прототип показал, что этого достаточно; WebGL не нужен).
Кнопка рисуется на **Canvas 2D** (веб) и Skia (Android). WebGL не используется.
```ts
interface WaveButtonProps {
@@ -113,7 +97,7 @@ interface WaveButtonProps {
}
```
Поведение (как в прототипе):
Поведение:
- **Пульс в такт**: фаза растёт со скоростью `bpm/60`, импульс `(1 − phase)^4 · (0.35 + 0.65·energy)`; в покое — медленное «дыхание».
- **Hover**: скорость анимации ×(1 + 1.4·hover), формы смещаются к курсору; **press**: сжатие до 0.93 с отскоком;
клик / Enter / Space — запуск / пауза.
@@ -134,7 +118,7 @@ interface WaveButtonProps {
очередь, громкость. Очередь — выезжающая справа панель; в режиме волны внизу пометка «Волна бесконечна…».
- Диалоги: стеклянные карточки по центру поверх `overlay`. Тосты — внизу над плеером.
Экраны, которые есть в прототипе, и документы, где описана логика:
Экраны и документы, где описана логика:
| Экран | Логика |
|---|---|
+1 -6
View File
@@ -28,9 +28,4 @@ packages/i18n/
(`"validation.password_too_short": "Пароль должен быть не короче {{min}} символов"`).
Неизвестный код → общий текст «Что-то пошло не так» + код в деталях.
## Процесс
- CI запускает `pnpm i18n:check` — отсутствующий ключ в любом языке ломает сборку.
- Новые строки сначала добавляются в `en` и `ru` одновременно в том же PR.
- В будущем — подключение платформы переводов (Weblate, self-hosted) к папке `locales/`, формат JSON совместим.
- Тесты UI запускаются минимум на двух локалях (длинные русские строки ловят проблемы вёрстки).
Ключи есть в каждом языке, лишних нет, плейсхолдеры совпадают — это проверяет `packages/i18n/scripts/check.ts`.
+13 -20
View File
@@ -13,10 +13,6 @@
Готовые Docker-образы не публикуются: сервер клонирует репозиторий и собирает образы сам
(`docker compose build`). Релизы в Gitea содержат только APK для Android.
> **Что умеет текущая версия.** В `v0.1.0` (этап M0) работают развёртывание, HTTPS и проверка связи
> клиентов с сервером. Вход, библиотека и плеер появятся в следующих версиях
> (см. [roadmap.md](roadmap.md)). Шаги, которые зависят от версии, помечены.
---
## 1. Что нужно заранее
@@ -192,12 +188,10 @@ LIBRARY_HOST=/mnt/music
./update.sh
```
Скрипт переключает репозиторий на последний релизный тег (например, `v0.1.1`), собирает образы, запускает
Скрипт переключает репозиторий на последний релизный тег, собирает образы, запускает
все сервисы и ждёт готовности API (до 2 минут). Первая сборка занимает 5–15 минут, следующие — быстрее
за счёт кэша Docker. Конкретная версия: `./update.sh v0.1.1`.
за счёт кэша Docker. Конкретная версия: `./update.sh v1.2.0`.
> Локальная сборка поддерживается начиная с `v0.1.1`: в теге `v0.1.0` `compose.yaml` ещё ссылается на реестр образов.
> Пока `v0.1.1` не вышел, запускайте `./update.sh main`.
При первом запуске Caddy сам получит сертификат для `DOMAIN`, обычно за 10–30 секунд.
Проверка с любого компьютера:
@@ -206,7 +200,7 @@ LIBRARY_HOST=/mnt/music
curl -s https://music.example.com/api/v1/server-info
```
Ожидаемый ответ: `{"name":"pipu-music","version":"0.1.0",...}`. Проверка всех зависимостей:
Ожидаемый ответ — JSON с полями `name` и `version`. Проверка всех зависимостей:
```bash
curl -s https://music.example.com/readyz
@@ -218,11 +212,9 @@ curl -s https://music.example.com/readyz
## 8. Веб-клиент
Откройте `https://music.example.com` в браузере.
Откройте `https://music.example.com` в браузере. На экране входа можно переключить язык RU / EN.
- **v0.1.0:** карточка «Сервер 0.1.0 на связи» и переключатель языка RU / EN.
- **С версии 0.2 (M1):** экран входа. Первый вход — логин `admin` и пароль, который сервер один раз
выводит в лог при первом запуске:
Первый вход — логин `admin` и пароль, который сервер один раз выводит в лог при первом запуске:
```bash
docker compose logs api | grep -i bootstrap
@@ -231,10 +223,11 @@ curl -s https://music.example.com/readyz
Сразу после входа приложение попросит сменить логин и пароль администратора
([auth-and-roles.md](auth-and-roles.md#первый-запуск-bootstrap)). Чтобы задать пароль заранее, впишите
`BOOTSTRAP_ADMIN_PASSWORD` в `.env` **до** первого запуска.
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» → выберите
подпапку на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей
→ «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
Музыку подключает администратор: Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» →
подпапка на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей →
«Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
---
@@ -246,10 +239,10 @@ curl -s https://music.example.com/readyz
разрешите и вернитесь к установке.
3. Запустите **Pipu Music**, на экране подключения введите адрес сервера: `music.example.com`
(`https://` подставится сам) → «Проверить».
4. Ожидаемый результат — «Сервер 0.1.0 на связи». Если версия приложения слишком старая для сервера,
приложение попросит обновиться.
4. Приложение показывает, что сервер на связи. Если установленная версия слишком старая,
оно попросит обновиться.
Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер
Дальше приложение обновляется само: проверяет новые версии через ваш сервер
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
+7 -12
View File
@@ -21,16 +21,12 @@
- APK только под `arm64-v8a` (`plugins/withArm64Only.js`): нативные модули собираются под одну ABI, а без фильтра
в APK попадали неполные каталоги `x86`, `x86_64`, `armeabi-v7a` из библиотек React Native — эмулятор x86 и
32-битные устройства выбирали их и падали при старте (`couldn't find DSO to load: libexpo-modules-core.so`).
Эмулятор для проверки — образ arm64 или настоящее устройство.
- Все `.so` выровнены по 16 КБ: устройства с Android 15+ и 16 КБ страницами памяти не загружают библиотеки
с выравниванием 4 КБ, приложение закрывается сразу после запуска. Skia 2.0.0-next.4 собиралась без
`ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES` — флаг добавлен pnpm-патчем (`patches/`).
- `scripts/check-apk.mjs` в релизе проверяет ABI, наличие ключевых библиотек и выравнивание; без этого релиз
не публикуется.
с выравниванием 4 КБ, приложение закрывается сразу после запуска.
## Экраны
Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android).
Сводка внешнего вида — [design.md](design.md#android).
- **Подключение и вход**: адрес сервера (проверка `GET /server-info`: версия сервера, HTTPS, поддерживается ли эта версия
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
@@ -53,9 +49,8 @@
загрузки (high) → параметр `q` стрима.
- **Кэш недавних треков** — кэш ExoPlayer (`cacheDir/TrackPlayer`, 500 МБ). Пока сервис плеера жив, SimpleCache держит
индекс в памяти, поэтому «Очистить» помечает кэш, а удаляется он при следующем холодном запуске до `setupPlayer`.
Путь каталога взят из KotlinAudio — проверить на устройстве.
- **Офлайн-баннер** вверху, когда нет связи с сервером.
- Админку в мобильном **не делаем** на первом этапе (ссылка «открыть в браузере»).
- Админка открывается в браузере (ссылка в профиле у персонала).
## Воспроизведение
@@ -118,13 +113,13 @@
- В манифесте: `REQUEST_INSTALL_PACKAGES` (FileProvider добавляет expo-file-system).
- Сервер: `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN`, кэш APK — `UPDATES_DIR` (`/updates`). Без них — `404 app.update_unavailable`,
приложение молча пропускает проверку.
- Все релизы подписываются **одним и тем же ключом** (keystore в секретах CI) — иначе Android откажет в обновлении.
- `versionCode` монотонно растёт: `major·1 000 000 + minor·10 000 + patch·100 + (99 | rc)` (см. [ci-cd.md](ci-cd.md#версионирование)).
- Все версии подписываются **одним и тем же ключом** — иначе Android откажет в обновлении.
- `versionCode` монотонно растёт: `major·1 000 000 + minor·10 000 + patch·100 + (99 для выпуска, N для -rc.N)`.
- Ассеты релиза: `pipu-music-<version>.apk`, `pipu-music-<version>.apk.sha256`.
## Безопасность
- Refresh-токен — `expo-secure-store` (Android Keystore).
- HTTPS обязателен; для self-signed сертификатов — возможность добавить пользовательский CA через
`network_security_config` (собирается в CI по флагу) или пиннинг отпечатка, введённого при подключении к серверу.
- HTTPS обязателен. Самоподписанный сертификат принимается, если при подключении указан его отпечаток
или на устройство установлен пользовательский CA.
- `android:allowBackup="false"` для данных с токенами.
+3 -6
View File
@@ -253,10 +253,7 @@ MMR-разнообразие и квоты остаются. Пока модел
- `POST /wave/sessions/{id}/next`: p95 < 150 мс (включая вызов recommender).
- Онлайн-обновление профиля по событию: < 50 мс, асинхронно из Redis Stream.
## 11. Эволюция
## 11. Что умеет волна
| Версия | Содержимое |
|---|---|
| v1 | Аудиоанализ, эвристическое ранжирование, контексты, сессионный вектор, правила, fallback |
| v2 | ALS, LightGBM-ранжировщик, бандит, центроиды вкуса, онбординг |
| v3 | Последовательная модель по сессиям (SASRec / небольшой трансформер) как дополнительный источник кандидатов и признак — когда накопится ≥ 1 млн событий; «волна по исполнителю / треку», ежедневные персональные подборки |
Эвристическое ранжирование, контексты (занятие и настроение), сессионный вектор и запасной алгоритм в `api` работают всегда.
ALS, LightGBM, бандит, центроиды вкуса и онбординг включаются на накопленных прослушиваниях: ранжировщик — после примерно 20 тысяч размеченных показов.
-62
View File
@@ -1,62 +0,0 @@
# План разработки
Каждый этап заканчивается рабочей версией с тегом и релизом.
## M0 — Скелет (v0.1)
- Монорепо: pnpm + Turborepo, Go-модуль, Python-проект, `openapi.yaml` с `server-info`.
- `deploy/compose.dev.yaml`, nginx, Postgres, Redis, Meilisearch.
- `ci.yml` и `release.yml` (пока собирают пустые образы и debug-APK «hello»), генерация тестовой музыки.
- **Готово, когда:** тег `v0.1.0` создаёт релиз в Gitea с образами и APK.
## M1 — Доступ (v0.2)
- Bootstrap-админ, вход, refresh-ротация, смена пароля / учётных данных, сессии.
- Роли и политика прав, админка «Пользователи», аудит.
- Веб: каркас (топбар, сайдбар, пустой плеер), экраны входа и setup, i18n ru / en.
- **Готово, когда:** E2E-сценарии 1, 2, 8, 9, 10 зелёные.
## M2 — Библиотека и поиск (v0.3)
- storage-agent, источники NFS / local, тест подключения.
- Шаблоны путей с предпросмотром, сканер (инкрементальный, переименования, дубли), обложки.
- Meilisearch, suggest и страница поиска, страницы исполнителя и альбома.
- **Готово, когда:** реальная библиотека 11k треков сканируется, поиск < 50 мс, сценарии 3, 4.
## M3 — Воспроизведение (v0.4)
- Стриминг через X-Accel, подписанные ссылки, транскодинг и кэш.
- Плеер в вебе (очередь, Media Session, горячие клавиши, нормализация), сохранение состояния.
- События, история, лайки / дизлайки, раздел «Коллекция».
- **Готово, когда:** сценарии 5, 7; перемотка мгновенная; позиция восстанавливается на другом устройстве.
## M4 — Моя волна v1 (v0.5)
- analyzer (Essentia), признаки и эмбеддинги, прогресс в админке.
- recommender: кандидаты, эвристическое ранжирование, контексты (занятия / настроения), сессионный вектор,
правила разнообразия; fallback в Go.
- Анимированная кнопка волны и MoodPicker в вебе.
- **Готово, когда:** сценарий 6; метрики волны видны в админке.
## M5 — Android (v0.6)
- Вход с адресом сервера, главная с волной, поиск, коллекция, плеер с фоновым воспроизведением.
- Офлайн: загрузки, «Скачанное», очередь событий.
- Самообновление через сервер.
- **Готово, когда:** Maestro-сценарии зелёные; обновление 0.6.0 → 0.6.1 проходит на реальном устройстве.
## M5.5 — Очередь на добавление (v0.7)
- Отпечатки (SHA-256, Chromaprint) для существующей библиотеки.
- Запись через storage-agent, `accepts_uploads`, `write_template` с автоподбором и проверкой обратимости.
- tus-загрузка, предпроверка дублей, квота, обработка, экраны «Предложить треки», «Очередь на добавление», «Настройки».
- **Готово, когда:** E2E-сценарии 11, 12; импорт реального альбома ложится рядом с существующими по той же структуре.
## M6 — Моя волна v2 (v0.8)
- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
- Персонализация suggest.
- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
- **Состояние:** всё реализовано (v0.8, v1.0). Ранжировщик включается сам после ~20 тыс. размеченных показов;
критерий проверяется по метрикам версий в админке («Библиотека → Моя волна») и NDCG в `model_versions`.
## M7 — Стабилизация (v1.0)
- Нагрузочный smoke, документация администратора, доступность, полировка анимаций, багфиксы.
- **Состояние:** v1.0 — [руководство администратора](admin-guide.md), аудит доступности WCAG 2.1 AA, k6-smoke
(`tests/load/smoke.js`, запускается вручную на сервере перед релизом). Копии базы и музыки — внешними средствами.
## Дальше (идеи)
- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
Android Auto, iOS (TestFlight), Last.fm-скробблинг, импорт лайков.
-79
View File
@@ -1,79 +0,0 @@
# Тестирование
## Пирамида
| Уровень | Где | Инструменты | Что покрываем |
|---|---|---|---|
| Unit | Go | `testing`, `testify`, табличные тесты | Политики прав (вся матрица из auth-and-roles.md), парсинг шаблонов путей и слияние с тегами, нормализация имён, подпись ссылок, расчёт `implicit_score`, рендер `write_template` и очистка имён, обратимость шаблон ↔ регэксп, учёт квоты при конкурентных резервах, переходы статусов загрузки |
| Unit | Python | `pytest`, `hypothesis` | Формулы скоринга, context_fit, MMR, бандит, обновление сессионного вектора, правила переранжирования |
| Unit | TS | `vitest` | `packages/core`: очередь, события (подсчёт `played_ms`, засчитанное прослушивание), single-flight refresh, офлайн-очередь |
| Компоненты | Web | `vitest` + Testing Library + axe-core | Поиск, плеер, волна, формы админки, вход; серьёзные нарушения axe на главной и входе |
| Компоненты | Mobile | `vitest` (`apps/mobile/test`) | Офлайн-волна и решение диалога обновления |
| Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер, API через `httptest`; `TestUserJourney` — сценарии 1–2 и скан |
| Интеграция | Python | `testcontainers` | Чтение событий из Redis Stream, kNN в pgvector, ночной пайплайн на синтетических данных |
| Контракт | — | `tests/contract/schemathesis.sh` | Ответы соответствуют `openapi.yaml`; нужен запущенный `api` |
| E2E | Web | Playwright (`apps/web/e2e`, Chromium, `pnpm --filter @pipu/web test:e2e`) | Сценарии 1, 4 и 9 на фейковом API |
| E2E | Mobile | Maestro (`apps/mobile/.maestro`) | Вход, коллекция, проверка обновления. Ночной прогон (`PIPU_E2E_SERVER`, `PIPU_E2E_USER`, `PIPU_E2E_PASSWORD`), не на каждый PR |
| Качество рекомендаций | Python | офлайн-оценка | NDCG / Recall на синтетическом датасете с известными «вкусами»; регрессия качества ломает тест |
## Тестовая музыка
Реальную музыку в репозиторий не кладём. `tests/fixtures/music/generate.sh` генерирует через `ffmpeg`
~200 коротких (5–20 с) файлов разных форматов (FLAC, MP3, Opus, M4A) с синусами / шумом разного темпа и громкости,
с полными, частичными и отсутствующими тегами, в нескольких структурах папок — для проверки шаблонов,
дублей, переименований, кириллицы в путях. Генерация детерминирована (фиксированные параметры), результат кэшируется в CI.
Для аудиоанализа — отдельный набор из нескольких клипов с известным BPM (метроном 90 / 128 / 170).
## E2E-сценарии (обязательные)
Сценарии 1, 4 и 9 гоняет Playwright. Сценарии входа, ролей, поиска, плеера, волны и языка — `apps/web/src/App.test.tsx`.
Сценарии источника и скана — `TestUserJourney` в `services/api` (нужен `TEST_DATABASE_URL`).
1. Первый запуск: вход bootstrap-админа → принудительная смена логина / пароля → главная.
2. Админ создаёт модератора; модератор создаёт пользователя; модератор **не может** создать модератора (UI и API).
3. Админ добавляет `local`-источник, задаёт два шаблона, предпросмотр, скан → треки появились в поиске.
4. Поиск: выпадающий список → переход к исполнителю; Enter → страница поиска.
5. Воспроизведение: запуск трека, перемотка, перезагрузка страницы → трек и позиция восстановлены.
6. Моя волна: запуск с занятием «Бегаю» → в выдаче треки с BPM из профиля; дизлайк → следующий трек, трек в «Дизлайках».
7. Коллекция: лайк → в «Лайках»; история содержит прослушанное.
8. Смена пароля пользователем → старые сессии разлогинены.
9. Переключение языка ru ↔ en.
10. Сброс пароля пользователю → обязательная смена при входе.
11. Очередь на добавление: модератор загружает папку из 5 файлов, где 1 — точная копия трека из библиотеки,
1 — тот же трек в другом формате → первый отброшен на предпроверке, второй — по отпечатку; оба не учтены в квоте;
оставшиеся 3 — `pending_review`. Админ правит альбом, одобряет → файлы появились в хранилище по `write_template`,
трек найден поиском; квота освобождена.
12. Квота: лимит 50 МБ, загрузка 80 МБ → часть файлов отклонена `uploads.queue_full`; модератор не может
одобрить собственную загрузку, а обычный пользователь — открыть загрузку вообще (UI и API).
## Правила
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
- Тест на каждый исправленный баг.
- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
- Нагрузочный smoke (k6, вручную перед релизом) — см. ниже.
## Нагрузочный smoke
`tests/load/smoke.js` (k6 ≥ 0.50), 3 минуты: 100 одновременных слушателей волны (старт волны, начало потока
каждого трека — первые 64 КБ, события прослушивания, следующая пачка с отзывами) и 20 запросов подсказок поиска
в секунду. Пороги — цели из [api.md](api.md) и [recommendations.md](recommendations.md#10-требования-к-производительности):
| Запрос | p95 |
|---|---|
| `GET /search/suggest` | < 50 мс |
| `POST /wave/sessions/{id}/next` | < 150 мс |
| `POST /wave/sessions` | < 300 мс |
| начало потока (`Range: bytes=0-65535`) | < 300 мс |
| `POST /events` | < 100 мс |
Ошибок < 1%. k6 завершается с ненулевым кодом, если порог не выполнен.
```bash
k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js
```
Пользователь — отдельный обычный (`role=user`): тест наполняет его историю и лайки. Нагрузку можно менять:
`-e LISTENERS=20 -e SUGGEST_RPS=5 -e DURATION=1m`. Запускать с машины в той же сети, что и сервер, —
иначе в цифрах будет задержка канала.
+2 -2
View File
@@ -84,7 +84,7 @@
до 4 параллельных загрузок, прогресс по каждому файлу.
- Мобильный клиент загрузку не поддерживает (первая версия).
- Staging — локальный диск сервера `/srv/pipu/uploads/<upload_id>/<file_id>` (не NFS).
- Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Проверяется не расширение, а содержимое (`ffprobe`):
- Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Просим присылать **FLAC или WAV**; MP3 и остальные — крайний случай: форма предупреждает об этом, а очередь на добавление помечает такой файл «обратить внимание». Проверяется не расширение, а содержимое (`ffprobe`):
должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины.
- Допускается отдельная картинка обложки (`cover.jpg|png`) в той же пачке — привязывается к файлам той же папки.
@@ -184,7 +184,7 @@
очереди — `upload_files.fingerprint` (дубли внутри очереди ловятся так же, как с библиотекой).
- Квота: `Store.CreateBatch` резервирует под `SELECT … FOR UPDATE` строки `upload_quota`; любая смена статуса идёт через
`transition` — место освобождается в той же транзакции, если новый статус его не занимает (таблица раздела 2).
- Приём — ядро tus (ADR-015): `HEAD` / `PATCH /api/v1/uploads/files/{id}`, только автор и только в `uploading`.
- Приём — ядро tus: `HEAD` / `PATCH /api/v1/uploads/files/{id}`, только автор и только в `uploading`.
Принят целиком → `processing`.
- Обработка (`worker.go`, по одному файлу): размер → `ffprobe` (5 с – 3 ч) → SHA-256 (сверка с заявленным) →
дубль по SHA-256 → Chromaprint против библиотеки и очереди (длительность ±5 с, `library.Similarity` ≥ 0.75) →
+8 -9
View File
@@ -11,9 +11,9 @@
| Клиентское состояние | Zustand (плеер, очередь, UI) |
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку |
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска); в M1 — собственные `Menu` / `Dialog` в `shared/ui`, переход на Radix вместе с поиском (M2) |
| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
| Формы | React Hook Form + Zod (в M1 формы простые — контролируемые поля, валидация на сервере с ошибками полей) |
| Компоненты-примитивы | Свои `Menu`, `Dialog`, поля и сегменты в `shared/ui` |
| Анимация | Canvas 2D для кнопки волны |
| Формы | Контролируемые поля; проверка на сервере, ошибки приходят по полям |
| i18n | i18next + react-i18next, словари из `packages/i18n` |
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
@@ -31,21 +31,20 @@ apps/web/src/
└── shared/ # ui-kit, lib, hooks, config
```
Правило: слой может импортировать только из нижележащих (`pages → widgets → features → entities → shared`),
проверяется `eslint-plugin-boundaries`.
Слой импортирует только из нижележащих: `pages → widgets → features → entities → shared`.
## Ключевые компоненты
### App shell
Макет — `Pipu Web.dc.html`, каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки,
Админка по роли) / контент / плеер.
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
- Адаптивность (макетов нет): < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
- Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
### Поиск
- `SearchBox` — Radix Popover + combobox по WAI-ARIA (стрелки, Enter, Esc).
- `SearchBox` — combobox по WAI-ARIA (стрелки, Enter, Esc).
- Debounce 150 мс, отмена предыдущего запроса, `GET /search/suggest`.
- Enter без выбранного элемента → `/search?q=…`; Enter / клик по элементу → исполнитель / альбом / запуск трека.
- Горячая клавиша `/` или `Ctrl+K` — фокус в поиск.
@@ -87,7 +86,7 @@ apps/web/src/
Перед загрузкой — Web Worker считает SHA-256 и читает теги (`music-metadata`), `POST /uploads/precheck` →
дубли сразу показываются как «уже есть: <трек>» и не загружаются. Загрузка — `tus-js-client`, до 4 параллельно,
пауза / возобновление. Ниже — «Мои предложения» со статусами и причинами отклонения.
- **Очередь на добавление** (admin): группы по альбомам (вариант `grouped` из прототипа), мини-плеер для прослушивания, inline-редактирование
- **Очередь на добавление** (admin): группы по пачкам, прослушивание из staging, правка метаданных
метаданных (поле на весь альбом), live-превью итогового пути, бейджи «возможный дубль» / «лучшее качество»,
массовые одобрение / отклонение с причиной. Бейдж с числом ожидающих — в сайдбаре.
- **Настройки** (admin): лимит очереди (ГБ), лимит на модератора, макс. размер файла, хранилище по умолчанию.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

@@ -1,790 +0,0 @@
<!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>
File diff suppressed because it is too large. Load diff
@@ -1,66 +0,0 @@
<!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>
<meta name="design_doc_mode" content="canvas">
<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&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">
<style>
body{margin:0;background:#05070D;color:#EDF1FF;font-family:Onest,system-ui,sans-serif}
a{color:#5E9BFF}a:hover{color:#8FB4FF}
i{font-family:'Material Symbols Rounded';font-style:normal;font-weight:normal;line-height:1;font-size:22px;display:inline-block;-webkit-font-smoothing:antialiased;font-feature-settings:'liga';user-select:none}
</style>
</helmet>
<section style="padding:56px 64px;display:flex;flex-direction:column;gap:32px">
<div style="display:flex;flex-direction:column;gap:8px;max-width:720px">
<div style="font-family:Unbounded;font-size:28px;font-weight:600;letter-spacing:-0.01em">Кнопка «Моя волна» — 3 варианта</div>
<div style="font-size:16px;line-height:1.5;color:#A0ABC6;text-wrap:pretty">Наведите курсор — волны ускоряются и тянутся к нему; зажмите — сжатие с отскоком; кликните — включение/пауза (пульс в такт BPM). Выбранный вариант переключается и в прототипе через Tweaks.</div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:24px">
<sc-for list="{{ variants }}" as="v" hint-placeholder-count="3">
<div id="{{ v.id }}" style="width:440px;display:flex;flex-direction:column;gap:20px;padding:24px;border-radius:24px;background:#0B0F1A;border:1px solid rgba(130,160,255,.10)">
<div style="display:flex;align-items:center;gap:12px">
<div style="padding:4px 10px;border-radius:999px;background:rgba(47,128,255,.14);color:#5E9BFF;font-weight:600;font-size:13px">{{ v.id }}</div>
<div style="font-weight:600;font-size:17px">{{ v.name }}</div>
</div>
<div style="height:400px;display:flex;align-items:center;justify-content:center;border-radius:16px;background:radial-gradient(circle at 50% 50%, #0A1020, #05070D 70%);overflow:hidden">
<x-import component="WaveButton" from="./wave.js" variant="{{ v.key }}" playing="{{ v.playing }}" bpm="{{ 118 }}" energy="{{ 0.75 }}" size="{{ 260 }}" onClick="{{ v.toggle }}" hint-size="260px,260px">
<i style="font-size:44px;color:#fff;font-variation-settings:'FILL' 1">{{ v.icon }}</i>
<div style="font-family:Unbounded;font-weight:600;font-size:18px;color:#fff;margin-top:6px">Моя волна</div>
</x-import>
</div>
<div style="font-size:14px;line-height:1.5;color:#A0ABC6;text-wrap:pretty">{{ v.desc }}</div>
</div>
</sc-for>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = { playing: {} };
renderVals() {
const defs = [
{ id: '1a', key: 'orb', name: 'Сфера с шумом', desc: 'Живая сфера: перетекающие цветовые пятна и синусоидальные волны внутри. Ближе всего к шейдеру из документации.' },
{ id: '1b', key: 'rings', name: 'Кольца', desc: 'Плотное ядро и расходящиеся кольца-волны. Самый «читаемый» ритм: на каждый бит — выброс кольца.' },
{ id: '1c', key: 'liquid', name: 'Жидкая форма', desc: 'Три слоя мягкого блоба меняют силуэт; на ховере форма вытягивается к курсору.' }
];
return {
variants: defs.map(d => ({
...d,
playing: !!this.state.playing[d.key],
icon: this.state.playing[d.key] ? 'pause' : 'play_arrow',
toggle: () => this.setState(s => ({ playing: { ...s.playing, [d.key]: !s.playing[d.key] } }))
}))
};
}
}
</script>
</body>
</html>
@@ -1,229 +0,0 @@
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
// Android.jsx — Simplified Android (Material 3) device frame
// Status bar + top app bar + content + gesture nav + keyboard.
// Based on Figma M3 spec. No dependencies, no image assets.
// Exports (to window): AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard
//
// Usage — wrap your screen content in <AndroidDevice> to get the bezel, status
// bar and gesture nav (props: title, large, keyboard, dark):
//
// <AndroidDevice title="Inbox" large>
// ...your screen content...
// </AndroidDevice>
// <AndroidDevice title="Compose" keyboard>…</AndroidDevice>
/* END USAGE */
const MD_C = {
surface: '#f4fbf8',
surfaceVariant: '#dae5e1',
inverseOnSurface: '#ecf2ef',
secondaryContainer: '#cde8e1',
primaryFixedDim: '#83d5c6',
onSurface: '#171d1b',
onSurfaceVar: '#49454f',
onPrimaryContainer: '#00201c',
primary: '#006a60',
frameBorder: 'rgba(116,119,117,0.5)',
};
// ─────────────────────────────────────────────────────────────
// Status bar (time left, wifi/cell/battery right)
// ─────────────────────────────────────────────────────────────
function AndroidStatusBar({ dark = false }) {
const c = dark ? '#fff' : MD_C.onSurface;
return (
<div style={{
height: 40, display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '0 16px',
position: 'relative',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{/* time left */}
<div style={{ width: 128, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 400, letterSpacing: 0.25, lineHeight: '20px', color: c }}>9:30</span>
</div>
{/* camera punch-hole (center) */}
<div style={{
position: 'absolute', left: '50%', top: 8, transform: 'translateX(-50%)',
width: 24, height: 24, borderRadius: 100, background: '#2e2e2e',
}} />
{/* status icons right */}
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', paddingRight: 2 }}>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill={c}/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill={c}/>
</svg>
</div>
<svg width="16" height="16" viewBox="0 0 16 16">
<rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill={c}/>
<rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill={c}/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Top app bar (Material 3 small/medium)
// ─────────────────────────────────────────────────────────────
function AndroidAppBar({ title = 'Title', large = false }) {
const iconDot = (
<div style={{
width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 22, height: 22, borderRadius: '50%', background: MD_C.onSurfaceVar, opacity: 0.3 }} />
</div>
);
return (
<div style={{ background: MD_C.surface, padding: '4px 4px 0' }}>
<div style={{ height: 56, display: 'flex', alignItems: 'center', gap: 4 }}>
{iconDot}
{!large && (
<span style={{
flex: 1, fontSize: 22, fontWeight: 400, color: MD_C.onSurface,
fontFamily: 'Roboto, system-ui, sans-serif',
}}>{title}</span>
)}
{large && <div style={{ flex: 1 }} />}
{iconDot}
</div>
{large && (
<div style={{
padding: '16px 16px 20px',
fontSize: 28, fontWeight: 400, color: MD_C.onSurface,
fontFamily: 'Roboto, system-ui, sans-serif',
}}>{title}</div>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────
// List item (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidListItem({ headline, supporting, leading }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: '12px 16px', minHeight: 56, boxSizing: 'border-box',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{leading && (
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: MD_C.primary, color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 500, flexShrink: 0,
}}>{leading}</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, color: MD_C.onSurface, lineHeight: '24px' }}>{headline}</div>
{supporting && (
<div style={{ fontSize: 14, color: MD_C.onSurfaceVar, lineHeight: '20px' }}>{supporting}</div>
)}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Gesture nav bar (pill)
// ─────────────────────────────────────────────────────────────
function AndroidNavBar({ dark = false }) {
return (
<div style={{
height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: 108, height: 4, borderRadius: 2,
background: dark ? '#fff' : MD_C.onSurface, opacity: 0.4,
}} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame — wraps everything
// ─────────────────────────────────────────────────────────────
function AndroidDevice({
children, width = 412, height = 892, dark = false,
title, large = false, keyboard = false, bg,
}) {
return (
// data-om-starter: inert presence marker — Claude Design's starter-usage
// probe reads it; it renders nothing. Keep it on this root element.
<div data-om-starter="android-frame" style={{
width, height, borderRadius: 18, overflow: 'hidden',
background: bg || (dark ? '#1d1b20' : MD_C.surface),
border: `8px solid ${MD_C.frameBorder}`, position: 'relative',
boxShadow: '0 30px 80px rgba(0,0,0,0.25)',
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
}}>
<AndroidStatusBar dark={dark} />
{title !== undefined && <AndroidAppBar title={title} large={large} />}
<div style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0 }}>
{children}
</div>
{keyboard && <AndroidKeyboard />}
<AndroidNavBar dark={dark} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — Gboard (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidKeyboard() {
let _k = 0;
const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
<div key={_k++} style={{
height: 46, borderRadius: r, flex, minWidth: minW,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'Roboto, system-ui', fontSize: fs,
color: MD_C.onPrimaryContainer,
}}>{l}</div>
);
const row = (keys, style = {}) => (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', ...style }}>
{keys.map(l => key(l))}
</div>
);
return (
<div style={{
background: MD_C.inverseOnSurface, padding: '0 8px 8px',
display: 'flex', flexDirection: 'column', gap: 4,
}}>
{/* navbar spacer (icons omitted) */}
<div style={{ height: 44 }} />
{/* key rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
<div style={{ display: 'flex', gap: 6 }}>
{key('', { bg: MD_C.surfaceVariant })}
<div style={{ display: 'flex', gap: 6, flex: 7, minWidth: 274 }}>
{['z','x','c','v','b','n','m'].map(l => key(l))}
</div>
{key('', { bg: MD_C.surfaceVariant })}
</div>
<div style={{ display: 'flex', gap: 6 }}>
{key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
{key(',', { bg: MD_C.surfaceVariant })}
{key('', { flex: 3, minW: 154 })}
{key('.', { bg: MD_C.surfaceVariant })}
{key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
</div>
</div>
</div>
);
}
Object.assign(window, {
AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
});
@@ -1,104 +0,0 @@
(function () {
const H = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const fmt = s => { s = Math.max(0, Math.floor(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
const plural = (n, f) => { const a = n % 10, b = n % 100; return f[(a === 1 && b !== 11) ? 0 : (a >= 2 && a <= 4 && (b < 10 || b >= 20)) ? 1 : 2]; };
const AR = {
kino: ['Кино', 'Русский рок', 0.62], zemfira: ['Земфира', 'Рок', 0.55], radiohead: ['Radiohead', 'Альтернатива', 0.45],
massive: ['Massive Attack', 'Трип-хоп', 0.38], moderat: ['Moderat', 'Электроника', 0.66], bonobo: ['Bonobo', 'Даунтемпо', 0.48],
daftpunk: ['Daft Punk', 'Электроника', 0.78], splean: ['Сплин', 'Русский рок', 0.56]
};
const AL = [
['rh-ir', 'In Rainbows', 'radiohead', 2007, '#F2A23A', '#1C3B6E', ['15 Step', 'Bodysnatchers', 'Nude', 'Weird Fishes/Arpeggi', 'All I Need', 'Faust Arp', 'Reckoner', 'House of Cards', 'Jigsaw Falling into Place', 'Videotape']],
['kino-gk', 'Группа крови', 'kino', 1988, '#C2412D', '#2A0E0A', ['Группа крови', 'Закрой за мной дверь, я ухожу', 'Война', 'Спокойная ночь', 'Мама, мы все сошли с ума', 'Бошетунмай', 'Попробуй спеть вместе со мной', 'Звезда']],
['kino-zs', 'Звезда по имени Солнце', 'kino', 1989, '#E39B2D', '#3A1A05', ['Звезда по имени Солнце', 'Песня без слов', 'Место для шага вперёд', 'Невесёлая песня', 'Апрель', 'Пачка сигарет']],
['zem-pm', 'Прости меня моя любовь', 'zemfira', 2000, '#D9467A', '#2B0B23', ['Хочешь?', 'Искала', 'Ромашки', 'Непошлое', 'Прости меня моя любовь', 'Город']],
['ma-mz', 'Mezzanine', 'massive', 1998, '#6A7385', '#0E1116', ['Angel', 'Risingson', 'Teardrop', 'Inertia Creeps', 'Exchange', 'Dissolved Girl']],
['mod-2', 'II', 'moderat', 2013, '#3A7BFF', '#0A1636', ['The Mark', 'Bad Kingdom', 'Versions', 'Let in the Light', 'Milk', 'Damage Done']],
['bon-mg', 'Migration', 'bonobo', 2017, '#2FB6A0', '#0B2730', ['Migration', 'Break Apart', 'Outlier', 'Kerala', 'Grains', 'No Reason']],
['dp-ram', 'Random Access Memories', 'daftpunk', 2013, '#A7AFBF', '#141821', ['Give Life Back to Music', 'The Game of Love', 'Instant Crush', 'Lose Yourself to Dance', 'Get Lucky', "Doin' It Right"]],
['spl-ga', 'Гранатовый альбом', 'splean', 1998, '#B4233A', '#1D0710', ['Орбит без сахара', 'Пластмассовая жизнь', 'Иди через лес', 'Феллини']]
];
const ART = {}, ALB = {}, TR = {}, ALL = [];
Object.keys(AR).forEach(k => { ART[k] = { id: k, name: AR[k][0], genre: AR[k][1], energy: AR[k][2], albums: [], tracks: [] }; });
AL.forEach(([id, title, ar, year, c1, c2, list]) => {
const cover = `radial-gradient(circle at 28% 22%, rgba(255,255,255,.32), rgba(255,255,255,0) 55%), linear-gradient(140deg, ${c1}, ${c2})`;
ALB[id] = { id, title, artistId: ar, year, c1, c2, cover, tracks: [] };
const A = ART[ar];
A.albums.push(id);
if (!A.c1) { A.c1 = c1; A.c2 = c2; A.avatar = `radial-gradient(circle at 35% 28%, ${c1}, ${c2} 78%)`; A.banner = `radial-gradient(ellipse at 20% 0%, ${c1}55, transparent 60%), linear-gradient(180deg, ${c2}cc, transparent)`; }
list.forEach((t, i) => {
const h = H(id + t), tid = id + '-' + i;
TR[tid] = { id: tid, title: t, artistId: ar, albumId: id, n: i + 1, dur: 150 + h % 170, bpm: 72 + h % 74, energy: clamp(AR[ar][2] + ((h >> 3) % 40 - 20) / 100, 0.05, 0.98), pop: h % 100 };
ALB[id].tracks.push(tid); A.tracks.push(tid); ALL.push(tid);
});
});
const ACT = [['run', 'Бегаю', 'directions_run', 0.85], ['wake', 'Просыпаюсь', 'wb_twilight', 0.55], ['focus', 'Работаю', 'center_focus_strong', 0.4], ['gym', 'Тренировка', 'fitness_center', 0.92], ['road', 'В дороге', 'directions_car', 0.6], ['rest', 'Отдыхаю', 'weekend', 0.35], ['sleep', 'Засыпаю', 'bedtime', 0.15]];
const MOOD = [['energy', 'Энергичное', 'bolt', 0.85, '#2F80FF', '#35D3FF', '#7CF0FF'], ['happy', 'Весёлое', 'sentiment_satisfied', 0.7, '#2F80FF', '#35D3FF', '#9B7CFF'], ['calm', 'Спокойное', 'spa', 0.3, '#2F80FF', '#6C7CFF', '#5AD1E6'], ['sad', 'Грустное', 'water_drop', 0.22, '#3B5BFF', '#7C5CFF', '#24317A']];
function waveBatch(n, opts) {
const { activity, mood, dislikes = [], likes = [], recent = [], artistId } = opts || {};
const tg = [];
if (activity) tg.push(ACT.find(a => a[0] === activity)[3]);
if (mood) tg.push(MOOD.find(m => m[0] === mood)[3]);
const target = tg.length ? tg.reduce((a, b) => a + b, 0) / tg.length : 0.55;
return ALL.filter(id => !dislikes.includes(id) && !recent.includes(id))
.map(id => {
const t = TR[id];
let sc = -Math.abs(t.energy - target) * 1.4 + (likes.includes(id) ? 0.2 : 0) + Math.random() * 0.35;
if (artistId) sc += t.artistId === artistId ? 0.8 : (ART[t.artistId].genre === ART[artistId].genre ? 0.35 : 0);
return [id, sc];
})
.sort((a, b) => b[1] - a[1]).slice(0, n).map(x => x[0]);
}
const EN = "qwertyuiop[]asdfghjkl;'zxcvbnm,.`", RU = 'йцукенгшщзхъфывапролджэячсмитьбюё';
const swapLayout = q => q.split('').map(c => { const l = c.toLowerCase(); const i = EN.indexOf(l); if (i >= 0) return RU[i]; const j = RU.indexOf(l); if (j >= 0) return EN[j]; return c; }).join('');
const TL = { а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'c', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya' };
const norm = s => s.toLowerCase().replace(/ё/g, 'е').replace(/[^a-zа-я0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
const tl = s => s.split('').map(c => TL[c] ?? c).join('');
const lev = (a, b) => { const d = Array.from({ length: a.length + 1 }, (_, i) => [i]); for (let j = 1; j <= b.length; j++) d[0][j] = j; for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); return d[a.length][b.length]; };
const m1 = (n, q) => { if (n.includes(q)) return true; if (q.length >= 4) return n.split(' ').some(w => lev(w.slice(0, q.length), q) <= 1) || lev(n.slice(0, q.length), q) <= 1; return false; };
const match = (name, q) => { const n = norm(name), qq = norm(q); if (!qq) return false; return m1(n, qq) || m1(tl(n), tl(qq)); };
function searchRaw(q) {
const artists = Object.values(ART).filter(a => match(a.name, q));
const albums = Object.values(ALB).filter(a => match(a.title, q) || match(ART[a.artistId].name, q));
const tracks = ALL.filter(id => match(TR[id].title, q) || match(ART[TR[id].artistId].name, q)).sort((a, b) => TR[b].pop - TR[a].pop);
return { artists, albums, tracks };
}
function search(q) {
let r = searchRaw(q), fixed = null;
if (q.trim() && !r.artists.length && !r.albums.length && !r.tracks.length) {
const q2 = swapLayout(q), r2 = searchRaw(q2);
if (r2.artists.length || r2.albums.length || r2.tracks.length) { r = r2; fixed = q2; }
}
return { ...r, fixed };
}
const sanitize = v => String(v ?? '').replace(/[\/\\:*?"<>|]/g, '_').replace(/[. ]+$/, '');
function fillTpl(tpl, f) {
let out = tpl.replace(/\[([^\]]*)\]/g, (m, inner) => [...inner.matchAll(/\{(\w+)(?::[^}]*)?\}/g)].every(x => f[x[1]]) ? inner : '');
return out.replace(/\{(\w+)(?::([^}]*))?\}/g, (m, k, mod) => {
let v = f[k]; if (k === 'albumartist' && !v) v = f.artist;
v = v == null ? '' : (k === 'ext' ? v : sanitize(v));
if (mod === '02') v = String(v).padStart(2, '0');
if (mod === '1-') v = v ? v + '-' : '';
return v;
});
}
const GC = { artist: '#5E9BFF', albumartist: '#8FB4FF', album: '#A08BFF', year: '#FFB547', disc: '#FF8FB1', track: '#35D3FF', title: '#2FD39A', genre: '#FF9F5A', skip: '#FF5C70' };
const GL = { artist: 'исполнитель', albumartist: 'исп. альбома', album: 'альбом', year: 'год', disc: 'диск', track: '№', title: 'название', genre: 'жанр', skip: 'пропуск' };
function compile(p) { try { return new RegExp(p.replace(/\(\?P</g, '(?<'), 'd'); } catch (e) { return e; } }
function tokens(p) { return p.split(/(\(\?P<\w+>)/).filter(Boolean).map(t => { const m = t.match(/^\(\?P<(\w+)>$/); return { t, c: m ? (GC[m[1]] || 'var(--accent2)') : 'var(--text)', w: m ? '600' : '400' }; }); }
function segments(path, m) {
const g = m.indices && m.indices.groups ? Object.entries(m.indices.groups).filter(([, v]) => v).sort((a, b) => a[1][0] - b[1][0]) : [];
const out = []; let i = 0;
g.forEach(([k, [s, e]]) => { if (s > i) out.push({ t: path.slice(i, s), c: 'var(--text3)' }); out.push({ t: path.slice(s, e), c: GC[k] || 'var(--text)' }); i = e; });
if (i < path.length) out.push({ t: path.slice(i), c: 'var(--text3)' });
return out;
}
window.PIPU = { H, clamp, fmt, plural, ART, ALB, TR, ALL, ACT, MOOD, waveBatch, search, swapLayout, fillTpl, sanitize, compile, tokens, segments, GC, GL };
})();
File diff suppressed because it is too large. Load diff
@@ -1,209 +0,0 @@
(function () {
const R = window.React;
const { useRef, useEffect } = R;
function rgb(hex) {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const mix = (a, b, k) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k].map(Math.round);
function drawOrb(ctx, cx, cy, r, s, t) {
const c1 = rgb(s.color), c2 = rgb(s.color2), c3 = rgb(s.color3);
const glow = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r * 1.65);
glow.addColorStop(0, rgba(c1, 0.32 + s.pulse * 0.35 + s.hover * 0.12));
glow.addColorStop(0.5, rgba(c2, 0.10 + s.pulse * 0.12));
glow.addColorStop(1, rgba(c1, 0));
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(cx, cy, r * 1.65, 0, Math.PI * 2); ctx.fill();
ctx.save();
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip();
ctx.fillStyle = s.dark ? '#060B1C' : '#0B1533';
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.globalCompositeOperation = 'lighter';
const blobs = [c1, c2, c3, c1, c2];
for (let i = 0; i < blobs.length; i++) {
const a = t * (0.25 + i * 0.07) + i * 1.7;
const bx = cx + Math.cos(a) * r * (0.35 + 0.12 * Math.sin(t * 0.3 + i)) + s.mx * r * 0.18 * s.hover;
const by = cy + Math.sin(a * 1.3) * r * 0.38 + s.my * r * 0.18 * s.hover;
const br = r * (0.75 + 0.15 * Math.sin(t * 0.7 + i) + s.pulse * 0.2);
const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
g.addColorStop(0, rgba(blobs[i], 0.55));
g.addColorStop(1, rgba(blobs[i], 0));
ctx.fillStyle = g;
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
}
const lines = 9;
for (let l = 0; l < lines; l++) {
const yy = cy - r * 0.75 + (l / (lines - 1)) * r * 1.5;
const amp = r * (0.03 + 0.05 * s.amp + 0.09 * s.pulse + 0.03 * s.hover);
ctx.beginPath();
for (let x = -r; x <= r; x += 4) {
const y = yy + Math.sin(x / r * (3 + l * 0.4) + t * (1.2 + l * 0.15) + l) * amp * Math.cos(x / r * 1.4);
x === -r ? ctx.moveTo(cx + x, y) : ctx.lineTo(cx + x, y);
}
ctx.strokeStyle = `rgba(255,255,255,${0.05 + 0.05 * s.amp + (l % 3 === 0 ? 0.05 : 0)})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
const hl = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.45, 0, cx - r * 0.35, cy - r * 0.45, r * 0.9);
hl.addColorStop(0, 'rgba(255,255,255,0.18)');
hl.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hl;
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.restore();
ctx.beginPath(); ctx.arc(cx, cy, r - 0.5, 0, Math.PI * 2);
ctx.strokeStyle = rgba(mix(c1, [255, 255, 255], 0.4), 0.35 + s.pulse * 0.3);
ctx.lineWidth = 1; ctx.stroke();
}
function drawRings(ctx, cx, cy, r, s, t) {
const c1 = rgb(s.color), c2 = rgb(s.color2), c3 = rgb(s.color3);
const N = 8;
for (let i = 0; i < N; i++) {
const k = ((t * 0.12 * s.speed + i / N) % 1);
const rr = r * (0.58 + k * 0.95);
const a = Math.pow(1 - k, 1.6) * (0.5 + 0.3 * s.amp);
ctx.beginPath();
for (let j = 0; j <= 120; j++) {
const th = (j / 120) * Math.PI * 2;
const pull = s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 4) * r * 0.08 * (1 - k);
const d = Math.sin(th * 6 + t * 2 + i) * r * 0.012 * (1 + s.amp * 2 + s.pulse * 4) + pull;
const x = cx + Math.cos(th) * (rr + d), y = cy + Math.sin(th) * (rr + d);
j ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.strokeStyle = rgba(mix(c1, c3, k), a);
ctx.lineWidth = 1.5 + (1 - k) * 2;
ctx.stroke();
}
const cr = r * (0.58 + s.pulse * 0.07 + 0.015 * Math.sin(t * 1.4));
const glow = ctx.createRadialGradient(cx, cy, cr * 0.5, cx, cy, cr * 1.9);
glow.addColorStop(0, rgba(c1, 0.45 + s.pulse * 0.3));
glow.addColorStop(1, rgba(c1, 0));
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(cx, cy, cr * 1.9, 0, Math.PI * 2); ctx.fill();
const g = ctx.createLinearGradient(cx - cr, cy - cr, cx + cr, cy + cr);
g.addColorStop(0, rgba(mix(c1, [255, 255, 255], 0.15), 1));
g.addColorStop(0.6, rgba(c1, 1));
g.addColorStop(1, rgba(c2, 1));
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(cx + s.mx * 4 * s.hover, cy + s.my * 4 * s.hover, cr, 0, Math.PI * 2); ctx.fill();
}
function drawLiquid(ctx, cx, cy, r, s, t) {
const cols = [rgb(s.color), rgb(s.color2), rgb(s.color3)];
const glow = ctx.createRadialGradient(cx, cy, r * 0.4, cx, cy, r * 1.6);
glow.addColorStop(0, rgba(cols[0], 0.3 + s.pulse * 0.3));
glow.addColorStop(1, rgba(cols[0], 0));
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(cx, cy, r * 1.6, 0, Math.PI * 2); ctx.fill();
ctx.globalCompositeOperation = s.dark ? 'lighter' : 'source-over';
for (let L = 0; L < 3; L++) {
ctx.beginPath();
const P = 140;
for (let j = 0; j <= P; j++) {
const th = (j / P) * Math.PI * 2;
const w = 1
+ 0.06 * Math.sin(3 * th + t * (0.9 + L * 0.2) + L * 2)
+ 0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L)
+ 0.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp)
+ 0.08 * s.pulse * Math.sin(2 * th + L)
+ 0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3);
const rr = r * (0.86 - L * 0.07) * w;
const x = cx + Math.cos(th) * rr, y = cy + Math.sin(th) * rr;
j ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3, oy = Math.sin(t * 0.5 + L) * r * 0.3;
const g = ctx.createRadialGradient(cx + ox, cy + oy, 0, cx, cy, r * 1.1);
g.addColorStop(0, rgba(mix(cols[L], [255, 255, 255], 0.25), 0.85));
g.addColorStop(1, rgba(cols[L], 0.25));
ctx.fillStyle = g;
ctx.globalAlpha = s.dark ? 0.6 : 0.75;
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function WaveButton(props) {
const size = props.size || 360;
const canvasRef = useRef(null), labelRef = useRef(null), wrapRef = useRef(null);
const st = useRef({ hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0, speed: 1, phase: 0 });
const p = st.current;
p.variant = props.variant || 'orb';
p.playing = !!props.playing;
p.bpm = props.bpm || 100;
p.energy = props.energy == null ? 0.6 : props.energy;
p.color = props.color || '#2F80FF';
p.color2 = props.color2 || '#7C5CFF';
p.color3 = props.color3 || '#35D3FF';
p.dark = props.theme !== 'light';
p.reduced = !!props.reduced;
p.size = size;
useEffect(() => {
let raf, last = performance.now();
const cv = canvasRef.current, ctx = cv.getContext('2d');
const loop = (now) => {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
const s = st.current;
const S = s.size * 1.6, dpr = window.devicePixelRatio || 1;
if (cv.width !== Math.round(S * dpr)) { cv.width = Math.round(S * dpr); cv.height = Math.round(S * dpr); }
s.hover += (s.hoverT - s.hover) * 0.08;
s.pressV += (s.pressT - s.press) * 0.35; s.pressV *= 0.62; s.press += s.pressV;
s.amp += ((s.playing ? 1 : 0) - s.amp) * 0.04;
s.speed = s.reduced ? 0.15 : 1 + s.hover * 1.4 + s.amp * 0.7;
s.t += dt * s.speed;
if (s.playing && !s.reduced) {
s.phase = (s.phase + dt * s.bpm / 60) % 1;
s.pulse += (Math.pow(1 - s.phase, 4) * (0.35 + s.energy * 0.65) - s.pulse) * 0.5;
} else {
s.pulse += ((s.reduced ? 0 : (Math.sin(s.t * 1.1) * 0.5 + 0.5) * 0.12) - s.pulse) * 0.1;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, S, S);
const sc = 1 - s.press * 0.07 + s.hover * 0.02;
const r = s.size * 0.5 * sc;
const cx = S / 2 + s.mx * s.hover * 6, cy = S / 2 + s.my * s.hover * 6;
if (s.variant === 'rings') drawRings(ctx, cx, cy, r * 0.62, s, s.t);
else if (s.variant === 'liquid') drawLiquid(ctx, cx, cy, r, s, s.t);
else drawOrb(ctx, cx, cy, r, s, s.t);
if (labelRef.current) labelRef.current.style.transform = `scale(${sc + s.pulse * 0.03})`;
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
const onMove = (e) => {
const b = wrapRef.current.getBoundingClientRect();
const x = (e.clientX - b.left) / b.width * 2 - 1, y = (e.clientY - b.top) / b.height * 2 - 1;
p.mx = x; p.my = y; p.ang = Math.atan2(y, x);
};
const interactive = props.interactive !== false;
return R.createElement('div', {
ref: wrapRef,
role: interactive ? 'button' : undefined,
tabIndex: interactive ? 0 : undefined,
'aria-label': props.ariaLabel || 'Моя волна',
onMouseMove: interactive ? onMove : undefined,
onMouseEnter: interactive ? () => { p.hoverT = 1; } : undefined,
onMouseLeave: interactive ? () => { p.hoverT = 0; p.pressT = 0; } : undefined,
onMouseDown: interactive ? () => { p.pressT = 1; } : undefined,
onMouseUp: interactive ? () => { p.pressT = 0; p.pressV -= 0.12; } : undefined,
onClick: interactive ? props.onClick : undefined,
onKeyDown: interactive ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onClick && props.onClick(); } } : undefined,
style: { position: 'relative', width: size, height: size, borderRadius: '50%', cursor: interactive ? 'pointer' : 'default', outline: 'none', flexShrink: 0, pointerEvents: interactive ? 'auto' : 'none' }
},
R.createElement('canvas', { ref: canvasRef, style: { position: 'absolute', left: -size * 0.3, top: -size * 0.3, width: size * 1.6, height: size * 1.6, pointerEvents: 'none' } }),
R.createElement('div', { ref: labelRef, style: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' } }, props.children)
);
}
window.WaveButton = WaveButton;
if (typeof module !== 'undefined') module.exports = { WaveButton };
})();