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
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:
1 parent
74da457362
commit
1e3b350dfc
49 files changed
+147
-5572
No files matched your search
+1
-4
@@ -1,7 +1,6 @@
|
||||
# Pipu Music — исходная идея продукта
|
||||
|
||||
> Документ описывает **что** должно быть в приложении с точки зрения пользователя.
|
||||
> Используется как входные данные для создания дизайна. Технические решения — в [README документации](README.md).
|
||||
> Как продукт выглядит для пользователя. Устройство системы — в [README документации](README.md).
|
||||
|
||||
## Суть
|
||||
|
||||
@@ -165,5 +164,3 @@
|
||||
|
||||
- Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки.
|
||||
- Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок.
|
||||
- Один репозиторий: веб, мобильное приложение, бэкенд, тесты, CI/CD.
|
||||
- CI/CD на Gitea: по тегу собирается релиз.
|
||||
+3
-8
@@ -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**.
|
||||
@@ -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
@@ -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 | Очередь, сгруппированная по пачкам |
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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` без `..`).
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"` для данных с токенами.
|
||||
@@ -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 тысяч размеченных показов.
|
||||
@@ -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-скробблинг, импорт лайков.
|
||||
@@ -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
@@ -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
@@ -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="{"theme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"string","section":"Вид"},"waveVariant":{"editor":"enum","options":["orb","rings","liquid"],"default":"orb","tsType":"string","section":"Вид"},"playerLayout":{"editor":"enum","options":["cover","wave"],"default":"cover","tsType":"string","section":"Варианты"},"ambient":{"editor":"boolean","default":true,"tsType":"boolean","section":"Вид"},"offline":{"editor":"boolean","default":false,"tsType":"boolean","section":"Прототип"},"role":{"editor":"enum","options":["user","moderator","admin"],"default":"user","tsType":"string","section":"Прототип"},"startScreen":{"editor":"enum","options":["app","connect"],"default":"app","tsType":"string","section":"Прототип"}}">
|
||||
const F1 = "'FILL' 1", F0 = "'FILL' 0";
|
||||
const QOPT = [['low', 'Низкое', '96 кбит/с · экономит трафик'], ['normal', 'Нормальное', '160 кбит/с Opus'], ['high', 'Высокое', '320 кбит/с Opus'], ['orig', 'Оригинал', 'Без перекодирования, FLAC — как в библиотеке']];
|
||||
const QROWS = [['wifi', 'По Wi-Fi', 'wifi'], ['cell', 'В мобильной сети', 'signal_cellular_alt'], ['dl', 'Для загрузок', 'download']];
|
||||
const NOTES = {
|
||||
connect: 'Адрес сервера вводится один раз; можно хранить несколько профилей серверов. «Проверить» обращается к /server-info. Попробуйте адрес со словом bad — увидите ошибку.',
|
||||
login: 'Без регистрации. Язык переключается прямо здесь. Любой логин и пароль от 4 символов — вход.',
|
||||
home: 'Нажмите волну — заиграет, пульс в такт BPM трека. Занятие и настроение меняют поток без остановки музыки.',
|
||||
search: 'Ищет на лету по исполнителям, альбомам и трекам; прощает опечатки и раскладку.',
|
||||
collection: 'История по дням, лайки, дизлайки (с отменой) и скачанное.',
|
||||
downloads: 'Занятое место, авто-скачивание лайков, очередь загрузок (2 параллельно, только Wi-Fi), удаление по треку или альбомом.',
|
||||
album: 'Кнопка со стрелкой скачивает альбом для офлайна — прогресс виден по каждому треку.',
|
||||
artist: '«Волна по исполнителю» — поток, построенный вокруг артиста и похожих.',
|
||||
profile: 'Качество для Wi-Fi, мобильной сети и загрузок раздельно, кэш, обновления, сервер. Админка — только ссылкой в браузер.',
|
||||
player: 'Свайп/тап по мини-плееру. Два варианта полноэкранного плеера — переключаются в Tweaks (playerLayout).',
|
||||
update: 'Самообновление через сервер Pipu: changelog → разрешение на установку → загрузка APK → проверка SHA-256 → установка.',
|
||||
offline: 'Без сети играет только скачанное, волна строится из локальных треков, прослушивания копятся и уйдут на сервер позже.'
|
||||
};
|
||||
|
||||
class Component extends DCLogic {
|
||||
state = { ready: false };
|
||||
scrollRef = React.createRef();
|
||||
_fh = {};
|
||||
componentDidMount() {
|
||||
const wait = () => (window.PIPU ? this.init() : setTimeout(wait, 40)); wait();
|
||||
this.iv = setInterval(() => this.tick(), 250);
|
||||
}
|
||||
componentWillUnmount() { clearInterval(this.iv); }
|
||||
init() {
|
||||
const D = window.PIPU;
|
||||
let sv = null; try { sv = JSON.parse(localStorage.getItem('pipu-android-player') || 'null'); } catch (e) {}
|
||||
const ok = sv && D.TR[sv.trackId];
|
||||
const alb = D.ALB['ma-mz'].tracks;
|
||||
const hist = []; let k = 5;
|
||||
[['Сегодня', 5, 9], ['Вчера', 6, 21]].forEach(([d, n, h]) => { let m = 50; for (let j = 0; j < n; j++) { k = (k * 7 + 3) % D.ALL.length; hist.push({ id: D.ALL[k], day: d, time: String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') }); m -= 12; if (m < 0) { m += 60; h--; } } });
|
||||
this.setState({
|
||||
ready: true, auth: this.props.startScreen === 'connect' ? 'connect' : null,
|
||||
tab: 'home', sub: null, colTab: 'history', full: false, sheet: null,
|
||||
queue: ok && sv.queue ? sv.queue.filter(id => D.TR[id]) : alb, qi: ok ? sv.qi || 0 : 2, trackId: ok ? sv.trackId : alb[2], pos: ok ? sv.pos || 0 : 64, playing: false,
|
||||
source: (ok && sv.source) || { label: 'Альбом · Mezzanine' }, waveOn: ok ? !!sv.waveOn : false, activity: null, mood: null,
|
||||
likes: ['rh-ir-6', 'ma-mz-2', 'kino-gk-0', 'mod-2-1', 'bon-mg-2', 'dp-ram-4', 'zem-pm-0', 'kino-zs-4', 'rh-ir-3'], dislikes: ['dp-ram-1', 'spl-ga-3'], history: hist,
|
||||
downloads: [...D.ALB['rh-ir'].tracks, ...D.ALB['ma-mz'].tracks, 'kino-gk-0', 'kino-zs-4', 'bon-mg-2', 'mod-2-1'], dlQueue: [], autoLikes: true, wifiOnly: true,
|
||||
qual: { wifi: 'high', cell: 'normal', dl: 'high' }, cache: 312,
|
||||
srvAddr: 'music.home.lan', srvState: 'ok', f: {}, loginErr: '', pwErr: '', lang: 'ru', q: '',
|
||||
upd: 'available', updPct: 0, updSeen: false, offline: null, pending: 3, toast: null
|
||||
});
|
||||
}
|
||||
tick() {
|
||||
const s = this.state, D = window.PIPU; if (!s.ready) return;
|
||||
const u = {};
|
||||
if (s.playing && !s.auth) { const t = D.TR[s.trackId]; const np = s.pos + 0.25; if (np >= t.dur) { this.next(); return; } u.pos = np; }
|
||||
if (s.dlQueue.length) {
|
||||
const q = s.dlQueue.map((x, i) => i < 2 ? { ...x, p: x.p + 6 + Math.random() * 10 } : x);
|
||||
const done = q.filter(x => x.p >= 100).map(x => x.id);
|
||||
u.dlQueue = q.filter(x => x.p < 100);
|
||||
if (done.length) u.downloads = [...s.downloads, ...done.filter(id => !s.downloads.includes(id))];
|
||||
}
|
||||
if (s.upd === 'downloading') { const p = s.updPct + 3 + Math.random() * 4; if (p >= 100) { u.updPct = 100; u.upd = 'verify'; setTimeout(() => this.setState({ upd: 'ready' }), 1200); } else u.updPct = p; }
|
||||
if (Object.keys(u).length) this.setState(u);
|
||||
this._t = (this._t || 0) + 1;
|
||||
if (this._t % 4 === 0) { try { localStorage.setItem('pipu-android-player', JSON.stringify({ trackId: s.trackId, pos: s.pos, queue: s.queue.slice(0, 60), qi: s.qi, source: s.source, waveOn: s.waveOn })); } catch (e) {} }
|
||||
}
|
||||
isOff() { return this.state.offline ?? !!this.props.offline; }
|
||||
toastMsg(m) { clearTimeout(this._tt); this.setState({ toast: m }); this._tt = setTimeout(() => this.setState({ toast: null }), 2600); }
|
||||
fh(k) { return this._fh[k] || (this._fh[k] = e => this.setState(s => ({ f: { ...s.f, [k]: e.target.value } }))); }
|
||||
goTab(tab) { this.setState({ tab, sub: null, full: false }); if (this.scrollRef.current) this.scrollRef.current.scrollTop = 0; }
|
||||
goSub(sub) { this.setState({ sub, full: false }); if (this.scrollRef.current) this.scrollRef.current.scrollTop = 0; }
|
||||
canPlay(id) { if (this.isOff() && !this.state.downloads.includes(id)) { this.toastMsg('Нет сети — этот трек не скачан'); return false; } return true; }
|
||||
addHist(h, id) { if (h[0] && h[0].id === id) return h; const d = new Date(); return [{ id, day: 'Сегодня', time: String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') }, ...h]; }
|
||||
setTrack(qi, queue, extra = {}) { const id = queue[qi]; this.setState(s => ({ queue, qi, trackId: id, pos: 0, playing: true, history: this.addHist(s.history, id), pending: this.isOff() ? s.pending + 1 : s.pending, ...extra })); }
|
||||
playList(list, i, label) {
|
||||
if (this.isOff()) { const dl = this.state.downloads; const id = list[i]; if (!this.canPlay(id)) return; list = list.filter(x => dl.includes(x)); i = list.indexOf(id); }
|
||||
this.setTrack(i, list, { waveOn: false, source: { label } });
|
||||
}
|
||||
wopts(extra = {}) { const s = this.state; return { activity: s.activity, mood: s.mood, dislikes: s.dislikes, likes: s.likes, ...extra }; }
|
||||
batch(n, extra) { const D = window.PIPU; let b = D.waveBatch(this.isOff() ? 200 : n, this.wopts(extra)); if (this.isOff()) b = b.filter(id => this.state.downloads.includes(id)).slice(0, n); return b; }
|
||||
startWave(artistId) { const q = this.batch(12, { artistId }); if (!q.length) return this.toastMsg('Нет скачанных треков для офлайн-волны'); this.setTrack(0, q, { waveOn: true, source: { artistId: artistId || null } }); }
|
||||
next() {
|
||||
const s = this.state; let q = s.queue, i = s.qi + 1;
|
||||
if (s.waveOn && i >= q.length - 2) q = [...q, ...this.batch(8, { recent: q.slice(-20), artistId: s.source.artistId })];
|
||||
if (i >= q.length) { this.setState({ playing: false, pos: 0 }); return; }
|
||||
this.setTrack(i, q);
|
||||
}
|
||||
prev() { const s = this.state; if (s.pos > 3 || s.qi === 0) return this.setState({ pos: 0 }); this.setTrack(s.qi - 1, s.queue); }
|
||||
togglePlay() { this.setState(s => ({ playing: !s.playing })); }
|
||||
setCtx(k, v) {
|
||||
this.setState(s => ({ [k]: s[k] === v ? null : v }), () => {
|
||||
const s = this.state; if (!s.waveOn) return;
|
||||
const keep = s.queue.slice(0, s.qi + 1);
|
||||
this.setState({ queue: [...keep, ...this.batch(10, { recent: keep.slice(-20) })] });
|
||||
});
|
||||
}
|
||||
toggleLike(id) {
|
||||
const s = this.state, on = s.likes.includes(id);
|
||||
this.setState({ likes: on ? s.likes.filter(x => x !== id) : [id, ...s.likes], dislikes: s.dislikes.filter(x => x !== id) });
|
||||
if (!on && s.autoLikes && !s.downloads.includes(id) && !this.isOff()) { this.setState(st => ({ dlQueue: [...st.dlQueue, { id, p: 0 }] })); this.toastMsg('Лайк · трек скачивается для офлайна'); }
|
||||
}
|
||||
toggleDislike(id) {
|
||||
const s = this.state, was = s.dislikes.includes(id);
|
||||
this.setState({ dislikes: was ? s.dislikes.filter(x => x !== id) : [id, ...s.dislikes], likes: s.likes.filter(x => x !== id) });
|
||||
if (!was && id === s.trackId && s.waveOn) { setTimeout(() => this.next(), 0); this.toastMsg('Больше не попадётся в волне'); }
|
||||
else this.toastMsg(was ? 'Дизлайк снят' : 'Трек скрыт из рекомендаций');
|
||||
}
|
||||
jump(k) {
|
||||
const D = window.PIPU, base = { full: false, sheet: null, offline: null, sub: null };
|
||||
const m = {
|
||||
connect: { auth: 'connect', srvState: null }, login: { auth: 'login' },
|
||||
home: { auth: null, tab: 'home' }, search: { auth: null, tab: 'search', q: 'кино' }, collection: { auth: null, tab: 'collection', colTab: 'history' },
|
||||
downloads: { auth: null, tab: 'collection', colTab: 'downloads' }, album: { auth: null, tab: 'search', sub: { type: 'album', id: 'mod-2' } },
|
||||
artist: { auth: null, tab: 'search', sub: { type: 'artist', id: 'radiohead' } }, profile: { auth: null, tab: 'profile' },
|
||||
player: { auth: null, tab: 'home', full: true }, update: { auth: null, tab: 'profile', sheet: 'update', upd: 'available', updPct: 0 },
|
||||
offline: { auth: null, tab: 'home', offline: true }
|
||||
}[k];
|
||||
this.setState({ ...base, ...m, jumpKey: k });
|
||||
if (k === 'player' && !this.state.playing) this.setState({ playing: true });
|
||||
if (this.scrollRef.current) this.scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
|
||||
ambient(c3) {
|
||||
const blob = (k, bg, size, anim, pos) => React.createElement('div', { key: k, style: { position: 'absolute', ...pos, width: size, height: size, borderRadius: '50%', background: `radial-gradient(circle, ${bg} 0%, transparent 62%)`, filter: 'blur(24px)', animation: anim } });
|
||||
return React.createElement('div', { 'aria-hidden': true, style: { position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none', opacity: 'var(--amb-op)', zIndex: 0, animation: 'pipuHue 30s linear infinite' } }, [
|
||||
blob('a', 'rgba(47,128,255,.6)', '560px', 'pipuDrift1 16s ease-in-out infinite alternate', { left: '-260px', top: '-240px' }),
|
||||
blob('b', 'rgba(124,92,255,.45)', '480px', 'pipuDrift2 21s ease-in-out infinite alternate', { right: '-240px', top: '200px' }),
|
||||
blob('c', c3 + '88', '460px', 'pipuDrift3 26s ease-in-out infinite alternate', { left: '-60px', bottom: '-280px' })
|
||||
]);
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const D = window.PIPU, s = this.state, P = this.props;
|
||||
const theme = P.theme || 'dark';
|
||||
const base = { theme, isDark: theme === 'dark', deviceBg: theme === 'dark' ? '#05070D' : '#F3F5FA', waveVariant: P.waveVariant || 'orb' };
|
||||
const jk = s.jumpKey || (s.auth || (s.full ? 'player' : s.sub ? s.sub.type : s.tab === 'collection' && s.colTab === 'downloads' ? 'downloads' : s.tab));
|
||||
const JL = [['connect', 'Подключение к серверу'], ['login', 'Вход'], ['home', 'Главная · Моя волна'], ['search', 'Поиск'], ['collection', 'Коллекция'], ['downloads', 'Скачанное'], ['album', 'Альбом'], ['artist', 'Исполнитель'], ['profile', 'Профиль'], ['player', 'Полноэкранный плеер'], ['update', 'Обновление приложения'], ['offline', 'Без сети']];
|
||||
base.jumps = JL.map(([k, l], i) => ({ n: String(i + 1).padStart(2, '0'), label: l, onClick: () => this.jump(k), bg: jk === k ? 'rgba(47,128,255,.16)' : 'transparent', color: jk === k ? '#EDF1FF' : '#A0ABC6' }));
|
||||
base.note = NOTES[jk] || '';
|
||||
if (!s.ready || !D) return { ...base, scr: {}, ambient: null };
|
||||
const { ART, ALB, TR, fmt, plural } = D;
|
||||
const off = this.isOff();
|
||||
const t = TR[s.trackId], al = ALB[t.albumId], ar = ART[t.artistId];
|
||||
base.ambient = P.ambient === false ? null : this.ambient(s.playing ? al.c1 : '#35D3FF');
|
||||
const role = P.role || 'user';
|
||||
const me = role === 'admin' ? { name: 'Алексей Морозов', login: 'admin', ini: 'АМ', role: 'Администратор' } : role === 'moderator' ? { name: 'Илья Сафонов', login: 'ilya', ini: 'ИС', role: 'Модератор' } : { name: 'Анна Белова', login: 'anna', ini: 'АБ', role: 'Пользователь' };
|
||||
const eq = React.createElement('div', { style: { display: 'flex', gap: 2, alignItems: 'flex-end', height: 14 } }, [0, 1, 2].map(i => React.createElement('span', { key: i, style: { width: 3, height: 14, borderRadius: 2, background: '#fff', boxShadow: '0 0 4px rgba(0,0,0,.4)', transformOrigin: 'bottom', animation: `pipuEq ${0.55 + i * 0.17}s ease-in-out ${i * 0.1}s infinite alternate`, animationPlayState: s.playing ? 'running' : 'paused' } })));
|
||||
const eqA = React.createElement('div', { style: { display: 'flex', gap: 2, alignItems: 'flex-end', height: 14 } }, [0, 1, 2].map(i => React.createElement('span', { key: i, style: { width: 3, height: 14, borderRadius: 2, background: 'var(--accent2)', transformOrigin: 'bottom', animation: `pipuEq ${0.55 + i * 0.17}s ease-in-out ${i * 0.1}s infinite alternate`, animationPlayState: s.playing ? 'running' : 'paused' } })));
|
||||
const row = (id, i, list, label, extra = {}) => {
|
||||
const tr = TR[id], isCur = s.trackId === id, liked = s.likes.includes(id), dl = s.downloads.includes(id);
|
||||
return { id, num: i + 1, title: tr.title, artist: ART[tr.artistId].name, album: ALB[tr.albumId].title, dur: fmt(tr.dur), cover: ALB[tr.albumId].cover, size: (tr.dur * 0.04).toFixed(1).replace('.', ',') + ' МБ',
|
||||
isCur, notCur: !isCur, eq: extra.eqA ? eqA : eq, dl, dling: s.dlQueue.some(x => x.id === id), time: extra.time || '', op: off && !dl ? 0.38 : (extra.op ?? 1),
|
||||
titleColor: isCur ? 'var(--accent2)' : 'var(--text)', likeColor: liked ? 'var(--accent2)' : 'var(--text3)', likeFill: liked ? F1 : F0, bg: isCur ? 'var(--accent-soft)' : 'transparent',
|
||||
onPlay: extra.onPlay || (() => this.playList(list, i, label)),
|
||||
onLike: e => { e.stopPropagation(); this.toggleLike(id); }, onUndo: e => { e.stopPropagation(); this.toggleDislike(id); },
|
||||
onDel: e => { e.stopPropagation(); this.setState(st => ({ downloads: st.downloads.filter(x => x !== id) })); this.toastMsg('Удалено с устройства'); } };
|
||||
};
|
||||
const seg = (on, label, onClick) => ({ label, onClick, bg: on ? 'var(--accent)' : 'transparent', color: on ? '#fff' : 'var(--text2)' });
|
||||
const fh = {}; ['login', 'pass', 'cur', 'np', 'np2'].forEach(k => { fh[k] = this.fh(k); });
|
||||
const srv = {
|
||||
addr: s.srvAddr, onAddr: e => this.setState({ srvAddr: e.target.value, srvState: null }),
|
||||
checking: s.srvState === 'checking', ok: s.srvState === 'ok', err: s.srvState === 'err', border: s.srvState === 'err' ? 'var(--danger)' : s.srvState === 'ok' ? 'var(--ok)' : 'var(--line2)',
|
||||
onCheck: () => { this.setState({ srvState: 'checking' }); setTimeout(() => this.setState(st => ({ srvState: /bad|^\s*$/.test(st.srvAddr) ? 'err' : 'ok' })), 900); },
|
||||
onNext: () => { if (s.srvState === 'ok') this.setState({ auth: 'login', jumpKey: null }); else this.toastMsg('Сначала проверьте сервер'); },
|
||||
nextBg: s.srvState === 'ok' ? 'var(--accent)' : 'var(--surface3)', nextColor: s.srvState === 'ok' ? '#fff' : 'var(--text3)',
|
||||
profiles: [['Дом', 'music.home.lan', 'home'], ['Дача', '10.8.0.2:8443', 'cottage']].map(([n, a, ic]) => ({ name: n, addr: a, icon: ic, bg: s.srvAddr === a ? 'var(--accent-soft)' : 'var(--surface2)', border: s.srvAddr === a ? 'var(--accent)' : 'transparent', onClick: () => this.setState({ srvAddr: a, srvState: null }) }))
|
||||
};
|
||||
const common = { ...base, f: s.f, fh, srv, langs: [seg(s.lang === 'ru', 'RU', () => this.setState({ lang: 'ru' })), seg(s.lang === 'en', 'EN', () => this.setState({ lang: 'en' }))], toast: s.toast, stop: e => e.stopPropagation(),
|
||||
toConnect: () => this.setState({ auth: 'connect', srvState: null, jumpKey: null }), onLogout: () => this.setState({ auth: 'login', playing: false, jumpKey: null }) };
|
||||
const sheet = { any: !!s.sheet, qual: !!s.sheet && s.sheet.startsWith('q-'), pw: s.sheet === 'pw', queue: s.sheet === 'queue', update: s.sheet === 'update' };
|
||||
const out = { ...common, sheet, closeSheet: () => this.setState({ sheet: null, pwErr: '' }) };
|
||||
if (s.auth) {
|
||||
return { ...out, scr: { connect: s.auth === 'connect', login: s.auth === 'login' }, loginErr: s.loginErr, toastBottom: '24px',
|
||||
onLogin: () => { const l = (s.f.login || '').trim(), p = s.f.pass || ''; if (!l || p.length < 4) return this.setState({ loginErr: !l || !p ? 'Введите логин и пароль' : 'Неверный логин или пароль' }); this.setState({ auth: null, loginErr: '', f: {}, tab: 'home', jumpKey: null }); } };
|
||||
}
|
||||
|
||||
const page = s.sub ? s.sub.type : s.tab;
|
||||
const actLbl = s.activity && D.ACT.find(a => a[0] === s.activity)[1], moodRec = s.mood && D.MOOD.find(m => m[0] === s.mood);
|
||||
const ctxLabel = [actLbl, moodRec && moodRec[1]].filter(Boolean).join(' · ') || (off ? 'Из скачанного' : 'Под ваш вкус');
|
||||
const waveIsPlaying = s.waveOn && s.playing;
|
||||
const wc = moodRec ? { c1: moodRec[4], c2: moodRec[5], c3: moodRec[6] } : { c1: '#2F80FF', c2: '#7C5CFF', c3: '#35D3FF' };
|
||||
if (waveIsPlaying) wc.c2 = al.c1;
|
||||
const chip = (on, icon, label, onClick) => ({ label, icon, onClick, bg: on ? 'var(--accent)' : 'var(--surface2)', color: on ? '#fff' : 'var(--text)', border: on ? 'var(--accent)' : 'var(--line)', ic: on ? '#fff' : 'var(--text2)' });
|
||||
const srcLabel = s.waveOn ? (s.source.artistId ? 'Волна по исполнителю · ' + ART[s.source.artistId].name : 'Моя волна · ' + ctxLabel) : s.source.label;
|
||||
const liked = s.likes.includes(t.id), dis = s.dislikes.includes(t.id);
|
||||
const tabDef = [['home', 'Главная', 'home'], ['search', 'Поиск', 'search'], ['collection', 'Коллекция', 'library_music'], ['profile', 'Профиль', 'person']];
|
||||
Object.assign(out, {
|
||||
scr: { app: true }, me, offline: off, pendingEvents: s.pending, isStaff: role !== 'user',
|
||||
pg: { [page]: true }, back: () => this.setState({ sub: null }),
|
||||
tabs: tabDef.map(([k, l, ic]) => { const on = s.tab === k; return { label: l, icon: ic, onClick: () => this.goTab(k), bg: on ? 'var(--accent-soft)' : 'transparent', ic: on ? 'var(--accent2)' : 'var(--text2)', fill: on ? F1 : F0, color: on ? 'var(--text)' : 'var(--text2)', w: on ? '600' : '500' }; }),
|
||||
cur: { title: t.title, artist: ar.name, cover: al.cover, bpm: t.bpm, energy: t.energy, dl: s.downloads.includes(t.id), likeColor: liked ? 'var(--accent2)' : 'var(--text2)', likeFill: liked ? F1 : F0, disColor: dis ? 'var(--danger)' : 'var(--text2)', disFill: dis ? F1 : F0,
|
||||
onLike: () => this.toggleLike(t.id), onDislike: () => this.toggleDislike(t.id), onArtist: () => this.goSub({ type: 'artist', id: ar.id }), onAlbum: () => this.goSub({ type: 'album', id: al.id }) },
|
||||
playing: s.playing, waveOn: s.waveOn, waveIsPlaying, waveC: wc, waveIcon: waveIsPlaying ? 'pause' : 'play_arrow', ctxLabel,
|
||||
onWave: () => { if (s.waveOn && !s.source.artistId) this.togglePlay(); else this.startWave(); },
|
||||
activities: D.ACT.map(a => chip(s.activity === a[0], a[2], a[1], () => this.setCtx('activity', a[0]))),
|
||||
moods: D.MOOD.map(m => chip(s.mood === m[0], m[2], m[1], () => this.setCtx('mood', m[0]))),
|
||||
playIcon: s.playing ? 'pause' : 'play_arrow', togglePlay: () => this.togglePlay(), next: () => this.next(), prev: () => this.prev(),
|
||||
miniPlay: e => { e && e.stopPropagation && e.stopPropagation(); this.togglePlay(); }, miniLike: e => { e.stopPropagation(); this.toggleLike(t.id); },
|
||||
miniSub: s.waveOn ? srcLabel : ar.name, srcLabel, srcColor: s.waveOn ? 'var(--accent2)' : 'var(--text2)',
|
||||
posStr: fmt(s.pos), durStr: fmt(t.dur), pct: (s.pos / t.dur * 100).toFixed(2) + '%',
|
||||
onSeek: e => { const b = e.currentTarget.getBoundingClientRect(); this.setState({ pos: D.clamp((e.clientX - b.left) / b.width, 0, 0.999) * t.dur }); },
|
||||
full: s.full, openFull: () => this.setState({ full: true, jumpKey: null }), closeFull: () => this.setState({ full: false, jumpKey: null }),
|
||||
fpCover: (P.playerLayout || 'cover') === 'cover', fpWave: P.playerLayout === 'wave',
|
||||
fp: { tint: `linear-gradient(180deg, ${al.c1}55 0%, ${al.c2}44 45%, transparent 85%)`, c1: al.c1, srcKind: s.waveOn ? 'Играет волна' : 'Играет', srcName: s.waveOn ? ctxLabel : s.source.label, output: off || s.downloads.includes(t.id) ? 'Динамик телефона · из памяти' : 'Динамик телефона · ' + QOPT.find(q => q[0] === s.qual.wifi)[1].toLowerCase() + ' качество' },
|
||||
openQueue: () => this.setState({ sheet: 'queue' }),
|
||||
queueRows: s.queue.slice(s.qi, s.qi + 30).map((id, k) => row(id, k, s.queue, '', { onPlay: () => { this.setTrack(s.qi + k, s.queue); this.setState({ sheet: null }); } })),
|
||||
toastBottom: s.full ? '40px' : '160px'
|
||||
});
|
||||
|
||||
if (page === 'search') {
|
||||
const q = s.q.trim(), r = q ? D.search(q) : null;
|
||||
Object.assign(out, { q: s.q, hasQ: !!s.q, onQ: e => this.setState({ q: e.target.value }), clearQ: () => this.setState({ q: '' }), sEmpty: !q,
|
||||
recent: ['Кино', 'Moderat', 'Teardrop', 'ршту'].map(l => ({ label: l, onClick: () => this.setState({ q: l }) })),
|
||||
sr: r ? { fixed: r.fixed, hasArtists: r.artists.length > 0, hasAlbums: r.albums.length > 0, hasTracks: r.tracks.length > 0, none: !r.artists.length && !r.albums.length && !r.tracks.length,
|
||||
artists: r.artists.map(a => ({ name: a.name, img: a.avatar, ini: a.name.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase(), onClick: () => this.goSub({ type: 'artist', id: a.id }) })),
|
||||
albums: r.albums.slice(0, 4).map(a => ({ title: a.title, sub: 'Альбом · ' + ART[a.artistId].name + ' · ' + a.year, img: a.cover, onClick: () => this.goSub({ type: 'album', id: a.id }) })),
|
||||
tracks: r.tracks.slice(0, 10).map((id, i) => row(id, i, r.tracks, 'Поиск · ' + q)) } : {} });
|
||||
}
|
||||
if (page === 'collection') {
|
||||
const ct = s.colTab;
|
||||
out.colTabs = [['history', 'История', 'history'], ['likes', 'Лайки', 'favorite'], ['dislikes', 'Дизлайки', 'heart_broken'], ['downloads', 'Скачанное', 'download_done']].map(([k, l, ic]) => ({ label: l, icon: ic, onClick: () => this.setState({ colTab: k, jumpKey: null }), bg: ct === k ? 'var(--accent)' : 'transparent', color: ct === k ? '#fff' : 'var(--text)', border: ct === k ? 'var(--accent)' : 'var(--line2)', fill: ct === k ? F1 : F0 }));
|
||||
let groups = [];
|
||||
if (ct === 'history') { const list = s.history.map(h => h.id); s.history.forEach((h, i) => { let g = groups.find(x => x.day === h.day); if (!g) { g = { day: h.day, rows: [] }; groups.push(g); } g.rows.push(row(h.id, i, list, 'История', { time: h.time })); }); }
|
||||
if (ct === 'likes') groups = [{ day: '', rows: s.likes.map((id, i) => row(id, i, s.likes, 'Лайки')) }];
|
||||
if (ct === 'dislikes') groups = [{ day: '', rows: s.dislikes.map((id, i) => row(id, i, s.dislikes, 'Дизлайки', { op: 0.6 })) }];
|
||||
out.col = { isDl: ct === 'downloads', isList: ct !== 'downloads', isDis: ct === 'dislikes', notDis: ct !== 'dislikes', canPlay: ct === 'likes' && s.likes.length > 0, groups,
|
||||
onPlay: () => this.playList(s.likes, 0, 'Лайки'), onShuffle: () => this.playList([...s.likes].sort(() => Math.random() - 0.5), 0, 'Лайки · вперемешку') };
|
||||
if (ct === 'downloads') {
|
||||
const mb = s.downloads.reduce((a, id) => a + TR[id].dur * 0.04, 0);
|
||||
const byAlb = []; s.downloads.forEach(id => { const a = TR[id].albumId; let g = byAlb.find(x => x.id === a); if (!g) { g = { id: a, ids: [] }; byAlb.push(g); } g.ids.push(id); });
|
||||
const left = s.likes.filter(id => !s.downloads.includes(id) && !s.dlQueue.some(x => x.id === id));
|
||||
out.dl = {
|
||||
usedStr: mb >= 1024 ? (mb / 1024).toFixed(1).replace('.', ',') + ' ГБ' : Math.round(mb) + ' МБ', count: s.downloads.length + ' ' + plural(s.downloads.length, ['трек', 'трека', 'треков']), pct: Math.min(100, mb / 4096 * 100) + '%',
|
||||
auto: { bg: s.autoLikes ? 'var(--accent)' : 'var(--line2)', x: s.autoLikes ? '24px' : '4px' }, onAuto: () => this.setState({ autoLikes: !s.autoLikes }),
|
||||
likesLeft: left.length ? 'ещё ' + left.length : 'всё скачано', onAllLikes: () => { if (!left.length) return this.toastMsg('Все лайки уже скачаны'); if (off) return this.toastMsg('Нет сети'); this.setState({ dlQueue: [...s.dlQueue, ...left.map(id => ({ id, p: 0 }))] }); },
|
||||
hasQueue: s.dlQueue.length > 0, queue: s.dlQueue.map(x => ({ title: TR[x.id].title + ' — ' + ART[TR[x.id].artistId].name, cover: ALB[TR[x.id].albumId].cover, w: Math.min(100, x.p) + '%', pctStr: x.p > 0 ? Math.round(Math.min(100, x.p)) + '%' : 'ждёт' })),
|
||||
albums: byAlb.map(g => { const A = ALB[g.id]; const m2 = g.ids.reduce((a, id) => a + TR[id].dur * 0.04, 0); return { title: A.title, cover: A.cover, sub: ART[A.artistId].name + ' · ' + g.ids.length + ' из ' + A.tracks.length + ' · ' + Math.round(m2) + ' МБ',
|
||||
onDel: () => { this.setState(st => ({ downloads: st.downloads.filter(x => !g.ids.includes(x)) })); this.toastMsg('Альбом удалён с устройства'); }, rows: g.ids.map((id, i) => row(id, i, g.ids, 'Скачанное · ' + A.title, { eqA: true })) }; })
|
||||
};
|
||||
}
|
||||
}
|
||||
if (page === 'album') {
|
||||
const B = ALB[s.sub.id], A = ART[B.artistId], tot = B.tracks.reduce((x, id) => x + TR[id].dur, 0);
|
||||
const have = B.tracks.filter(id => s.downloads.includes(id)).length, inQ = B.tracks.some(id => s.dlQueue.some(x => x.id === id)), all = have === B.tracks.length;
|
||||
const isThis = !s.waveOn && B.tracks.includes(s.trackId) && s.source.label === 'Альбом · ' + B.title;
|
||||
out.alb = { title: B.title, cover: B.cover, artist: A.name, year: B.year, tint: `linear-gradient(180deg, ${B.c1}55, transparent 420px)`, meta: Math.round(tot / 60) + ' мин',
|
||||
playIcon: isThis && s.playing ? 'pause' : 'play_arrow', onPlay: () => isThis ? this.togglePlay() : this.playList(B.tracks, 0, 'Альбом · ' + B.title),
|
||||
onShuffle: () => this.playList([...B.tracks].sort(() => Math.random() - 0.5), 0, 'Альбом · ' + B.title), onArtist: () => this.goSub({ type: 'artist', id: A.id }),
|
||||
dlIcon: all ? 'download_done' : inQ ? 'downloading' : 'download', dlColor: all || inQ ? 'var(--accent2)' : 'var(--text)',
|
||||
dlLabel: all ? 'Альбом скачан · доступен без сети' : inQ ? 'Скачивается: ' + have + ' из ' + B.tracks.length : have ? 'Скачано ' + have + ' из ' + B.tracks.length : 'Скачайте, чтобы слушать без сети',
|
||||
onDl: () => { if (all) { this.setState({ downloads: s.downloads.filter(x => !B.tracks.includes(x)) }); return this.toastMsg('Альбом удалён с устройства'); } if (off) return this.toastMsg('Нет сети'); this.setState({ dlQueue: [...s.dlQueue, ...B.tracks.filter(id => !s.downloads.includes(id) && !s.dlQueue.some(x => x.id === id)).map(id => ({ id, p: 0 }))] }); this.toastMsg(s.wifiOnly ? 'Скачиваем по Wi-Fi' : 'Скачиваем'); },
|
||||
rows: B.tracks.map((id, i) => row(id, i, B.tracks, 'Альбом · ' + B.title, { eqA: true })) };
|
||||
}
|
||||
if (page === 'artist') {
|
||||
const A = ART[s.sub.id], top = [...A.tracks].sort((a, b) => TR[b].pop - TR[a].pop).slice(0, 5);
|
||||
out.art = { name: A.name, img: A.avatar, banner: `linear-gradient(180deg, ${A.c1}55, transparent 420px)`, ini: A.name.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase(), meta: A.genre + ' · ' + A.tracks.length + ' ' + plural(A.tracks.length, ['трек', 'трека', 'треков']),
|
||||
onWave: () => { this.startWave(A.id); }, onPlay: () => this.playList(top, 0, 'Исполнитель · ' + A.name),
|
||||
rows: top.map((id, i) => row(id, i, top, 'Исполнитель · ' + A.name)), albums: A.albums.map(id => ({ title: ALB[id].title, year: ALB[id].year, img: ALB[id].cover, onClick: () => this.goSub({ type: 'album', id }) })) };
|
||||
}
|
||||
if (page === 'profile') {
|
||||
Object.assign(out, {
|
||||
openPw: () => this.setState({ sheet: 'pw', f: {}, pwErr: '' }),
|
||||
qualRows: QROWS.map(([k, l, ic]) => { const o = QOPT.find(q => q[0] === s.qual[k]); return { label: l, icon: ic, value: o[1] + ' · ' + o[2].split(' · ')[0], onClick: () => this.setState({ sheet: 'q-' + k }) }; }),
|
||||
wifiOnly: { bg: s.wifiOnly ? 'var(--accent)' : 'var(--line2)', x: s.wifiOnly ? '24px' : '4px' }, toggleWifiOnly: () => this.setState({ wifiOnly: !s.wifiOnly }),
|
||||
cacheStr: s.cache + ' МБ', clearCache: () => { this.setState({ cache: 0 }); this.toastMsg('Кэш очищен — скачанное не тронуто'); },
|
||||
appVer: '0.6.1 · ' + (s.updSeen ? 'доступна 0.6.2' : 'проверено сегодня'), updDot: s.upd !== 'done',
|
||||
checkUpdate: () => { if (s.upd === 'done') return this.toastMsg('У вас последняя версия'); this.setState({ sheet: 'update', updSeen: true }); },
|
||||
openAdmin: () => this.toastMsg('Открываем https://' + s.srvAddr + '/admin в браузере')
|
||||
});
|
||||
}
|
||||
if (sheet.qual) {
|
||||
const k = s.sheet.slice(2), def = QROWS.find(r => r[0] === k);
|
||||
out.qs = { title: 'Качество · ' + def[1].toLowerCase(), opts: QOPT.map(([v, l, sub]) => ({ label: l, sub, ring: s.qual[k] === v ? 'var(--accent)' : 'var(--line2)', dot: s.qual[k] === v ? 'var(--accent)' : 'transparent', onClick: () => this.setState({ qual: { ...s.qual, [k]: v }, sheet: null }) })) };
|
||||
}
|
||||
if (sheet.pw) {
|
||||
out.pwErr = s.pwErr;
|
||||
out.onPw = () => { const f = s.f; if (!f.cur) return this.setState({ pwErr: 'Введите текущий пароль' }); if ((f.np || '').length < 8) return this.setState({ pwErr: 'Новый пароль — минимум 8 символов' }); if (f.np !== f.np2) return this.setState({ pwErr: 'Пароли не совпадают' }); this.setState({ sheet: null, f: {} }); this.toastMsg('Пароль изменён'); };
|
||||
}
|
||||
if (sheet.update) {
|
||||
const u = s.upd;
|
||||
out.up = { isAvail: u === 'available', isPerm: u === 'perm', isDl: u === 'downloading', isVerify: u === 'verify', isReady: u === 'ready',
|
||||
title: u === 'available' || u === 'perm' ? 'Доступна версия 0.6.2' : u === 'ready' ? 'Готово к установке' : 'Скачиваем обновление', sub: 'Сейчас 0.6.1 · APK 38,4 МБ · через сервер ' + s.srvAddr,
|
||||
notes: ['Офлайн-волна учитывает выбранное настроение', 'Плавная докачка альбомов после обрыва сети', 'Исправлено: мини-плеер мигал при смене трека', 'Поддержка пользовательского CA для self-signed сертификатов'],
|
||||
w: s.updPct + '%', pctStr: Math.round(s.updPct) + '%', dlStr: (s.updPct / 100 * 38.4).toFixed(1).replace('.', ',') + ' из 38,4 МБ',
|
||||
onUpdate: () => this.setState({ upd: 'perm' }), onPerm: () => this.setState({ upd: 'downloading', updPct: 0 }),
|
||||
onInstall: () => { this.setState({ sheet: null, upd: 'done' }); this.toastMsg('Открыт установщик Android — после установки приложение перезапустится'); } };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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 };
|
||||
})();
|
||||
Reference in new issue
Block a user