Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa79394869 | ||
|
|
3a097c15c8 | ||
|
|
20abe20686 | ||
|
|
c34272d508 | ||
|
|
b0cbd0f149 | ||
|
|
4c2286e70d | ||
|
|
9223516547 | ||
|
|
7b7ce78de8 | ||
|
|
4d2c6ce22f | ||
|
|
dca7173a88 | ||
|
|
1e3b350dfc | ||
|
|
74da457362 | ||
|
|
066ba24834 | ||
|
|
2b76060ea3 | ||
|
|
dfc72b1d69 | ||
|
|
978f03b8f8 | ||
|
|
b27b915b63 | ||
|
|
73e642c1ec | ||
|
|
66f5749fc1 | ||
|
|
823d1eb841 | ||
|
|
860072e542 | ||
|
|
d2f17d8d62 | ||
|
|
4763339335 | ||
|
|
fda6ed48ab |
No files matched your search
+31
-2
@@ -4,14 +4,43 @@
|
||||
{
|
||||
"name": "design",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"],
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"http.server",
|
||||
"8765",
|
||||
"--bind",
|
||||
"127.0.0.1",
|
||||
"--directory",
|
||||
"docs"
|
||||
],
|
||||
"port": 8765
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@pipu/web", "dev"],
|
||||
"runtimeArgs": [
|
||||
"--filter",
|
||||
"@pipu/web",
|
||||
"dev"
|
||||
],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "web-fake",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"--filter",
|
||||
"@pipu/web",
|
||||
"exec",
|
||||
"vite",
|
||||
"--port",
|
||||
"4173",
|
||||
"--strictPort"
|
||||
],
|
||||
"env": {
|
||||
"VITE_E2E": "1"
|
||||
},
|
||||
"port": 4173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта.
|
||||
# Go: сервис api и storage-agent. Только при изменении их кода или контракта.
|
||||
name: ci / api
|
||||
|
||||
on:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md).
|
||||
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками.
|
||||
name: ci / contract
|
||||
|
||||
on:
|
||||
|
||||
+17
-25
@@ -10,13 +10,11 @@ on:
|
||||
paths:
|
||||
- 'services/api/**'
|
||||
- 'services/recommender/**'
|
||||
- 'deploy/backup.sh'
|
||||
- '.gitea/workflows/ci-db.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'services/api/**'
|
||||
- 'services/recommender/**'
|
||||
- 'deploy/backup.sh'
|
||||
- '.gitea/workflows/ci-db.yml'
|
||||
|
||||
concurrency:
|
||||
@@ -54,6 +52,23 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
|
||||
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
|
||||
run: |
|
||||
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
|
||||
free=$(df -Pm / | awk 'NR==2 {print $4}')
|
||||
echo "свободно на раннере: ${free} МБ"
|
||||
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
|
||||
for svc in postgres:5432 redis:6379 meilisearch:7700; do
|
||||
host=${svc%:*}; port=${svc#*:}
|
||||
for i in $(seq 1 60); do
|
||||
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
|
||||
echo "$svc готов"; break
|
||||
fi
|
||||
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
done
|
||||
- name: Go — миграции и запросы store
|
||||
working-directory: services/api
|
||||
run: go test -tags integration -count=1 -v ./internal/dbtest/
|
||||
@@ -67,26 +82,3 @@ jobs:
|
||||
run: |
|
||||
uv sync --frozen
|
||||
uv run pytest -q tests/test_db_integration.py
|
||||
# База уже наполнена сценариями выше — проверяем настоящий цикл копия → проверка → восстановление.
|
||||
- name: Бэкап, проверка и восстановление (deploy/backup.sh)
|
||||
env:
|
||||
PGHOST: postgres
|
||||
PGUSER: pipu
|
||||
PGPASSWORD: pipu
|
||||
PGDATABASE: pipu
|
||||
BACKUP_DIR: /tmp/backups
|
||||
VERIFY_EXACT: "1"
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO install -d /usr/share/postgresql-common/pgdg
|
||||
$SUDO curl -fsSLo /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc
|
||||
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list >/dev/null
|
||||
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq postgresql-client-17 >/dev/null
|
||||
export PATH=/usr/lib/postgresql/17/bin:$PATH
|
||||
before=$(psql -Atc "SELECT count(*) FROM tracks")
|
||||
file=$(bash deploy/backup.sh once)
|
||||
bash deploy/backup.sh verify "$file"
|
||||
bash deploy/backup.sh restore "$file"
|
||||
after=$(psql -Atc "SELECT count(*) FROM tracks")
|
||||
echo "tracks: $before → $after"
|
||||
test "$before" = "$after"
|
||||
@@ -1,4 +1,4 @@
|
||||
# Expo: expo-doctor и Metro-бандл Android (docs/ci-cd.md). Полная сборка APK — только в release.yml.
|
||||
# Expo: expo-doctor и Metro-бандл Android. Полная сборка APK — только в release.yml.
|
||||
name: ci / mobile
|
||||
|
||||
on:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Python: recommender и analyzer (docs/ci-cd.md).
|
||||
# Python: recommender и analyzer.
|
||||
name: ci / recommender
|
||||
|
||||
on:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md).
|
||||
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза.
|
||||
name: ci / ts
|
||||
|
||||
on:
|
||||
@@ -51,3 +51,11 @@ jobs:
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test
|
||||
- run: pnpm --filter @pipu/web build
|
||||
# Браузер качается долго (основной CDN Microsoft из раннера недоступен) — кэшируем по lock-файлу.
|
||||
# Системные библиотеки (libnspr4 и т.п.) в образе раннера нет, их ставит --with-deps на каждом прогоне.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium
|
||||
- run: pnpm --filter @pipu/web test:e2e
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
||||
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md).
|
||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
|
||||
- name: Сборка образов
|
||||
env: { DOCKER_BUILDKIT: "1" }
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Ночные проверки, которым нужен поднятый стенд или эмулятор. На каждый push не запускаются.
|
||||
name: nightly / e2e
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
jobs:
|
||||
schemathesis:
|
||||
name: schemathesis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: pip install schemathesis
|
||||
- name: Фаззинг API
|
||||
env:
|
||||
PIPU_API_URL: ${{ vars.PIPU_API_URL }}
|
||||
PIPU_API_TOKEN: ${{ secrets.PIPU_API_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$PIPU_API_URL" ]; then echo "PIPU_API_URL is not set"; exit 0; fi
|
||||
sh tests/contract/schemathesis.sh
|
||||
|
||||
maestro:
|
||||
name: maestro
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Сценарии Android
|
||||
env:
|
||||
PIPU_E2E_SERVER: ${{ vars.PIPU_E2E_SERVER }}
|
||||
PIPU_E2E_USER: ${{ vars.PIPU_E2E_USER }}
|
||||
PIPU_E2E_PASSWORD: ${{ secrets.PIPU_E2E_PASSWORD }}
|
||||
run: |
|
||||
if [ -z "$PIPU_E2E_SERVER" ]; then echo "emulator flow is configured in apps/mobile/.maestro"; exit 0; fi
|
||||
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
~/.maestro/bin/maestro test apps/mobile/.maestro
|
||||
@@ -1,4 +1,4 @@
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): Gitea Release с заметками → APK.
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
|
||||
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
|
||||
# и переписывает статус: собран / не удалась (scripts/release.mjs).
|
||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||||
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
||||
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
|
||||
# (container.valid_volumes: ['/opt/ci-cache/**']); иначе раннер их пропускает и всё качается заново.
|
||||
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
|
||||
defaults:
|
||||
run:
|
||||
@@ -143,6 +143,11 @@ jobs:
|
||||
fi
|
||||
./gradlew --stop
|
||||
|
||||
# Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске
|
||||
# на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86).
|
||||
- name: Проверка APK (ABI, 16 КБ страницы)
|
||||
run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Диагностика сбоя (диск, память, OOM)
|
||||
if: failure()
|
||||
run: |
|
||||
|
||||
@@ -27,6 +27,8 @@ __pycache__/
|
||||
|
||||
# generated test audio
|
||||
tests/fixtures/music/out/
|
||||
apps/web/test-results/
|
||||
apps/web/playwright-report/
|
||||
|
||||
# os / editors
|
||||
.DS_Store
|
||||
@@ -35,3 +37,7 @@ Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.cache/
|
||||
apps/desktop/src-tauri/target/
|
||||
apps/desktop/src-tauri/gen/
|
||||
apps/desktop/src-tauri/tauri.release.json
|
||||
apps/desktop/release/
|
||||
@@ -4,8 +4,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
|
||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||
|
||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||
- Документация для разработки: [docs/README.md](docs/README.md)
|
||||
- План: [docs/roadmap.md](docs/roadmap.md)
|
||||
- Документация: [docs/README.md](docs/README.md)
|
||||
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
||||
|
||||
## Структура
|
||||
@@ -67,4 +66,4 @@ pnpm generate && (cd services/api && go generate ./...)
|
||||
|
||||
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
|
||||
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
|
||||
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
|
||||
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты перечислены в шапке `.gitea/workflows/release.yml`.
|
||||
+319
-8
@@ -359,6 +359,22 @@ paths:
|
||||
$ref: "#/components/schemas/TemporaryPassword"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/users/{id}/reset-recommendations:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserId"
|
||||
post:
|
||||
operationId: resetUserRecommendations
|
||||
tags: [admin]
|
||||
summary: Сбросить рекомендации
|
||||
description: |
|
||||
Рекомендации начинаются с нуля: стираются профиль вкуса (онбординг, CF-вектор, бандит),
|
||||
статистика прослушиваний и показы волны; онбординг покажется снова.
|
||||
Лайки, дизлайки и история остаются.
|
||||
responses:
|
||||
"204":
|
||||
description: Сброшено
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/quota:
|
||||
get:
|
||||
operationId: getUploadQuota
|
||||
@@ -435,6 +451,35 @@ paths:
|
||||
$ref: "#/components/schemas/UploadFilePage"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/unseen:
|
||||
get:
|
||||
operationId: unseenUploadDecisions
|
||||
tags: [uploads]
|
||||
summary: Сколько решений по своим предложениям с прошлого визита (moderator, admin)
|
||||
responses:
|
||||
"200":
|
||||
description: Число
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [count]
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/seen:
|
||||
post:
|
||||
operationId: markUploadsSeen
|
||||
tags: [uploads]
|
||||
summary: Отметить свои предложения просмотренными
|
||||
responses:
|
||||
"204":
|
||||
description: Отмечено
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/files/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EntityId"
|
||||
@@ -519,6 +564,65 @@ paths:
|
||||
$ref: "#/components/schemas/UploadFile"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/uploads/files/{id}/stream:
|
||||
get:
|
||||
operationId: streamUpload
|
||||
tags: [uploads]
|
||||
security: []
|
||||
summary: Прослушать файл из staging (подпись, как у стрима трека)
|
||||
description: Ссылку даёт `stream_url` файла очереди. Range поддерживается.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EntityId"
|
||||
- name: exp
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- name: sig
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Аудио из staging
|
||||
content:
|
||||
audio/*:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/uploads/batches/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EntityId"
|
||||
patch:
|
||||
operationId: updateUploadBatch
|
||||
tags: [uploads]
|
||||
summary: Правка метаданных и обложки на всю пачку (admin)
|
||||
description: Исполнитель, альбом и год применяются к файлам в pending_review и import_failed. Обложка встраивается при импорте.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UploadBatchPatch"
|
||||
responses:
|
||||
"200":
|
||||
description: Обновлённые файлы пачки
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UploadFile"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/uploads/review:
|
||||
post:
|
||||
operationId: reviewUploads
|
||||
@@ -1006,6 +1110,24 @@ paths:
|
||||
$ref: "#/components/schemas/SearchResults"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/library/analysis/retry:
|
||||
post:
|
||||
operationId: retryAnalysis
|
||||
tags: [library]
|
||||
summary: Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||
responses:
|
||||
"200":
|
||||
description: Сколько задач снова в очереди
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [requeued]
|
||||
properties:
|
||||
requeued:
|
||||
type: integer
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/search/reindex:
|
||||
post:
|
||||
operationId: reindexSearch
|
||||
@@ -1379,6 +1501,7 @@ paths:
|
||||
get:
|
||||
operationId: getAndroidLatest
|
||||
tags: [app]
|
||||
security: [] # публично: страница /android раздаёт приложение без входа
|
||||
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||
description: |
|
||||
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
||||
@@ -1396,6 +1519,7 @@ paths:
|
||||
get:
|
||||
operationId: downloadAndroidApk
|
||||
tags: [app]
|
||||
security: []
|
||||
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
|
||||
parameters:
|
||||
- name: version
|
||||
@@ -1414,6 +1538,82 @@ paths:
|
||||
format: binary
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/me/playlists:
|
||||
get:
|
||||
tags: [collection]
|
||||
operationId: listPlaylists
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PlaylistList'
|
||||
post:
|
||||
tags: [collection]
|
||||
operationId: createPlaylist
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PlaylistInput'
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'201':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Playlist'
|
||||
/api/v1/me/playlists/{id}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
get:
|
||||
tags: [collection]
|
||||
operationId: getPlaylist
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Playlist'
|
||||
put:
|
||||
tags: [collection]
|
||||
operationId: updatePlaylist
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PlaylistUpdate'
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Playlist'
|
||||
delete:
|
||||
tags: [collection]
|
||||
operationId: deletePlaylist
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
components:
|
||||
parameters:
|
||||
SourceId:
|
||||
@@ -1474,6 +1674,53 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
schemas:
|
||||
PlaylistInput:
|
||||
type: object
|
||||
required: [name, description, track_ids]
|
||||
properties:
|
||||
name: { type: string, minLength: 1, maxLength: 120 }
|
||||
description: { type: string, maxLength: 2000 }
|
||||
track_ids:
|
||||
type: array
|
||||
maxItems: 2000
|
||||
uniqueItems: true
|
||||
items: { type: string, format: uuid }
|
||||
PlaylistUpdate:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/PlaylistInput'
|
||||
- type: object
|
||||
required: [revision]
|
||||
properties:
|
||||
revision: { type: integer, minimum: 1 }
|
||||
PlaylistSummary:
|
||||
type: object
|
||||
required: [id, name, description, revision, track_count, duration_ms, created_at, updated_at]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
revision: { type: integer }
|
||||
track_count: { type: integer }
|
||||
duration_ms: { type: integer, format: int64 }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
Playlist:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/PlaylistSummary'
|
||||
- type: object
|
||||
required: [tracks]
|
||||
properties:
|
||||
tracks:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Track' }
|
||||
PlaylistList:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/PlaylistSummary' }
|
||||
|
||||
Onboarding:
|
||||
type: object
|
||||
required: [required, artists, genres]
|
||||
@@ -1584,6 +1831,9 @@ components:
|
||||
type: string
|
||||
duration_ms:
|
||||
type: integer
|
||||
album:
|
||||
type: string
|
||||
maxLength: 500
|
||||
UploadPrecheckResult:
|
||||
type: object
|
||||
required: [files]
|
||||
@@ -1697,7 +1947,7 @@ components:
|
||||
nullable: true
|
||||
UploadFile:
|
||||
type: object
|
||||
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url]
|
||||
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, quality_upgrade, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url, stream_url]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -1771,6 +2021,9 @@ components:
|
||||
enum: [sha256, metadata, fingerprint, null]
|
||||
possible_duplicate:
|
||||
type: boolean
|
||||
quality_upgrade:
|
||||
type: boolean
|
||||
description: Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade
|
||||
target_source_id:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -1796,6 +2049,10 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Только автору и пока файл в статусе uploading
|
||||
stream_url:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Подписанная ссылка прослушивания из staging; null, если файла уже нет
|
||||
UploadFilePage:
|
||||
type: object
|
||||
required: [items, next_cursor]
|
||||
@@ -1815,6 +2072,23 @@ components:
|
||||
target_source_id:
|
||||
type: string
|
||||
format: uuid
|
||||
UploadBatchPatch:
|
||||
type: object
|
||||
description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки.
|
||||
properties:
|
||||
artist:
|
||||
type: string
|
||||
maxLength: 500
|
||||
album:
|
||||
type: string
|
||||
maxLength: 500
|
||||
year:
|
||||
type: integer
|
||||
nullable: true
|
||||
cover_base64:
|
||||
type: string
|
||||
nullable: true
|
||||
description: JPEG, PNG или WebP, не больше 8 МБ после декодирования
|
||||
UploadReviewRequest:
|
||||
type: object
|
||||
required: [file_ids, decision]
|
||||
@@ -1854,7 +2128,7 @@ components:
|
||||
- $ref: "#/components/schemas/Problem"
|
||||
UploadSettings:
|
||||
type: object
|
||||
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id]
|
||||
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id, allow_quality_upgrade]
|
||||
properties:
|
||||
queue_limit_bytes:
|
||||
type: integer
|
||||
@@ -1870,6 +2144,9 @@ components:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
allow_quality_upgrade:
|
||||
type: boolean
|
||||
description: Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли
|
||||
UploadSettingsPatch:
|
||||
type: object
|
||||
description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано).
|
||||
@@ -1893,6 +2170,8 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: null — без хранилища по умолчанию
|
||||
allow_quality_upgrade:
|
||||
type: boolean
|
||||
HealthStatus:
|
||||
type: object
|
||||
required: [status]
|
||||
@@ -1944,7 +2223,7 @@ components:
|
||||
enum: [admin, moderator, user]
|
||||
ClientType:
|
||||
type: string
|
||||
enum: [web, android]
|
||||
enum: [web, android, desktop]
|
||||
LoginRequest:
|
||||
type: object
|
||||
required: [username, password, client_type]
|
||||
@@ -1964,7 +2243,7 @@ components:
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
description: Только Android; веб передаёт токен в cookie
|
||||
description: Android и desktop; веб передаёт токен в cookie
|
||||
AuthTokens:
|
||||
type: object
|
||||
required: [access_token, token_type, expires_in, user]
|
||||
@@ -1979,7 +2258,7 @@ components:
|
||||
description: Срок жизни access-токена, секунды
|
||||
refresh_token:
|
||||
type: string
|
||||
description: "Только для `client_type: android`"
|
||||
description: "Для `client_type: android` и `client_type: desktop`"
|
||||
user:
|
||||
$ref: "#/components/schemas/Me"
|
||||
UiPrefs:
|
||||
@@ -1994,6 +2273,19 @@ components:
|
||||
web_player:
|
||||
type: string
|
||||
enum: [bar, island]
|
||||
search_position:
|
||||
type: string
|
||||
enum: [center, left, right]
|
||||
description: Где в топбаре веба поиск (по умолчанию center)
|
||||
search_collapse:
|
||||
type: boolean
|
||||
description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false)
|
||||
home_activities:
|
||||
type: boolean
|
||||
description: Показывать на главной веба выбор занятия (по умолчанию true)
|
||||
home_moods:
|
||||
type: boolean
|
||||
description: Показывать на главной веба выбор настроения (по умолчанию true)
|
||||
mobile_player:
|
||||
type: string
|
||||
enum: [cover, wave]
|
||||
@@ -2007,7 +2299,7 @@ components:
|
||||
type: boolean
|
||||
Me:
|
||||
type: object
|
||||
required: [id, username, display_name, role, locale, ui_prefs, must_change_password, must_change_credentials, created_at]
|
||||
required: [id, username, display_name, role, reviews_uploads, locale, ui_prefs, must_change_password, must_change_credentials, created_at]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -2019,6 +2311,9 @@ components:
|
||||
nullable: true
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
|
||||
locale:
|
||||
type: string
|
||||
ui_prefs:
|
||||
@@ -2059,7 +2354,7 @@ components:
|
||||
type: string
|
||||
AdminUser:
|
||||
type: object
|
||||
required: [id, username, display_name, role, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
|
||||
required: [id, username, display_name, role, reviews_uploads, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -2071,6 +2366,9 @@ components:
|
||||
nullable: true
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
|
||||
locale:
|
||||
type: string
|
||||
is_blocked:
|
||||
@@ -2108,6 +2406,9 @@ components:
|
||||
maxLength: 64
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Сразу выдать аудит загрузок. Может задать только админ
|
||||
CreatedUser:
|
||||
type: object
|
||||
required: [user, temporary_password]
|
||||
@@ -2120,6 +2421,9 @@ components:
|
||||
UpdateUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: Новый логин. Может менять только администратор, в том числе у себя
|
||||
display_name:
|
||||
type: string
|
||||
maxLength: 64
|
||||
@@ -2128,6 +2432,9 @@ components:
|
||||
$ref: "#/components/schemas/Role"
|
||||
is_blocked:
|
||||
type: boolean
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
|
||||
TemporaryPassword:
|
||||
type: object
|
||||
required: [temporary_password]
|
||||
@@ -3081,7 +3388,7 @@ components:
|
||||
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
|
||||
AnalysisStatus:
|
||||
type: object
|
||||
required: [queued, running, done, failed, analyzer_ver]
|
||||
required: [queued, running, done, failed, analyzer_ver, top_error]
|
||||
properties:
|
||||
queued:
|
||||
type: integer
|
||||
@@ -3094,6 +3401,10 @@ components:
|
||||
analyzer_ver:
|
||||
type: string
|
||||
nullable: true
|
||||
top_error:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
|
||||
WaveMetrics:
|
||||
type: object
|
||||
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- runFlow: login.yaml
|
||||
- tapOn: "Коллекция"
|
||||
- assertVisible: "Лайки"
|
||||
@@ -0,0 +1,15 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- assertVisible: "Адрес сервера"
|
||||
- tapOn: "Адрес сервера"
|
||||
- inputText: ${PIPU_E2E_SERVER}
|
||||
- tapOn: "Подключиться"
|
||||
- assertVisible: "Вход"
|
||||
- tapOn: "Логин"
|
||||
- inputText: ${PIPU_E2E_USER}
|
||||
- tapOn: "Пароль"
|
||||
- inputText: ${PIPU_E2E_PASSWORD}
|
||||
- tapOn: "Войти"
|
||||
- assertVisible: "Бегаю"
|
||||
@@ -0,0 +1,10 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- assertVisible: "Адрес сервера|Server address"
|
||||
- stopApp
|
||||
- launchApp
|
||||
- assertVisible: "Адрес сервера|Server address"
|
||||
- openLink: "pipu://album/1"
|
||||
- assertVisible: "Адрес сервера|Server address"
|
||||
@@ -0,0 +1,6 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- runFlow: login.yaml
|
||||
- tapOn: "Профиль"
|
||||
- assertVisible: "Проверить обновления"
|
||||
- tapOn: "Проверить обновления"
|
||||
@@ -30,7 +30,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
'expo-secure-store',
|
||||
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
||||
// Android по умолчанию запрещает незашифрованный HTTP.
|
||||
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
||||
// Распаковываем .so при установке: SoLoader на x86_64 с ARM-трансляцией ищет
|
||||
// внутри APK lib/x86_64 вместо arm64-v8a и не находит libreactnative.so.
|
||||
['expo-build-properties', { android: { usesCleartextTraffic: true, useLegacyPackaging: true } }],
|
||||
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
|
||||
'./plugins/withArm64Only',
|
||||
],
|
||||
extra: { versionCode },
|
||||
experiments: { typedRoutes: false },
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"start": "expo start --dev-client",
|
||||
"android": "expo run:android",
|
||||
"prebuild": "expo prebuild --platform android --clean",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
@@ -42,7 +43,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"@types/react": "~19.0.10"
|
||||
"@types/react": "~19.0.10",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"expo": {
|
||||
"doctor": {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI
|
||||
// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят
|
||||
// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог
|
||||
// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so».
|
||||
const { withAppBuildGradle } = require('expo/config-plugins');
|
||||
|
||||
const MARKER = '// pipu: arm64-v8a only';
|
||||
|
||||
module.exports = function withArm64Only(config) {
|
||||
return withAppBuildGradle(config, (cfg) => {
|
||||
if (cfg.modResults.contents.includes(MARKER)) return cfg;
|
||||
cfg.modResults.contents = cfg.modResults.contents.replace(
|
||||
/defaultConfig\s*\{/,
|
||||
(m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`,
|
||||
);
|
||||
return cfg;
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { unwrap, type Track } from '@pipu/core';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
|
||||
const session = useSession();
|
||||
const current = usePlayerState().track;
|
||||
const [tab, setTab] = useState<Tab>('likes');
|
||||
const router = useRouter();
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['m-collection', tab],
|
||||
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
|
||||
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
||||
|
||||
const tabs = (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
|
||||
<Segmented<Tab>
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
|
||||
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||
|
||||
|
||||
@@ -116,6 +116,16 @@ export default function ProfileScreen() {
|
||||
onChange={(v) => void setPrefs({ mobile_player: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t('settings.normalize')}>
|
||||
<Switch value={user.ui_prefs.normalize !== false} onValueChange={(v) => void setPrefs({ normalize: v })} />
|
||||
</Row>
|
||||
<Row label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}>
|
||||
<Segmented
|
||||
value={String([0, 2, 4, 6].includes(user.ui_prefs.crossfade_s ?? 0) ? (user.ui_prefs.crossfade_s ?? 0) : 0)}
|
||||
options={['0', '2', '4', '6'].map((v) => ({ value: v, label: v }))}
|
||||
onChange={(v) => void setPrefs({ crossfade_s: Number(v) })}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import type { i18n as I18n } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -54,8 +54,6 @@ const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly stri
|
||||
function Gate() {
|
||||
const { server, session, ready } = useSessionCtx();
|
||||
const auth = useAuthState();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
const { i18n } = useTranslation();
|
||||
const user = auth.status === 'signed_in' ? auth.user : null;
|
||||
const system = useColorScheme();
|
||||
@@ -79,17 +77,6 @@ function Gate() {
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const first = segments[0] as string | undefined;
|
||||
let target: string | null = null;
|
||||
if (!server) target = first === 'connect' ? null : '/connect';
|
||||
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
|
||||
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
|
||||
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
|
||||
if (target) router.replace(target as never);
|
||||
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
|
||||
|
||||
if (!ready) return <Splash />;
|
||||
return (
|
||||
<ThemeContext.Provider value={theme}>
|
||||
@@ -101,9 +88,30 @@ function Gate() {
|
||||
|
||||
function Screens() {
|
||||
const colors = useColors();
|
||||
const { server, session } = useSessionCtx();
|
||||
const auth = useAuthState();
|
||||
const connected = !!server && !!session;
|
||||
const user = connected && auth.status === 'signed_in' ? auth.user : null;
|
||||
const needsSetup = !!user && (user.must_change_credentials || user.must_change_password);
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
|
||||
{/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
|
||||
<Stack.Protected guard={!connected}>
|
||||
<Stack.Screen name="connect" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={connected && !user}>
|
||||
<Stack.Screen name="login" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={needsSetup}>
|
||||
<Stack.Screen name="setup" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={!!user && !needsSetup}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="album/[id]" />
|
||||
<Stack.Screen name="artist/[id]" />
|
||||
<Stack.Screen name="playlists" />
|
||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
|
||||
import { useUser } from '../lib/session';
|
||||
import { useSessionCtx, useUser } from '../lib/session';
|
||||
import { player } from '../player/controller';
|
||||
import { usePlayerState } from '../player/hooks';
|
||||
import { useColors } from '../theme';
|
||||
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
|
||||
const router = useRouter();
|
||||
const st = usePlayerState();
|
||||
const prefs = useUser().ui_prefs;
|
||||
const { server } = useSessionCtx();
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [barWidth, setBarWidth] = useState(1);
|
||||
const track = st.track;
|
||||
|
||||
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
|
||||
const duration = st.durationMs || track.duration_ms;
|
||||
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const share = async () => {
|
||||
if (!server || sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
|
||||
} catch {
|
||||
Alert.alert(t('player.share'), t('player.share_error'));
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
@@ -45,8 +59,13 @@ export default function PlayerScreen() {
|
||||
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
|
||||
<Icon name="share" size={24} color={colors.text} />
|
||||
</Pressable>
|
||||
<DownloadButton track={track} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
{prefs.mobile_player === 'wave' ? (
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||
import { useSession, useUser } from '../lib/session';
|
||||
import { player } from '../player/controller';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||
|
||||
type Playlist = components['schemas']['Playlist'];
|
||||
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
|
||||
|
||||
export default function PlaylistsScreen() {
|
||||
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
|
||||
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
|
||||
}
|
||||
|
||||
function List({ track }: { track?: string }) {
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [create, setCreate] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||
const add = useMutation({ mutationFn: async (id: string) => {
|
||||
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
|
||||
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||
return <Screen><FlatList
|
||||
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
|
||||
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
|
||||
contentContainerStyle={{ padding: 16, gap: 12 }}
|
||||
ListHeaderComponent={<View style={{ gap: 12 }}>
|
||||
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
|
||||
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
|
||||
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
|
||||
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
|
||||
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
|
||||
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||
</View>}
|
||||
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
|
||||
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
|
||||
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
|
||||
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
|
||||
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
|
||||
}} />}</Screen>;
|
||||
}
|
||||
|
||||
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
|
||||
const session = useSession();
|
||||
const { t } = useTranslation();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [original] = useState(playlist);
|
||||
const [name, setName] = useState(playlist?.name ?? '');
|
||||
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||
const save = useMutation({ mutationFn: async () => {
|
||||
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
|
||||
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
|
||||
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
|
||||
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
|
||||
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
|
||||
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
|
||||
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
|
||||
</ScrollView></Screen></Modal>;
|
||||
}
|
||||
|
||||
function Detail({ id }: { id: string }) {
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [playError, setPlayError] = useState<unknown>();
|
||||
const key = ['m-playlists', user.id, id];
|
||||
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
const p = query.data;
|
||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
||||
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
||||
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
|
||||
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||
const play = (shuffle: boolean) => {
|
||||
const tracks = p?.tracks.filter(t => t.available) ?? [];
|
||||
if (shuffle) for (let i=tracks.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [tracks[i],tracks[j]]=[tracks[j]!,tracks[i]!]; }
|
||||
if (tracks.length) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
|
||||
};
|
||||
const move = (index: number, delta: number) => {
|
||||
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
||||
};
|
||||
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
|
||||
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
||||
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
||||
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
||||
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
||||
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
||||
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
||||
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
|
||||
])} />
|
||||
</>}
|
||||
</View>}
|
||||
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
|
||||
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
|
||||
{manage && <View style={{padding:12,gap:6}}>
|
||||
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
|
||||
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
|
||||
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
|
||||
</View>}
|
||||
</View>}
|
||||
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type ReleaseNeed = 'current' | 'optional' | 'required';
|
||||
|
||||
/** Нужно ли обновление: обязательное, если установленная версия ниже min_supported. */
|
||||
export function releaseNeed(installed: number, versionCode: number, minSupported: number): ReleaseNeed {
|
||||
if (versionCode <= installed) return 'current';
|
||||
return installed < minSupported ? 'required' : 'optional';
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { sha256 } from 'js-sha256';
|
||||
|
||||
import { releaseNeed } from './releaseNeed';
|
||||
|
||||
export type AndroidRelease = components['schemas']['AndroidRelease'];
|
||||
|
||||
/** Состояние самообновления (docs/mobile.md#самообновление). */
|
||||
@@ -96,11 +98,12 @@ class Updater {
|
||||
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
|
||||
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
|
||||
const installed = installedVersionCode();
|
||||
if (release.version_code > installed) {
|
||||
const need = releaseNeed(installed, release.version_code, release.min_supported);
|
||||
if (need !== 'current') {
|
||||
this.set({
|
||||
status: 'available',
|
||||
release,
|
||||
required: installed < release.min_supported,
|
||||
required: need === 'required',
|
||||
dismissed: false,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import NetInfo from '@react-native-community/netinfo';
|
||||
import {
|
||||
EventBuffer,
|
||||
PlayAttempt,
|
||||
normalizeGain,
|
||||
unwrap,
|
||||
type AuthSession,
|
||||
type PlayContext,
|
||||
@@ -18,6 +19,10 @@ import TrackPlayer, {
|
||||
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||
import { downloads } from '../offline/downloads';
|
||||
import { PersistentEvents } from '../offline/events';
|
||||
import { offlineWave } from './offlineWave';
|
||||
import { eventId } from './eventId';
|
||||
|
||||
export { offlineWave };
|
||||
|
||||
export interface WaveInfo {
|
||||
sessionId: string;
|
||||
@@ -45,15 +50,6 @@ const WAVE_REFILL_AT = 2;
|
||||
const EARLY_SKIP_MS = 30_000;
|
||||
const SAVE_EVERY_MS = 10_000;
|
||||
|
||||
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
||||
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
|
||||
return tracks
|
||||
.filter((t) => t.reaction !== 'dislike')
|
||||
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
|
||||
.sort((a, b) => a.key - b.key)
|
||||
.map((x) => x.t);
|
||||
}
|
||||
|
||||
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
||||
|
||||
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
|
||||
@@ -118,7 +114,7 @@ class Controller {
|
||||
this.server = server;
|
||||
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
||||
const sink = this.persistent;
|
||||
this.events = sink ? new EventBuffer({ send: sink.send }) : null;
|
||||
this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
|
||||
void sink?.drain();
|
||||
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||
if (st.isConnected) void this.persistent?.drain();
|
||||
@@ -159,6 +155,10 @@ class Controller {
|
||||
this.lastPositionMs = ms;
|
||||
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
|
||||
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
|
||||
const cf = Math.min(6, Math.max(0, this.userPrefs()?.crossfade_s ?? 0));
|
||||
const left = duration - position;
|
||||
const fade = cf > 0 && left < cf ? Math.max(0, left / cf) : 1;
|
||||
void this.applyVolume(this.state.track, fade);
|
||||
});
|
||||
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
|
||||
this.finishAttempt(lastPosition);
|
||||
@@ -167,6 +167,7 @@ class Controller {
|
||||
if (!track) return;
|
||||
this.attempt = new PlayAttempt(track, 0);
|
||||
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
|
||||
void this.applyVolume(track, 1);
|
||||
this.events?.push({
|
||||
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
|
||||
context_id: this.state.context?.id,
|
||||
@@ -209,6 +210,17 @@ class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
private userPrefs() {
|
||||
const st = this.session?.state;
|
||||
return st?.status === 'signed_in' ? st.user.ui_prefs : undefined;
|
||||
}
|
||||
|
||||
/** Громкость 0..1: нормализация вниз к −14 LUFS и затухание кроссфейда в конце трека. */
|
||||
private async applyVolume(track: Track | null, fade: number) {
|
||||
const gain = Math.min(1, normalizeGain(track?.features?.loudness_lufs, this.userPrefs()?.normalize !== false) * fade);
|
||||
await TrackPlayer.setVolume(gain).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
||||
const q = await quality();
|
||||
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { uuidv7 } from '@pipu/core';
|
||||
|
||||
/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */
|
||||
export function eventId(now: number): string {
|
||||
return uuidv7(now, () => {
|
||||
const hex = globalThis.expo.uuidv4().replace(/-/g, '');
|
||||
return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Track } from '@pipu/core';
|
||||
|
||||
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
||||
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
|
||||
return tracks
|
||||
.filter((t) => t.reaction !== 'dislike')
|
||||
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
|
||||
.sort((a, b) => a.key - b.key)
|
||||
.map((x) => x.t);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ApiError, type Track } from '@pipu/core';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
@@ -199,6 +200,7 @@ export function TrackRow({
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const liked = track.reaction === 'like';
|
||||
const router = useRouter();
|
||||
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||
return (
|
||||
<Pressable
|
||||
@@ -233,6 +235,10 @@ export function TrackRow({
|
||||
</Pressable>
|
||||
)}
|
||||
{trailing}
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
|
||||
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
|
||||
<Icon name="playlist-add" size={22} />
|
||||
</Pressable>
|
||||
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
||||
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { EventBuffer } from '@pipu/core';
|
||||
import { eventId } from '../src/player/eventId';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('queues restored-track events on Hermes without Web Crypto', () => {
|
||||
vi.stubGlobal('crypto', undefined);
|
||||
const nativeUuid = vi.fn()
|
||||
.mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff')
|
||||
.mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe');
|
||||
vi.stubGlobal('expo', { uuidv4: nativeUuid });
|
||||
const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId);
|
||||
buffer.push({ type: 'play_start', track_id: 'restored-track' });
|
||||
buffer.push({ type: 'skip', track_id: 'restored-track' });
|
||||
expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff');
|
||||
expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id);
|
||||
expect(nativeUuid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { releaseNeed } from '../src/lib/releaseNeed';
|
||||
import { offlineWave } from '../src/player/offlineWave';
|
||||
import type { Track } from '@pipu/core';
|
||||
|
||||
function track(id: string, reaction: Track['reaction']): Track {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
duration_ms: 1000,
|
||||
artists: [],
|
||||
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [],
|
||||
disc_no: 1,
|
||||
track_no: 1,
|
||||
available: true,
|
||||
stream_url: null,
|
||||
reaction,
|
||||
features: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('offline wave', () => {
|
||||
it('drops dislikes and keeps likes ahead of the rest', () => {
|
||||
const tracks = [track('d', 'dislike'), track('n', null), track('l', 'like')];
|
||||
const order = offlineWave(tracks, () => 0.5).map((t) => t.id);
|
||||
expect(order).toEqual(['l', 'n']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update dialog decision', () => {
|
||||
it('marks an older install below the minimum as required', () => {
|
||||
expect(releaseNeed(10, 12, 11)).toBe('required');
|
||||
expect(releaseNeed(11, 12, 11)).toBe('optional');
|
||||
expect(releaseNeed(12, 12, 11)).toBe('current');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,8 @@ WORKDIR /repo
|
||||
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
||||
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает)
|
||||
COPY patches/ patches/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/mobile/package.json apps/mobile/
|
||||
COPY packages/api-client/package.json packages/api-client/
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
async function login(page: Page, username: string) {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill(username);
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Ещё', exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function fits(page: Page) {
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const main = document.querySelector('main')!;
|
||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||
})).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430, 'alice']] as const) {
|
||||
test(`mobile ${width}: ${username} navigation, forms and search`, async ({ page }, info) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await login(page, username);
|
||||
await fits(page);
|
||||
const bottom = page.getByRole('navigation', { name: 'Навигация' }).filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
|
||||
await expect(bottom.getByRole('link')).toHaveCount(3);
|
||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Навигация' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('link', { name: 'Дизлайки', exact: true })).toBeVisible();
|
||||
if (username !== 'alice') {
|
||||
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole('link', { name: 'Хранилища', exact: true })).toHaveCount(username === 'mobile-admin' ? 1 : 0);
|
||||
await dialog.getByRole('link', { name: 'Пользователи', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
|
||||
await fits(page);
|
||||
await page.screenshot({ path: info.outputPath('users.png') });
|
||||
await page.getByRole('button', { name: /Действия.*alice/ }).click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
const rect = await menu.boundingBox();
|
||||
expect(rect!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(rect!.x + rect!.width).toBeLessThanOrEqual(width);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Создать пользователя', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('create-user.png') });
|
||||
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
} else {
|
||||
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible();
|
||||
await fits(page);
|
||||
await page.screenshot({ path: info.outputPath('settings.png') });
|
||||
for (const name of ['Слева', 'По центру', 'Справа']) {
|
||||
await page.getByRole('button', { name, exact: true }).click();
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||
await expect(page.getByRole('option', { name: /Кино/ }).first()).toBeVisible();
|
||||
await fits(page);
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Escape');
|
||||
}
|
||||
|
||||
if (username !== 'alice') {
|
||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
|
||||
await expect(page.locator('audio')).toBeVisible();
|
||||
await fits(page);
|
||||
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
|
||||
await expect(page.getByRole('region', { name: 'Действия с выбранными' })).toBeVisible();
|
||||
await fits(page);
|
||||
await page.screenshot({ path: info.outputPath('upload-queue.png') });
|
||||
await page.getByRole('region', { name: 'Действия с выбранными' }).getByRole('button', { name: 'Отклонить' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
}
|
||||
if (username === 'mobile-admin') {
|
||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
|
||||
await expect(page.getByRole('article', { name: 'Музыкальное хранилище' })).toBeVisible();
|
||||
await fits(page);
|
||||
await page.getByRole('button', { name: 'Шаблоны путей', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Добавить шаблон', exact: true }).click();
|
||||
await fits(page);
|
||||
await page.screenshot({ path: info.outputPath('patterns.png') });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 320, height: 740 });
|
||||
await login(page, 'alice');
|
||||
const box = page.getByRole('combobox', { name: 'Поиск', exact: true });
|
||||
await box.fill('кино');
|
||||
await box.press('Enter');
|
||||
await page.getByRole('link', { name: /Группа крови/ }).first().click();
|
||||
await page.getByRole('listitem').first().click();
|
||||
const player = page.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
|
||||
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
|
||||
await fits(page);
|
||||
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Остров', exact: true }).click();
|
||||
const nav = page.getByRole('navigation').filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
|
||||
await expect.poll(async () => {
|
||||
const p = await player.boundingBox();
|
||||
const n = await nav.boundingBox();
|
||||
return p!.y + p!.height <= n!.y + 1;
|
||||
}).toBe(true);
|
||||
await player.getByRole('button', { name: 'Очередь', exact: true }).click();
|
||||
await expect(page.getByRole('complementary', { name: 'Очередь', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('player-queue.png') });
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const width of [320, 1280]) {
|
||||
test(`playlists at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
|
||||
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
|
||||
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
|
||||
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
||||
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
||||
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const main = document.querySelector('main')!;
|
||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||
})).toBeLessThanOrEqual(1);
|
||||
await page.screenshot({ path: info.outputPath('playlist.png') });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
});
|
||||
|
||||
test('1. bootstrap admin changes credentials and lands on home', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByText(/Первый запуск/)).toBeVisible();
|
||||
await page.getByLabel('Логин').fill('admin');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('admin-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Смените логин и пароль' })).toBeVisible();
|
||||
await page.getByLabel('Новый логин').fill('owner');
|
||||
await page.getByRole('textbox', { name: 'Новый пароль' }).fill('a-strong-owner-pass');
|
||||
await page.getByRole('textbox', { name: 'Повторите пароль' }).fill('a-strong-owner-pass');
|
||||
await page.getByRole('button', { name: 'Сохранить и продолжить' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Бегаю' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('4. search suggest opens the artist and Enter opens search', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин').fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
const box = page.getByRole('combobox', { name: 'Поиск' });
|
||||
await box.fill('кино');
|
||||
await page.getByRole('option', { name: /Кино/ }).first().click();
|
||||
await expect(page.getByRole('heading', { name: 'Кино' })).toBeVisible();
|
||||
|
||||
await box.fill('кино');
|
||||
// A suggestion under the pointer must not become a keyboard selection.
|
||||
const suggestion = page.getByRole('option', { name: /Кино/ }).first();
|
||||
await suggestion.hover();
|
||||
await expect(suggestion).toHaveAttribute('aria-selected', 'false');
|
||||
await box.press('Enter');
|
||||
await expect(page).toHaveURL(/\/search\?q=/);
|
||||
});
|
||||
|
||||
test('settings expose crossfade and loudness normalization', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин').fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки' }).click();
|
||||
await expect(page.getByText('Выравнивание громкости')).toBeVisible();
|
||||
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
|
||||
await slider.fill('4');
|
||||
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
|
||||
});
|
||||
test('9. login page switches between Russian and English', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByRole('button', { name: 'EN' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'RU' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Вход' })).toBeVisible();
|
||||
});
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>pipu</title>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||
@@ -21,13 +22,16 @@
|
||||
"@pipu/i18n": "workspace:*",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"@tanstack/react-router": "^1.120.20",
|
||||
"@tanstack/react-virtual": "^3.14.21",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"music-metadata": "^12.2.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.3",
|
||||
"tus-js-client": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -35,6 +39,7 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"axe-core": "^4.14.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] },
|
||||
webServer: {
|
||||
command: 'pnpm exec vite --port 4173 --strictPort',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: { VITE_E2E: '1' },
|
||||
},
|
||||
});
|
||||
+138
-2
@@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query';
|
||||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import axe from 'axe-core';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -117,7 +118,8 @@ describe('navigation by role', () => {
|
||||
|
||||
it('shows users but not the audit log to moderators', async () => {
|
||||
const mod = makeUser('mod', 'moderator');
|
||||
await renderApp([mod], '/', { signedInAs: mod });
|
||||
const { user } = await renderApp([mod], '/', { signedInAs: mod });
|
||||
await user.click(await screen.findByRole('button', { name: 'Админка' }));
|
||||
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -152,6 +154,39 @@ describe('users admin', () => {
|
||||
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['root', 'anna'])('lets admins change the username of %s', async (target) => {
|
||||
const root = makeUser('root', 'admin');
|
||||
const anna = makeUser('anna', 'user');
|
||||
const { user, api, session } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||
await user.click(await screen.findByRole('button', { name: `Действия с ${target}` }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||
const input = within(dialog).getByLabelText('Логин');
|
||||
await user.clear(input);
|
||||
await user.type(input, `new.${target}`);
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(await screen.findByText(`@new.${target}`)).toBeInTheDocument();
|
||||
expect(api.users.get(target === 'root' ? root.id : anna.id)?.username).toBe(`new.${target}`);
|
||||
if (target === 'root') expect(session.state).toMatchObject({ status: 'signed_in', user: { username: 'new.root' } });
|
||||
});
|
||||
|
||||
it('keeps the username dialog open when the new username is taken', async () => {
|
||||
const root = makeUser('root', 'admin');
|
||||
const anna = makeUser('anna', 'user');
|
||||
const { user, api } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||
await user.click(await screen.findByRole('button', { name: 'Действия с anna' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||
const input = within(dialog).getByLabelText('Логин');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ROOT');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
|
||||
expect(api.users.get(anna.id)?.username).toBe('anna');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers moderators only what they may do', async () => {
|
||||
const mod = makeUser('mod', 'moderator');
|
||||
const anna = makeUser('anna', 'user');
|
||||
@@ -164,7 +199,7 @@ describe('users admin', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
|
||||
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
|
||||
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
|
||||
expect(items).toEqual(['Сбросить пароль', 'Сбросить рекомендации', 'Заблокировать']);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
|
||||
@@ -246,6 +281,30 @@ describe('search box', () => {
|
||||
});
|
||||
|
||||
describe('player', () => {
|
||||
it('opens a shared track after login and copies its page URL from the player', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const path = '/track/00000000-0000-4000-8000-000000000101';
|
||||
const { user, router } = await renderApp([alice], path);
|
||||
await screen.findByRole('heading', { name: 'Вход' });
|
||||
await user.type(screen.getByLabelText('Логин'), alice.username);
|
||||
await user.type(screen.getByLabelText('Пароль'), alice.password);
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
await user.click(screen.getByRole('listitem'));
|
||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
||||
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
|
||||
});
|
||||
|
||||
it('shows a missing shared track without starting playback', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
await renderApp([alice], '/track/missing', { signedInAs: alice });
|
||||
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||
@@ -313,6 +372,31 @@ describe('my wave', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggest tracks', () => {
|
||||
it('warns that lossless formats are the ones to send', async () => {
|
||||
const admin = makeUser('admin', 'admin');
|
||||
await renderApp([admin], '/uploads/new', { signedInAs: admin });
|
||||
expect(await screen.findByText(/Нужны FLAC, WAV или AIFF/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/MP3 — только в крайнем случае/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('home and login have no serious axe violations', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
await renderApp([alice], '/', { signedInAs: alice });
|
||||
await screen.findByRole('button', { name: 'Бегаю' });
|
||||
const home = await axe.run(document.body, { resultTypes: ['violations'] });
|
||||
expect(home.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
|
||||
|
||||
cleanup();
|
||||
await renderApp([], '/login');
|
||||
await screen.findByRole('heading', { name: 'Вход' });
|
||||
const login = await axe.run(document.body, { resultTypes: ['violations'] });
|
||||
expect(login.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeRedirect', () => {
|
||||
it.each([
|
||||
['/settings', '/settings'],
|
||||
@@ -323,3 +407,55 @@ describe('safeRedirect', () => {
|
||||
expect(safeRedirect(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playlists', () => {
|
||||
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||
await user.type(screen.getByLabelText('Название'), 'В дорогу');
|
||||
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await screen.findByRole('heading', { name: 'В дорогу' });
|
||||
const id = [...api.playlists.keys()][0]!;
|
||||
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
|
||||
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
|
||||
await screen.findByRole('heading', { name: 'Группа крови' });
|
||||
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
|
||||
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
|
||||
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
|
||||
await router.navigate({ to: '/playlist/$id', params: { id } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
|
||||
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
|
||||
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
|
||||
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
|
||||
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||
await user.clear(screen.getByLabelText('Название'));
|
||||
await user.type(screen.getByLabelText('Название'), 'Вечер');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await screen.findByRole('heading', { name: 'Вечер' });
|
||||
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
|
||||
await screen.findByRole('heading', { name: 'Плейлисты' });
|
||||
expect(api.playlists.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||
await user.type(screen.getByLabelText('Название'), 'Первый');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await screen.findByRole('heading', { name: 'Первый' });
|
||||
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||
const item = [...api.playlists.values()][0]!;
|
||||
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
|
||||
await user.type(screen.getByLabelText('Название'), ' изменён');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
|
||||
});
|
||||
});
|
||||
+26
-10
@@ -7,20 +7,24 @@ import {
|
||||
redirect,
|
||||
type ParsedLocation,
|
||||
} from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { AuditPage } from '../pages/admin/AuditPage';
|
||||
import { LibraryPage } from '../pages/admin/LibraryPage';
|
||||
import { PatternsPage } from '../pages/admin/PatternsPage';
|
||||
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
|
||||
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
|
||||
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
|
||||
import { SuggestPage } from '../pages/uploads/SuggestPage';
|
||||
import { SourcesPage } from '../pages/admin/SourcesPage';
|
||||
import { UsersPage } from '../pages/admin/UsersPage';
|
||||
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
||||
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
||||
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
||||
const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
|
||||
const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
|
||||
const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
|
||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||
import { AlbumPage } from '../pages/AlbumPage';
|
||||
import { TrackPage } from '../pages/TrackPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
||||
import { SettingsPage } from '../pages/SettingsPage';
|
||||
@@ -59,6 +63,9 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
errorComponent: RouteError,
|
||||
});
|
||||
|
||||
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/login',
|
||||
@@ -112,6 +119,9 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
};
|
||||
|
||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||
|
||||
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||
|
||||
@@ -221,7 +231,9 @@ const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/upl
|
||||
const uploadQueueRoute = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: '/admin/uploads',
|
||||
beforeLoad: adminOnly,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.user.reviews_uploads) throw redirect({ to: '/' });
|
||||
},
|
||||
component: UploadQueuePage,
|
||||
});
|
||||
|
||||
@@ -233,11 +245,15 @@ const adminSettingsRoute = createRoute({
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
androidAppRoute,
|
||||
loginRoute,
|
||||
setupAdminRoute,
|
||||
setupPasswordRoute,
|
||||
appRoute.addChildren([
|
||||
homeRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
playlistRoute,
|
||||
settingsRoute,
|
||||
searchRoute,
|
||||
historyRoute,
|
||||
|
||||
@@ -270,3 +270,12 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
|
||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||
.end { gap: 4px; }
|
||||
.like { opacity: 1; width: 44px; height: 44px; }
|
||||
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Fragment } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Fragment, useRef, type ReactNode } from 'react';
|
||||
import { AddToPlaylist } from './playlists';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
@@ -114,6 +116,7 @@ export function TrackList({
|
||||
rowMeta,
|
||||
lead,
|
||||
disliked,
|
||||
actions,
|
||||
}: {
|
||||
tracks: Track[];
|
||||
showAlbum?: boolean;
|
||||
@@ -125,25 +128,28 @@ export function TrackList({
|
||||
lead?: (t: Track, i: number) => string;
|
||||
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
||||
disliked?: boolean;
|
||||
actions?: (track: Track, index: number) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const { track: current, playing } = usePlayerState();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||
let lastDisc: number | null = null;
|
||||
return (
|
||||
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
|
||||
{tracks.map((tr, i) => {
|
||||
const disc = tr.disc_no ?? 1;
|
||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||
lastDisc = disc;
|
||||
const multiDisc = Boolean(numbered && discs.size > 1);
|
||||
const virtual = tracks.length > 30 && !multiDisc;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: virtual ? tracks.length : 0,
|
||||
getScrollElement: () => listRef.current?.closest('main') ?? null,
|
||||
estimateSize: () => 68,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const row = (tr: Track, i: number) => {
|
||||
const isCurrent = current?.id === tr.id;
|
||||
const play = () => tr.available && player.playList(tracks, i, context);
|
||||
const liked = tr.reaction === 'like';
|
||||
const meta = rowMeta?.(tr, i);
|
||||
return (
|
||||
<Fragment key={`${tr.id}-${i}`}>
|
||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||
<div
|
||||
role="listitem"
|
||||
tabIndex={tr.available ? 0 : -1}
|
||||
@@ -151,7 +157,7 @@ export function TrackList({
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) play();
|
||||
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||
@@ -178,7 +184,8 @@ export function TrackList({
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end}>
|
||||
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
||||
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
||||
{disliked ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -202,6 +209,37 @@ export function TrackList({
|
||||
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
let lastDisc: number | null = null;
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={s.tracks}
|
||||
role="list"
|
||||
aria-label={t('search.tracks')}
|
||||
style={virtual ? { height: virtualizer.getTotalSize(), position: 'relative' } : undefined}
|
||||
>
|
||||
{virtual
|
||||
? virtualizer.getVirtualItems().map((vi) => (
|
||||
<div
|
||||
key={vi.key}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||
>
|
||||
{row(tracks[vi.index]!, vi.index)}
|
||||
</div>
|
||||
))
|
||||
: tracks.map((tr, i) => {
|
||||
const disc = tr.disc_no ?? 1;
|
||||
const header = multiDisc && disc !== lastDisc;
|
||||
lastDisc = disc;
|
||||
return (
|
||||
<Fragment key={`${tr.id}-${i}`}>
|
||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||
{row(tr, i)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||
import { queueIcon } from '../shared/ui/icons';
|
||||
import s from './catalog.module.css';
|
||||
|
||||
export type Playlist = components['schemas']['Playlist'];
|
||||
export type PlaylistInput = components['schemas']['PlaylistInput'];
|
||||
|
||||
export function usePlaylists(enabled = true) {
|
||||
const session = useSession();
|
||||
return useQuery({ queryKey: ['playlists'], enabled,
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||
}
|
||||
|
||||
export function usePlaylist(id: string) {
|
||||
const session = useSession();
|
||||
return useQuery({ queryKey: ['playlists', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
}
|
||||
|
||||
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
|
||||
}
|
||||
|
||||
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
|
||||
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [original] = useState(playlist);
|
||||
const [name, setName] = useState(playlist?.name ?? '');
|
||||
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||
const save = useMutation({ mutationFn: async () => {
|
||||
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
|
||||
return original
|
||||
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||
}, onSuccess: p => {
|
||||
cache.setQueryData(['playlists', p.id], p);
|
||||
void cache.invalidateQueries({ queryKey: ['playlists'] });
|
||||
onSaved(p);
|
||||
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
|
||||
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
|
||||
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
|
||||
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
|
||||
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
|
||||
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
|
||||
</form>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
||||
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const list = usePlaylists();
|
||||
const [create, setCreate] = useState(false);
|
||||
const add = useMutation({ mutationFn: async (id: string) => {
|
||||
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||
const body = playlistInput(p);
|
||||
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
|
||||
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
|
||||
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
|
||||
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
|
||||
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
|
||||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
|
||||
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return <>
|
||||
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
|
||||
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
|
||||
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { needsFormatAttention } from './files';
|
||||
|
||||
describe('needsFormatAttention', () => {
|
||||
it('marks everything except lossless formats', () => {
|
||||
expect(needsFormatAttention('flac', 'a.mp3')).toBe(false);
|
||||
expect(needsFormatAttention('WAV', 'track.wav')).toBe(false);
|
||||
expect(needsFormatAttention(null, 'Кино/01.wav')).toBe(false);
|
||||
expect(needsFormatAttention('aiff', 'a.aif')).toBe(false);
|
||||
expect(needsFormatAttention(null, 'track.AIFF')).toBe(false);
|
||||
expect(needsFormatAttention(null, 'track.wv')).toBe(false);
|
||||
expect(needsFormatAttention('mp3', 'a.flac')).toBe(true);
|
||||
expect(needsFormatAttention(null, 'folder/song.MP3')).toBe(true);
|
||||
expect(needsFormatAttention(null, 'song.m4a')).toBe(true);
|
||||
expect(needsFormatAttention(null, 'noext')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,23 @@ export interface PickedFile {
|
||||
/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */
|
||||
export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv'];
|
||||
|
||||
/** То, что просим присылать. MP3 и прочие — крайний случай, их помечает очередь. */
|
||||
// Lossless — как в quality.go на сервере.
|
||||
const PREFERRED_AUDIO = new Set(['flac', 'wav', 'aiff', 'aif', 'wv']);
|
||||
|
||||
export function audioExt(name: string): string {
|
||||
const base = name.split(/[/\\]/).pop() ?? name;
|
||||
const dot = base.lastIndexOf('.');
|
||||
if (dot < 0 || dot === base.length - 1) return '';
|
||||
return base.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/** Не lossless (FLAC, WAV, AIFF, WavPack): в очереди на добавление такой файл нужно выделить. */
|
||||
export function needsFormatAttention(format: string | null | undefined, name: string): boolean {
|
||||
const ext = (format || audioExt(name)).toLowerCase();
|
||||
return ext !== '' && !PREFERRED_AUDIO.has(ext);
|
||||
}
|
||||
|
||||
export function isAudio(name: string): boolean {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
return AUDIO_EXTENSIONS.includes(ext);
|
||||
|
||||
@@ -17,15 +17,18 @@ export type Stage =
|
||||
| 'uploading'
|
||||
| 'done'
|
||||
| 'rejected'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'resume';
|
||||
|
||||
export interface UploadItem {
|
||||
id: string;
|
||||
file: File;
|
||||
file: File | null;
|
||||
size: number;
|
||||
relPath: string;
|
||||
stage: Stage;
|
||||
progress: number; // 0..1 текущего этапа (хэш или загрузка)
|
||||
sha256?: string;
|
||||
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
|
||||
trackId?: string | null;
|
||||
possibleDuplicate?: boolean;
|
||||
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
|
||||
@@ -44,6 +47,17 @@ export interface UploaderState {
|
||||
|
||||
const PARALLEL = 4;
|
||||
const CHUNK = 8 << 20;
|
||||
const PENDING_KEY = 'pipu.uploads.pending';
|
||||
|
||||
interface PendingUpload {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
relPath: string;
|
||||
sha256: string;
|
||||
tusUrl: string;
|
||||
uploadFileId: string;
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
|
||||
@@ -56,7 +70,9 @@ export class Uploader {
|
||||
private uploads = new Map<string, tus.Upload>();
|
||||
private refreshing: Promise<unknown> | null = null;
|
||||
|
||||
constructor(private readonly session: AuthSession) {}
|
||||
constructor(private readonly session: AuthSession) {
|
||||
this.restore();
|
||||
}
|
||||
|
||||
subscribe = (fn: () => void) => {
|
||||
this.listeners.add(fn);
|
||||
@@ -89,8 +105,8 @@ export class Uploader {
|
||||
continue;
|
||||
}
|
||||
// Тот же путь и размер уже в списке — не добавляем второй раз.
|
||||
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue;
|
||||
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 });
|
||||
if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
|
||||
fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
|
||||
}
|
||||
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
|
||||
this.hashQueue.push(...fresh.map((it) => it.id));
|
||||
@@ -101,6 +117,7 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
this.hashQueue = this.hashQueue.filter((x) => x !== id);
|
||||
this.set({ items: this.state.items.filter((it) => it.id !== id) });
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Очистить список (после завершения или чтобы начать заново). */
|
||||
@@ -108,6 +125,7 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
this.hashQueue = [];
|
||||
this.set({ items: [], skipped: 0, batchId: null, error: null });
|
||||
this.persist();
|
||||
}
|
||||
|
||||
private hashWorker(): Worker {
|
||||
@@ -125,6 +143,7 @@ export class Uploader {
|
||||
if (!it) continue;
|
||||
try {
|
||||
const sha256 = await this.hash(it);
|
||||
await this.readTags(it);
|
||||
this.patch(id, { sha256, stage: 'checking', progress: 1 });
|
||||
} catch (err) {
|
||||
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
@@ -137,13 +156,15 @@ export class Uploader {
|
||||
}
|
||||
|
||||
private hash(it: UploadItem): Promise<string> {
|
||||
const file = it.file;
|
||||
if (!file) return Promise.reject(new Error('no file'));
|
||||
const w = this.hashWorker();
|
||||
return new Promise((resolve, reject) => {
|
||||
const onMessage = (e: MessageEvent<HashResponse>) => {
|
||||
const m = e.data;
|
||||
if (m.id !== it.id) return;
|
||||
if (m.type === 'progress') {
|
||||
this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) });
|
||||
this.patch(it.id, { progress: m.done / Math.max(it.size, 1) });
|
||||
return;
|
||||
}
|
||||
w.removeEventListener('message', onMessage);
|
||||
@@ -151,7 +172,7 @@ export class Uploader {
|
||||
else reject(new Error(m.message));
|
||||
};
|
||||
w.addEventListener('message', onMessage);
|
||||
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest);
|
||||
w.postMessage({ id: it.id, file } satisfies HashRequest);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,7 +183,17 @@ export class Uploader {
|
||||
try {
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/uploads/precheck', {
|
||||
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) },
|
||||
body: {
|
||||
files: pending.map((it) => ({
|
||||
client_id: it.id,
|
||||
size: it.size,
|
||||
sha256: it.sha256!,
|
||||
artist: it.tags?.artist,
|
||||
title: it.tags?.title,
|
||||
album: it.tags?.album,
|
||||
duration_ms: it.tags?.duration_ms,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const r of res.files) {
|
||||
@@ -192,9 +223,9 @@ export class Uploader {
|
||||
comment: comment.trim() || undefined,
|
||||
files: ready.map((it) => ({
|
||||
client_id: it.id,
|
||||
name: it.file.name,
|
||||
name: it.file?.name ?? it.relPath,
|
||||
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
|
||||
size: it.file.size,
|
||||
size: it.size,
|
||||
sha256: it.sha256!,
|
||||
})),
|
||||
},
|
||||
@@ -209,6 +240,7 @@ export class Uploader {
|
||||
queue.push({ id: f.client_id, url: f.tus_url });
|
||||
}
|
||||
}
|
||||
this.persist();
|
||||
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
||||
} catch (err) {
|
||||
this.set({ error: err });
|
||||
@@ -239,9 +271,10 @@ export class Uploader {
|
||||
|
||||
private upload(id: string, url: string): Promise<void> {
|
||||
const it = this.item(id);
|
||||
if (!it) return Promise.resolve();
|
||||
if (!it?.file) return Promise.resolve();
|
||||
const file = it.file;
|
||||
return new Promise((resolve) => {
|
||||
const up = new tus.Upload(it.file, {
|
||||
const up = new tus.Upload(file, {
|
||||
uploadUrl: url,
|
||||
chunkSize: CHUNK,
|
||||
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
|
||||
@@ -264,6 +297,7 @@ export class Uploader {
|
||||
onSuccess: () => {
|
||||
this.uploads.delete(id);
|
||||
this.patch(id, { stage: 'done', progress: 1 });
|
||||
this.persist();
|
||||
resolve();
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -276,6 +310,100 @@ export class Uploader {
|
||||
up.start();
|
||||
});
|
||||
}
|
||||
|
||||
private async readTags(it: UploadItem) {
|
||||
if (!it.file) return;
|
||||
try {
|
||||
const { parseBlob } = await import('music-metadata');
|
||||
const meta = await parseBlob(it.file);
|
||||
const duration = meta.format.duration;
|
||||
this.patch(it.id, {
|
||||
tags: {
|
||||
artist: meta.common.artist,
|
||||
title: meta.common.title,
|
||||
album: meta.common.album,
|
||||
duration_ms: duration ? Math.round(duration * 1000) : undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Теги — подсказка для предпроверки; без них сервер прочитает их сам.
|
||||
}
|
||||
}
|
||||
|
||||
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
|
||||
private persist() {
|
||||
const pending: PendingUpload[] = this.state.items
|
||||
.filter((it): it is UploadItem & { sha256: string; tusUrl: string; uploadFileId: string } =>
|
||||
Boolean(it.tusUrl && it.sha256 && it.uploadFileId && it.stage !== 'done'),
|
||||
)
|
||||
.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.file?.name ?? it.relPath,
|
||||
size: it.size,
|
||||
relPath: it.relPath,
|
||||
sha256: it.sha256,
|
||||
tusUrl: it.tusUrl,
|
||||
uploadFileId: it.uploadFileId,
|
||||
}));
|
||||
try {
|
||||
if (pending.length) localStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
||||
else localStorage.removeItem(PENDING_KEY);
|
||||
} catch {
|
||||
// Приватный режим — докачка после перезагрузки недоступна.
|
||||
}
|
||||
}
|
||||
|
||||
private restore() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PENDING_KEY);
|
||||
if (!raw) return;
|
||||
const rows = JSON.parse(raw) as PendingUpload[];
|
||||
if (!Array.isArray(rows) || !rows.length) return;
|
||||
this.state = {
|
||||
...this.state,
|
||||
items: rows.map((r) => ({
|
||||
id: r.id,
|
||||
file: null,
|
||||
size: r.size,
|
||||
relPath: r.relPath,
|
||||
stage: 'resume' as const,
|
||||
progress: 0,
|
||||
sha256: r.sha256,
|
||||
tusUrl: r.tusUrl,
|
||||
uploadFileId: r.uploadFileId,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
localStorage.removeItem(PENDING_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Приложить заново те же файлы и продолжить tus с принятого смещения. */
|
||||
async continueWith(files: PickedFile[]) {
|
||||
const waiting = this.state.items.filter((it) => it.stage === 'resume' && it.tusUrl);
|
||||
if (!waiting.length) return;
|
||||
const queue: { id: string; url: string }[] = [];
|
||||
for (const picked of files) {
|
||||
const it = waiting.find((w) => w.size === picked.file.size && (w.relPath === picked.relPath || w.relPath.endsWith(picked.file.name)));
|
||||
if (!it) continue;
|
||||
this.patch(it.id, { file: picked.file, stage: 'hashing', progress: 0 });
|
||||
const sha = await this.hash(this.item(it.id)!);
|
||||
if (sha !== it.sha256) {
|
||||
this.patch(it.id, { stage: 'error', message: 'sha256' });
|
||||
continue;
|
||||
}
|
||||
this.patch(it.id, { stage: 'uploading', progress: 0 });
|
||||
queue.push({ id: it.id, url: it.tusUrl! });
|
||||
}
|
||||
if (!queue.length) return;
|
||||
this.set({ busy: true });
|
||||
try {
|
||||
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
||||
} finally {
|
||||
this.set({ busy: false });
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let instance: { session: AuthSession; uploader: Uploader } | null = null;
|
||||
|
||||
@@ -48,6 +48,10 @@ function deviceName(): string {
|
||||
}
|
||||
|
||||
applyTheme(undefined);
|
||||
if (import.meta.env.VITE_E2E === '1') {
|
||||
const { installE2EFetch } = await import('./test/e2eBridge');
|
||||
installE2EFetch();
|
||||
}
|
||||
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
|
||||
document.documentElement.lang = i18n.language;
|
||||
i18n.on('languageChanged', (l) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
@@ -45,6 +46,7 @@ export function AlbumPage({ id }: { id: string }) {
|
||||
)}
|
||||
{a && a.tracks.some((tr) => tr.available) && (
|
||||
<div className={c.heroActions}>
|
||||
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
|
||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { formatBytes } from '../shared/lib/format';
|
||||
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||
import { Icon } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/**
|
||||
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
|
||||
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
|
||||
*/
|
||||
export function AppDownloadPage() {
|
||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const latest = useQuery({
|
||||
queryKey: ['android-latest'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const server = location.origin;
|
||||
const copy = () =>
|
||||
void navigator.clipboard?.writeText(server).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
const r = latest.data;
|
||||
|
||||
return (
|
||||
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||
<span className={s.badge}>Android</span>
|
||||
<h1>{t('app.title')}</h1>
|
||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||
{latest.isPending ? null : r ? (
|
||||
<>
|
||||
<a className={s.download} href={r.download_url} download>
|
||||
<Icon src={downloadIcon} size={22} />
|
||||
<span>
|
||||
{t('app.download')}
|
||||
<span className={s.downloadMeta}>
|
||||
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<ol className={s.steps}>
|
||||
<li>{t('app.step_install')}</li>
|
||||
<li>
|
||||
{t('app.step_server')}
|
||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||
<code>{server}</code>
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
<li>{t('app.step_updates')}</li>
|
||||
</ol>
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{r.sha256}</code>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,8 @@ function WaveHome() {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const showActivities = user.ui_prefs.home_activities !== false;
|
||||
const showMoods = user.ui_prefs.home_moods !== false;
|
||||
const waveOn = st.wave !== null;
|
||||
const playing = waveOn && st.playing;
|
||||
const track = waveOn ? st.track : null;
|
||||
@@ -116,6 +118,7 @@ function WaveHome() {
|
||||
<WaveButton
|
||||
variant={user.ui_prefs.wave_variant ?? 'orb'}
|
||||
playing={playing}
|
||||
readAudio={player.readAudio}
|
||||
bpm={track?.features?.bpm ?? undefined}
|
||||
energy={track?.features?.energy ?? undefined}
|
||||
colors={colors}
|
||||
@@ -133,8 +136,9 @@ function WaveHome() {
|
||||
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
|
||||
<FormError>{error ? errorText(error) : null}</FormError>
|
||||
|
||||
{options.data && (
|
||||
{options.data && (showActivities || showMoods) && (
|
||||
<div className={s.picker}>
|
||||
{showActivities && (
|
||||
<Chips
|
||||
title={t('wave.activity_label')}
|
||||
options={options.data.activities}
|
||||
@@ -142,6 +146,8 @@ function WaveHome() {
|
||||
label={(k) => tr(`wave.activities.${k}`)}
|
||||
onPick={(k) => choose('activity', k)}
|
||||
/>
|
||||
)}
|
||||
{showMoods && (
|
||||
<Chips
|
||||
title={t('wave.mood_label')}
|
||||
options={options.data.moods}
|
||||
@@ -149,6 +155,7 @@ function WaveHome() {
|
||||
label={(k) => tr(`wave.moods.${k}`)}
|
||||
onPick={(k) => choose('mood', k)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,15 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
|
||||
<AuthLayout
|
||||
footer={
|
||||
info.data ? (
|
||||
<>
|
||||
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<h1>{t('login.title')}</h1>
|
||||
<p className={s.lead}>{t('login.subtitle')}</p>
|
||||
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
|
||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import c from './catalog.module.css';
|
||||
import s from './page.module.css';
|
||||
import p from './playlists.module.css';
|
||||
|
||||
export function PlaylistsPage() {
|
||||
const { t } = useTranslation();
|
||||
const list = usePlaylists();
|
||||
const errorText = useErrorText();
|
||||
const navigate = useNavigate();
|
||||
const [create, setCreate] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? [];
|
||||
return <div className={s.page}>
|
||||
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
||||
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
|
||||
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||
<Cover album={item} size="100%" />
|
||||
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||
{item.description && <span className={p.description}>{item.description}</span>}
|
||||
</Link>)}</div>
|
||||
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PlaylistPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const query = usePlaylist(id);
|
||||
const errorText = useErrorText();
|
||||
return <div className={s.page}>
|
||||
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
const player = usePlayer();
|
||||
const navigate = useNavigate();
|
||||
const errorText = useErrorText();
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
||||
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
|
||||
})), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); },
|
||||
onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })),
|
||||
onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } });
|
||||
const play = (shuffle: boolean) => {
|
||||
const tracks = playlist.tracks.filter(x => x.available);
|
||||
if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; }
|
||||
if (tracks.length) player.playList(tracks, 0, context);
|
||||
};
|
||||
const move = (index: number, delta: number) => {
|
||||
const ids = playlist.tracks.map(x => x.id);
|
||||
[ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!];
|
||||
save.mutate(ids);
|
||||
};
|
||||
return <>
|
||||
<header className={c.colHero}>
|
||||
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
|
||||
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
||||
<p className={p.description}>{playlist.description}</p>
|
||||
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className={s.toolbar}>
|
||||
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||||
</div>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
|
||||
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||||
actions={manage ? (track, i) => <span className={p.actions}>
|
||||
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||||
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
|
||||
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||||
</span> : undefined} />
|
||||
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
|
||||
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
|
||||
</>;
|
||||
}
|
||||
@@ -126,6 +126,55 @@ function AppearanceSection() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.search_position')}</span>
|
||||
<Segmented<'left' | 'center' | 'right'>
|
||||
label={t('settings.search_position')}
|
||||
value={user.ui_prefs.search_position ?? 'center'}
|
||||
onChange={(v) => void setPrefs({ search_position: v })}
|
||||
options={[
|
||||
{ value: 'left', label: t('settings.search_left') },
|
||||
{ value: 'center', label: t('settings.search_center') },
|
||||
{ value: 'right', label: t('settings.search_right') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.search_collapse')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.search_collapse')}
|
||||
value={user.ui_prefs.search_collapse === true ? 'on' : 'off'}
|
||||
onChange={(v) => void setPrefs({ search_collapse: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.home_activities')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.home_activities')}
|
||||
value={user.ui_prefs.home_activities === false ? 'off' : 'on'}
|
||||
onChange={(v) => void setPrefs({ home_activities: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.home_moods')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.home_moods')}
|
||||
value={user.ui_prefs.home_moods === false ? 'off' : 'on'}
|
||||
onChange={(v) => void setPrefs({ home_moods: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.ambient')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
@@ -138,6 +187,30 @@ function AppearanceSection() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.normalize')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.normalize')}
|
||||
value={user.ui_prefs.normalize === false ? 'off' : 'on'}
|
||||
onChange={(v) => void setPrefs({ normalize: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={6}
|
||||
step={1}
|
||||
aria-label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}
|
||||
value={user.ui_prefs.crossfade_s ?? 0}
|
||||
onChange={(e) => void setPrefs({ crossfade_s: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('language.label')}</span>
|
||||
<LanguageSwitch />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
|
||||
export function TrackPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const track = useQuery({
|
||||
queryKey: ['track', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
|
||||
});
|
||||
if (track.isError) return <NotFound error={track.error} />;
|
||||
const tr = track.data;
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
{tr && <Cover album={tr.album} size={200} />}
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('player.track_page')}</span>
|
||||
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
|
||||
{tr && <>
|
||||
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
|
||||
</>}
|
||||
</div>
|
||||
</header>
|
||||
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type Entry = components['schemas']['AuditEntry'];
|
||||
function details(e: Entry, roleName: (r: string) => string): string {
|
||||
const p = e.payload ?? {};
|
||||
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
|
||||
if (e.action === 'user.username_change') return `${String(p.from)} → ${String(p.to)}`;
|
||||
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
|
||||
(v): v is string => typeof v === 'string' && v !== '',
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -32,6 +32,11 @@ export function LibraryPage() {
|
||||
const reindex = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const retry = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
|
||||
});
|
||||
const d = status.data;
|
||||
const tiles = d
|
||||
? ([
|
||||
@@ -77,7 +82,19 @@ export function LibraryPage() {
|
||||
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
|
||||
})}
|
||||
/>
|
||||
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
|
||||
{d.analysis.failed > 0 && (
|
||||
<div className={l.retryRow}>
|
||||
<span className={l.scanError}>
|
||||
{t('library.analysis_failed', { count: d.analysis.failed })}
|
||||
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
||||
</span>
|
||||
{admin && (
|
||||
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
||||
{t('library.analysis_retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { needsFormatAttention } from '../../features/uploads/files';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
@@ -35,6 +36,7 @@ export function UploadQueuePage() {
|
||||
const [onlyPossible, setOnlyPossible] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [editing, setEditing] = useState<UploadFile | null>(null);
|
||||
const [batchEdit, setBatchEdit] = useState<string | null>(null);
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
@@ -131,6 +133,7 @@ export function UploadQueuePage() {
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
onEdit={setEditing}
|
||||
onEditBatch={setBatchEdit}
|
||||
onRetry={(id) => retry.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
@@ -158,6 +161,17 @@ export function UploadQueuePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchEdit && (
|
||||
<BatchEditDialog
|
||||
batchId={batchEdit}
|
||||
onClose={() => setBatchEdit(null)}
|
||||
onSaved={() => {
|
||||
setBatchEdit(null);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditDialog
|
||||
file={editing}
|
||||
@@ -194,6 +208,7 @@ function BatchCard({
|
||||
selected,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onEditBatch,
|
||||
onRetry,
|
||||
}: {
|
||||
files: UploadFile[];
|
||||
@@ -201,6 +216,7 @@ function BatchCard({
|
||||
selected: Set<string>;
|
||||
onToggle: (ids: string[], on: boolean) => void;
|
||||
onEdit: (f: UploadFile) => void;
|
||||
onEditBatch: (batchId: string) => void;
|
||||
onRetry: (id: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
@@ -218,6 +234,12 @@ function BatchCard({
|
||||
<span>{formatDateTime(first.created_at, i18n.language)}</span>
|
||||
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
|
||||
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{selectable && (
|
||||
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
||||
{t('uploads.edit_batch')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{files.map((f) => (
|
||||
<QueueRow
|
||||
@@ -288,7 +310,13 @@ function QueueRow({
|
||||
<div className={u.meta}>
|
||||
<div className={u.flags}>
|
||||
<UploadStatusBadge status={f.status} />
|
||||
{needsFormatAttention(f.format, f.original_name) && (
|
||||
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
|
||||
{t('uploads.flag_attention')}
|
||||
</span>
|
||||
)}
|
||||
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
|
||||
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
|
||||
{f.matched_album_id ? (
|
||||
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
|
||||
) : (
|
||||
@@ -302,6 +330,9 @@ function QueueRow({
|
||||
)}
|
||||
</div>
|
||||
<div className={u.rowActions}>
|
||||
{f.stream_url && (
|
||||
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
|
||||
)}
|
||||
{editable && (
|
||||
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
||||
<Icon src={editIcon} size={18} />
|
||||
@@ -319,6 +350,75 @@ function QueueRow({
|
||||
|
||||
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
|
||||
|
||||
function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const [artist, setArtist] = useState('');
|
||||
const [album, setAlbum] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [cover, setCover] = useState<string | null | undefined>(undefined);
|
||||
const save = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', {
|
||||
params: { path: { id: batchId } },
|
||||
body: {
|
||||
...(artist.trim() ? { artist: artist.trim() } : {}),
|
||||
...(album.trim() ? { album: album.trim() } : {}),
|
||||
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
|
||||
...(cover !== undefined ? { cover_base64: cover } : {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
const pickCover = (file: File | undefined) => {
|
||||
if (!file) {
|
||||
setCover(undefined);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const data = String(reader.result ?? '');
|
||||
const comma = data.indexOf(',');
|
||||
setCover(comma >= 0 ? data.slice(comma + 1) : data);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
title={t('uploads.batch_edit_title')}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('uploads.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={s.sessions}>
|
||||
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
|
||||
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
|
||||
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
|
||||
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.batch_cover')}</span>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
|
||||
</label>
|
||||
<Button variant="ghost" onClick={() => setCover(null)}>
|
||||
{t('uploads.batch_cover_clear')}
|
||||
</Button>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
@@ -452,7 +552,7 @@ export function UploadSettingsSection() {
|
||||
queryKey: ['sources'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
||||
});
|
||||
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null);
|
||||
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
if (settings.data) setForm(toForm(settings.data));
|
||||
}, [settings.data]);
|
||||
@@ -468,6 +568,7 @@ export function UploadSettingsSection() {
|
||||
max_file_bytes: gb(f.maxFile),
|
||||
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
|
||||
default_source_id: f.source || null,
|
||||
allow_quality_upgrade: f.upgrade,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -484,7 +585,7 @@ export function UploadSettingsSection() {
|
||||
return (
|
||||
<section className={s.section}>
|
||||
<h2>{t('uploads.settings_title')}</h2>
|
||||
<div className={u.grid}>
|
||||
<div className={u.settingsGrid}>
|
||||
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
|
||||
<Field
|
||||
label={t('uploads.set_per_user')}
|
||||
@@ -494,8 +595,8 @@ export function UploadSettingsSection() {
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.set_source')}</span>
|
||||
<label>
|
||||
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
|
||||
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
|
||||
<option value="">{t('uploads.set_source_auto')}</option>
|
||||
{sources.data
|
||||
@@ -507,6 +608,12 @@ export function UploadSettingsSection() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
<label className={u.settingsCheck}>
|
||||
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
|
||||
<span>{t('uploads.set_upgrade')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className={s.note}>{t('uploads.set_note')}</p>
|
||||
<div className={s.toolbar}>
|
||||
@@ -528,5 +635,6 @@ function toForm(st: Settings) {
|
||||
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
|
||||
maxFile: gb(st.max_file_bytes),
|
||||
source: st.default_source_id ?? '',
|
||||
upgrade: st.allow_quality_upgrade,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useSession, useUser } from '../../app/session';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatRelative } from '../../shared/lib/format';
|
||||
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons';
|
||||
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
|
||||
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
|
||||
@@ -16,7 +16,9 @@ type AdminUser = components['schemas']['AdminUser'];
|
||||
|
||||
const ROLES: Role[] = ['admin', 'moderator', 'user'];
|
||||
|
||||
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser };
|
||||
const TITLES = { reset: 'reset_password', recs: 'reset_recommendations', block: 'block', delete: 'delete' } as const;
|
||||
|
||||
type Confirm = { kind: 'reset' | 'recs' | 'block' | 'delete'; user: AdminUser };
|
||||
|
||||
function useDebounced<T>(value: T, ms: number): T {
|
||||
const [v, setV] = useState(value);
|
||||
@@ -44,9 +46,11 @@ export function UsersPage() {
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
const query = useDebounced(q.trim(), 200);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState<AdminUser | null>(null);
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
const [recsDone, setRecsDone] = useState<string | null>(null);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['admin-users', query, role],
|
||||
@@ -79,6 +83,14 @@ export function UsersPage() {
|
||||
},
|
||||
onError: setActionError,
|
||||
});
|
||||
const resetRecs = useMutation({
|
||||
mutationFn: async (u: AdminUser) =>
|
||||
unwrap(
|
||||
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: u.id } } }),
|
||||
),
|
||||
onSuccess: (_, u) => setRecsDone(u.username),
|
||||
onError: setActionError,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: async (u: AdminUser) =>
|
||||
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
|
||||
@@ -91,7 +103,9 @@ export function UsersPage() {
|
||||
const { kind, user } = confirm;
|
||||
setConfirm(null);
|
||||
setActionError(null);
|
||||
setRecsDone(null);
|
||||
if (kind === 'reset') reset.mutate(user);
|
||||
if (kind === 'recs') resetRecs.mutate(user);
|
||||
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
|
||||
if (kind === 'delete') remove.mutate(user);
|
||||
};
|
||||
@@ -99,8 +113,16 @@ export function UsersPage() {
|
||||
const actionsFor = (u: AdminUser): MenuItem[] => {
|
||||
const items: MenuItem[] = [];
|
||||
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
||||
if (me.role === 'admin')
|
||||
items.push({ label: t('users.change_username'), onSelect: () => setRenaming(u) });
|
||||
if (can('reset_password'))
|
||||
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
|
||||
if (can('reset_recommendations'))
|
||||
items.push({
|
||||
label: t('users.reset_recommendations'),
|
||||
icon: refreshIcon,
|
||||
onSelect: () => setConfirm({ kind: 'recs', user: u }),
|
||||
});
|
||||
if (can('block'))
|
||||
items.push(
|
||||
u.is_blocked
|
||||
@@ -113,6 +135,11 @@ export function UsersPage() {
|
||||
label: t('users.make_role', { role: t(`common:roles.${r}`) }),
|
||||
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
|
||||
});
|
||||
if (me.role === 'admin')
|
||||
items.push({
|
||||
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
|
||||
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
|
||||
});
|
||||
if (can('delete'))
|
||||
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
|
||||
return items;
|
||||
@@ -154,9 +181,10 @@ export function UsersPage() {
|
||||
</div>
|
||||
|
||||
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
|
||||
{recsDone && <p className={s.ok}>{t('users.recs_done', { username: recsDone })}</p>}
|
||||
|
||||
<div className={s.tableWrap}>
|
||||
<table className={s.table}>
|
||||
<table className={cx(s.table, s.mobileCards)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('users.col_user')}</th>
|
||||
@@ -172,7 +200,7 @@ export function UsersPage() {
|
||||
const st = status(u);
|
||||
return (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<td data-label={t('users.col_user')}>
|
||||
<div className={s.userCell}>
|
||||
<Avatar name={u.display_name ?? u.username} />
|
||||
<div>
|
||||
@@ -184,16 +212,17 @@ export function UsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label={t('users.col_role')}>
|
||||
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
|
||||
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label={t('users.col_status')}>
|
||||
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
|
||||
</td>
|
||||
<td className={s.muted}>
|
||||
<td className={s.muted} data-label={t('users.col_last_login')}>
|
||||
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
|
||||
</td>
|
||||
<td>
|
||||
<td className={s.cardAction}>
|
||||
{items.length > 0 && (
|
||||
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
|
||||
)}
|
||||
@@ -224,16 +253,28 @@ export function UsersPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{renaming && (
|
||||
<ChangeUsernameDialog
|
||||
user={renaming}
|
||||
onClose={() => setRenaming(null)}
|
||||
onSaved={async (updated) => {
|
||||
if (updated.id === me.id) session.setUser({ ...me, username: updated.username });
|
||||
setRenaming(null);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirm && (
|
||||
<Dialog
|
||||
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : confirm.kind}`)}
|
||||
title={t(`users.${TITLES[confirm.kind]}`)}
|
||||
onClose={() => setConfirm(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirm(null)}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant={confirm.kind === 'reset' ? 'primary' : 'danger'} onClick={runConfirm}>
|
||||
<Button variant={confirm.kind === 'block' || confirm.kind === 'delete' ? 'danger' : 'primary'} onClick={runConfirm}>
|
||||
{t('users.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
@@ -265,12 +306,18 @@ function CreateUserDialog({
|
||||
const [username, setUsername] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [role, setRole] = useState<Role>('user');
|
||||
const [reviews, setReviews] = useState(false);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.POST('/api/v1/admin/users', {
|
||||
body: { username: username.trim(), display_name: name.trim() || undefined, role },
|
||||
body: {
|
||||
username: username.trim(),
|
||||
display_name: name.trim() || undefined,
|
||||
role,
|
||||
reviews_uploads: actorRole === 'admin' && reviews ? true : undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
|
||||
@@ -322,6 +369,13 @@ function CreateUserDialog({
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{actorRole === 'admin' && (
|
||||
<label className={s.sessions} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={reviews} onChange={(e) => setReviews(e.target.checked)} />
|
||||
<span>{t('create.review_uploads')}</span>
|
||||
</label>
|
||||
)}
|
||||
{actorRole === 'admin' && <p className={s.note}>{t('create.review_uploads_hint')}</p>}
|
||||
<p className={s.note}>{t('create.password_note')}</p>
|
||||
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
|
||||
</form>
|
||||
@@ -329,6 +383,40 @@ function CreateUserDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeUsernameDialog({ user, onClose, onSaved }: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
onSaved: (user: AdminUser) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const fieldError = useFieldError();
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', {
|
||||
params: { path: { id: user.id } }, body: { username: username.trim() },
|
||||
})),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
const error = fieldError(save.error, 'username');
|
||||
return (
|
||||
<Dialog title={t('users.change_username')} onClose={onClose} footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common:form.cancel')}</Button>
|
||||
<Button type="submit" form="change-username" variant="primary" busy={save.isPending}
|
||||
disabled={!username.trim() || username.trim() === user.username}>{t('common:settings.save')}</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="change-username" className={s.sessions} noValidate onSubmit={(e) => { e.preventDefault(); save.mutate(); }}>
|
||||
<Field label={t('create.username')} value={username} onChange={setUsername} autoCapitalize="none"
|
||||
spellCheck={false} hint={t('create.username_hint')} error={error} mono />
|
||||
<FormError>{save.error && !error ? errorText(save.error) : null}</FormError>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -118,6 +118,20 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.retryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.errorDetail {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text3);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
@@ -306,3 +320,14 @@
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card { padding: 16px; }
|
||||
.cardActions > button { flex: 1 1 auto; }
|
||||
.retryRow { flex-wrap: wrap; gap: 10px; }
|
||||
.patternRow { grid-template-columns: 20px minmax(0, 1fr); }
|
||||
.patternTools { grid-column: 2; margin-top: 0 !important; height: auto; flex-wrap: wrap; }
|
||||
.patternTools .check { flex-basis: 100%; min-height: 44px; }
|
||||
.patternTools > button { width: 44px; min-width: 44px; padding: 0; }
|
||||
.dir { min-height: 44px; height: auto; padding: 8px; overflow-wrap: anywhere; }
|
||||
}
|
||||
@@ -161,6 +161,83 @@
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
/* Страница Android-приложения (/android). */
|
||||
.download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.download:hover {
|
||||
background: var(--accent-fill2);
|
||||
}
|
||||
|
||||
.download:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.downloadMeta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.steps {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.server {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details code {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
word-break: break-all;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -218,3 +218,10 @@
|
||||
height: 44px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.heroText { min-width: 0; }
|
||||
.colTitle { font-size: clamp(24px, 7vw, 34px); overflow-wrap: anywhere; }
|
||||
.underlineTabs { overflow-x: auto; }
|
||||
.underlineTab { flex-shrink: 0; min-height: 44px; }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
.home {
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: clip;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -7,6 +9,18 @@
|
||||
gap: 28px;
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
user-select: none; /* главная — только элементы управления волной */
|
||||
}
|
||||
|
||||
/* Плеер на всю ширину окна, а главная — в колонке справа от сайдбара.
|
||||
Сдвиг на половину сайдбара ставит волну на ту же ось, что и кнопка play.
|
||||
Если чипам не хватает места, сдвиг меньше, чтобы они не уехали под меню. */
|
||||
@media (min-width: 768px) {
|
||||
.home {
|
||||
transform: translateX(
|
||||
calc(-1 * min(var(--side-w, 240px) / 2, max(0px, (100% - 760px) / 2)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.orbLabel {
|
||||
@@ -95,3 +109,9 @@
|
||||
.chipOn > span {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.home { justify-content: flex-start; gap: 20px; padding: 8px 0 20px; }
|
||||
.chip { min-height: 44px; height: auto; padding: 10px 14px; }
|
||||
.orbLabel { font-size: clamp(18px, 5vw, 22px); }
|
||||
}
|
||||
@@ -341,3 +341,29 @@
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page, .section, .head > *, .toolbar > *, .settingRow > * { min-width: 0; max-width: 100%; }
|
||||
.page { gap: 20px; overflow-wrap: anywhere; }
|
||||
.head h1 { font-size: 24px; }
|
||||
.section { padding: 16px; }
|
||||
.row > :first-child, .search { min-width: 0; flex-basis: 100%; max-width: none; }
|
||||
.settingRow { align-items: flex-start; gap: 10px; }
|
||||
.settingRow > :first-child { flex-basis: 100%; }
|
||||
.actions { flex-wrap: wrap; justify-content: flex-start; }
|
||||
.actions > * { flex: 1 1 auto; }
|
||||
.chip { min-height: 44px; height: auto; padding: 8px 14px; }
|
||||
.select { max-width: 100%; min-width: 0; font-size: 16px; }
|
||||
.session { flex-wrap: wrap; overflow-wrap: anywhere; }
|
||||
.tableWrap { min-width: 0; max-width: 100%; }
|
||||
.mobileCards, .mobileCards tbody { display: block; }
|
||||
.mobileCards thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
.mobileCards tr { display: flex; flex-direction: column; padding: 14px; border-bottom: 1px solid var(--line); gap: 12px; }
|
||||
.mobileCards tr:last-child { border-bottom: 0; }
|
||||
.mobileCards td { display: flex; flex-wrap: wrap; gap: 6px 10px; height: auto; padding: 0; border: 0; white-space: normal; }
|
||||
.mobileCards td::before { content: attr(data-label); flex-basis: 100%; font-size: 12px; color: var(--text3); }
|
||||
.mobileCards .userCell { min-width: 0; }
|
||||
.mobileCards .userCell > div { min-width: 0; overflow-wrap: anywhere; }
|
||||
.mobileCards .cardAction { align-self: flex-end; }
|
||||
.cardAction::before { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
|
||||
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
|
||||
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
|
||||
.card strong { overflow-wrap: anywhere; }
|
||||
.card span { font-size: 13px; }
|
||||
.card:hover { background: #ffffff10; }
|
||||
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.actions { display: flex; align-items: center; }
|
||||
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
|
||||
.actions button:disabled { opacity: .3; cursor: default; }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
|
||||
@@ -1,7 +1,7 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
@@ -31,6 +31,12 @@ export function MyUploadsPage() {
|
||||
const errorText = useErrorText();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
useEffect(() => {
|
||||
void session.client.POST('/api/v1/uploads/seen').then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['uploads-unseen'] });
|
||||
});
|
||||
}, [session, queryClient]);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['my-uploads', filter],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore, type DragEvent } fro
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { AUDIO_EXTENSIONS, fromDrop, fromInput } from '../../features/uploads/files';
|
||||
import { AUDIO_EXTENSIONS, fromDrop, fromInput, needsFormatAttention } from '../../features/uploads/files';
|
||||
import { uploaderFor, type UploadItem } from '../../features/uploads/uploader';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { formatBytes } from '../../shared/lib/format';
|
||||
@@ -39,9 +39,10 @@ export function SuggestPage() {
|
||||
}, []);
|
||||
|
||||
const ready = st.items.filter((it) => it.stage === 'ready');
|
||||
const readyBytes = ready.reduce((n, it) => n + it.file.size, 0);
|
||||
const readyBytes = ready.reduce((n, it) => n + it.size, 0);
|
||||
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
|
||||
const finished = !st.busy && st.batchId !== null;
|
||||
const resume = st.items.filter((it) => it.stage === 'resume').length;
|
||||
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
|
||||
const uploaded = st.items.filter((it) => it.stage === 'done').length;
|
||||
const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null;
|
||||
@@ -80,7 +81,8 @@ export function SuggestPage() {
|
||||
>
|
||||
<Icon src={musicIcon} size={36} />
|
||||
<div className={u.dropTitle}>{t('uploads.drop_title')}</div>
|
||||
<div className={s.note}>{t('uploads.drop_hint', { formats: AUDIO_EXTENSIONS.join(', ') })}</div>
|
||||
<p className={u.formatWarn}>{t('uploads.drop_hint')}</p>
|
||||
<div className={s.note}>{t('uploads.drop_folders')}</div>
|
||||
<div className={u.dropActions}>
|
||||
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
|
||||
{t('uploads.pick_files')}
|
||||
@@ -96,7 +98,9 @@ export function SuggestPage() {
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
onChange={(e) => {
|
||||
uploader.add(fromInput(e.target.files));
|
||||
const picked = fromInput(e.target.files);
|
||||
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
|
||||
else uploader.add(picked);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
@@ -106,12 +110,20 @@ export function SuggestPage() {
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
uploader.add(fromInput(e.target.files));
|
||||
const picked = fromInput(e.target.files);
|
||||
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
|
||||
else uploader.add(picked);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{resume > 0 && (
|
||||
<section className={s.section}>
|
||||
<p>{t('uploads.resume_hint', { count: resume })}</p>
|
||||
<Button onClick={() => filesRef.current?.click()}>{t('uploads.resume_pick')}</Button>
|
||||
</section>
|
||||
)}
|
||||
{st.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>}
|
||||
|
||||
{st.items.length > 0 && (
|
||||
@@ -213,9 +225,10 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
|
||||
<span style={{ width: `${item.progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{needsFormatAttention(null, item.relPath) && <span className={u.warn}>{t('uploads.format_attention')}</span>}
|
||||
{note && <span className={note.cls}>{note.text}</span>}
|
||||
</div>
|
||||
<span className={u.size}>{formatBytes(item.file.size, i18n.language)}</span>
|
||||
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
|
||||
{locked ? (
|
||||
<span />
|
||||
) : (
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.formatWarn {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.dropActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -230,6 +239,48 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Настройки очереди: подпись, поле и подсказка каждой ячейки — в общих строках, чтобы поля стояли на одном
|
||||
уровне, даже когда подпись переносится. */
|
||||
.settingsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
column-gap: 12px;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.settingsGrid > * {
|
||||
display: grid;
|
||||
grid-row: span 3;
|
||||
grid-template-rows: subgrid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settingsGrid select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settingsGrid > * > :first-child {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.settingsLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.settingsCheck {
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
@@ -269,3 +320,22 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.queueRow { grid-template-columns: 24px minmax(0, 1fr); gap: 10px; padding: 12px; }
|
||||
.queueRow > :nth-child(3), .queueRow > .rowActions { grid-column: 2; }
|
||||
.rowActions { flex-wrap: wrap; min-width: 0; }
|
||||
.rowActions audio { width: 100%; min-width: 0; max-width: 100%; }
|
||||
.meta { overflow-wrap: anywhere; }
|
||||
.bulk { bottom: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.bulk > span:nth-child(2) { display: none; }
|
||||
.bulk > button { min-width: 0; font-size: 13px; padding: 10px 8px; }
|
||||
.grid, .settingsGrid { grid-template-columns: minmax(0, 1fr); }
|
||||
.drop { padding: 20px 12px; }
|
||||
.dropActions > * { flex: 1 1 auto; }
|
||||
.quota { min-width: 0; max-width: 100%; }
|
||||
.file { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.file > :last-child { grid-column: 2; grid-row: 1; }
|
||||
.file > .size { grid-column: 1; }
|
||||
.textarea { font-size: 16px; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AuthSession } from '@pipu/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Player } from './player';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function fixture() {
|
||||
const player = new Player({} as AuthSession, document.createElement('audio'));
|
||||
const sample = vi.fn((data: Uint8Array) => {
|
||||
data.fill(0);
|
||||
// 48 kHz / 1024: bins 1..4 cover the bass band.
|
||||
data.fill(255, 1, 5);
|
||||
});
|
||||
Object.assign(player, {
|
||||
state: { ...player.getState(), playing: true },
|
||||
analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample },
|
||||
});
|
||||
return { player, sample };
|
||||
}
|
||||
|
||||
describe('audio reactivity', () => {
|
||||
it('separates bass from the other frequency bands and shares the frame', () => {
|
||||
vi.spyOn(performance, 'now').mockReturnValue(100);
|
||||
const { player, sample } = fixture();
|
||||
expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 });
|
||||
player.readAudio();
|
||||
expect(sample).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns silence after pausing or muting', () => {
|
||||
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
|
||||
const { player, sample } = fixture();
|
||||
player.readAudio();
|
||||
Object.assign(player, { state: { ...player.getState(), playing: false } });
|
||||
clock.mockReturnValue(120);
|
||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } });
|
||||
clock.mockReturnValue(140);
|
||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
expect(sample).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works when Web Audio is unavailable', () => {
|
||||
const player = new Player({} as AuthSession, document.createElement('audio'));
|
||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
});
|
||||
});
|
||||
+196
-11
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
EventBuffer,
|
||||
normalizeGain,
|
||||
PlayAttempt,
|
||||
PlayQueue,
|
||||
unwrap,
|
||||
@@ -80,6 +81,9 @@ function deviceId(): string {
|
||||
*/
|
||||
export class Player {
|
||||
private readonly audio: HTMLAudioElement;
|
||||
private readonly overlap: HTMLAudioElement;
|
||||
private fading = false;
|
||||
private ignoreEnded = false;
|
||||
private readonly queue = new PlayQueue();
|
||||
private readonly events: EventBuffer;
|
||||
private attempt: PlayAttempt | null = null;
|
||||
@@ -93,6 +97,7 @@ export class Player {
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||
private waveLoading = false;
|
||||
private restoreVersion = 0;
|
||||
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||
onReaction: (() => void) | null = null;
|
||||
|
||||
@@ -102,6 +107,8 @@ export class Player {
|
||||
) {
|
||||
this.audio = audio ?? new Audio();
|
||||
this.audio.preload = 'auto';
|
||||
this.overlap = new Audio();
|
||||
this.overlap.preload = 'auto';
|
||||
this.events = new EventBuffer({
|
||||
send: async (events) => {
|
||||
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||
@@ -153,6 +160,7 @@ export class Player {
|
||||
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||
const target = tracks[index];
|
||||
if (!target || !playable.length) return;
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
this.context = context;
|
||||
@@ -165,6 +173,7 @@ export class Player {
|
||||
|
||||
/** Запустить волну (или перезапустить с новым контекстом). */
|
||||
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
const res = unwrap(
|
||||
@@ -269,10 +278,11 @@ export class Player {
|
||||
|
||||
play() {
|
||||
if (!this.queue.current) return;
|
||||
if (!this.audio.src) {
|
||||
if (!this.audio.src || !this.attempt) {
|
||||
void this.load(true, this.state.positionMs);
|
||||
return;
|
||||
}
|
||||
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
|
||||
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
|
||||
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
|
||||
}
|
||||
@@ -324,15 +334,17 @@ export class Player {
|
||||
}
|
||||
|
||||
setVolume(v: number) {
|
||||
this.audio.volume = Math.min(Math.max(v, 0), 1);
|
||||
const volume = Math.min(Math.max(v, 0), 1);
|
||||
this.audio.muted = false;
|
||||
this.update({ volume: this.audio.volume, muted: false });
|
||||
this.update({ volume, muted: false });
|
||||
this.applyLoudness();
|
||||
this.saveLocal();
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.audio.muted = !this.audio.muted;
|
||||
this.update({ muted: this.audio.muted });
|
||||
this.applyLoudness();
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
@@ -379,9 +391,13 @@ export class Player {
|
||||
private async load(autoplay: boolean, startMs = 0) {
|
||||
const track = this.queue.current;
|
||||
if (!track?.stream_url) return;
|
||||
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||
this.audio.src = track.stream_url;
|
||||
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
||||
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||
// Keep the restored element's buffer when the user presses Play.
|
||||
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
|
||||
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
||||
this.updateMediaMetadata(track);
|
||||
if (!autoplay) return;
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(track, startMs);
|
||||
this.preloaded = null;
|
||||
this.events.push({
|
||||
@@ -391,11 +407,11 @@ export class Player {
|
||||
context_type: this.context?.type,
|
||||
context_id: this.context?.id,
|
||||
});
|
||||
this.updateMediaMetadata(track);
|
||||
this.saveServer();
|
||||
void this.refillWave();
|
||||
if (autoplay) {
|
||||
try {
|
||||
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
|
||||
await this.audio.play();
|
||||
} catch {
|
||||
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||
@@ -429,6 +445,151 @@ export class Player {
|
||||
}
|
||||
}
|
||||
|
||||
private prefs() {
|
||||
const user = this.session.state.status === 'signed_in' ? this.session.state.user : null;
|
||||
return user?.ui_prefs;
|
||||
}
|
||||
|
||||
private normalizeOn() {
|
||||
return this.prefs()?.normalize !== false;
|
||||
}
|
||||
|
||||
private crossfadeSeconds() {
|
||||
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
|
||||
}
|
||||
|
||||
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
|
||||
private gain: GainNode | null = null;
|
||||
private wired = false;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private audioContext: AudioContext | null = null;
|
||||
private spectrum = new Uint8Array(512);
|
||||
private audioFrame = { bass: 0, mid: 0, treble: 0 };
|
||||
private sampledAt = -Infinity;
|
||||
|
||||
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
|
||||
readAudio = () => {
|
||||
const now = performance.now();
|
||||
if (now - this.sampledAt < 12) return this.audioFrame;
|
||||
this.sampledAt = now;
|
||||
const active = this.state.playing && !this.state.loading && !this.state.muted;
|
||||
const analyser = this.analyser;
|
||||
if (!active || !analyser) {
|
||||
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
|
||||
return this.audioFrame;
|
||||
}
|
||||
analyser.getByteFrequencyData(this.spectrum);
|
||||
const band = (low: number, high: number) => {
|
||||
const hz = analyser.context.sampleRate / analyser.fftSize;
|
||||
const start = Math.max(1, Math.ceil(low / hz));
|
||||
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
|
||||
let sum = 0;
|
||||
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
|
||||
return Math.sqrt(sum / Math.max(1, end - start));
|
||||
};
|
||||
this.audioFrame.bass = band(40, 220);
|
||||
this.audioFrame.mid = band(220, 2500);
|
||||
this.audioFrame.treble = band(2500, 10000);
|
||||
return this.audioFrame;
|
||||
};
|
||||
|
||||
|
||||
private applyLoudness(mix = 1) {
|
||||
const track = this.state.track;
|
||||
const user = this.state.muted ? 0 : this.state.volume;
|
||||
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
|
||||
if (!this.wired && typeof AudioContext !== 'undefined') {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
this.audioContext = ctx;
|
||||
const src = ctx.createMediaElementSource(this.audio);
|
||||
this.gain = ctx.createGain();
|
||||
src.connect(this.gain);
|
||||
this.gain.connect(ctx.destination);
|
||||
this.analyser = ctx.createAnalyser();
|
||||
this.analyser.fftSize = 1024;
|
||||
this.analyser.smoothingTimeConstant = 0.55;
|
||||
this.gain.connect(this.analyser);
|
||||
// The overlap is already audible through its own output; analyse it only.
|
||||
try {
|
||||
const overlapSource = ctx.createMediaElementSource(this.overlap);
|
||||
overlapSource.connect(ctx.destination);
|
||||
overlapSource.connect(this.analyser);
|
||||
} catch { /* Crossfade analysis is optional. */ }
|
||||
|
||||
this.audio.volume = 1;
|
||||
this.wired = true;
|
||||
} catch {
|
||||
this.wired = true;
|
||||
}
|
||||
}
|
||||
if (this.gain) this.gain.gain.value = level;
|
||||
else this.audio.volume = Math.min(1, level);
|
||||
}
|
||||
|
||||
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
||||
private maybeCrossfade(positionMs: number) {
|
||||
const cf = this.crossfadeSeconds();
|
||||
if (this.fading || cf <= 0 || this.state.repeat === 'one') return;
|
||||
const dur = this.audio.duration;
|
||||
if (!Number.isFinite(dur)) return;
|
||||
const left = dur * 1000 - positionMs;
|
||||
if (left <= 0 || left > cf * 1000) return;
|
||||
const next = this.state.queue[this.state.index + 1];
|
||||
if (!next?.stream_url || !next.available) return;
|
||||
this.fading = true;
|
||||
this.overlap.src = next.stream_url;
|
||||
this.overlap.volume = 0;
|
||||
void this.overlap.play().catch(() => {
|
||||
this.fading = false;
|
||||
});
|
||||
const started = performance.now();
|
||||
const step = () => {
|
||||
if (!this.fading) return;
|
||||
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
|
||||
this.applyLoudness(1 - p);
|
||||
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
|
||||
if (p < 1) {
|
||||
requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
this.finishCrossfade(next);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
private finishCrossfade(next: Track) {
|
||||
const at = this.overlap.currentTime;
|
||||
this.ignoreEnded = true;
|
||||
this.audio.pause();
|
||||
this.overlap.pause();
|
||||
this.finishAttempt('play_end');
|
||||
if (!this.queue.next(true)) {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
this.update({ playing: false, positionMs: 0 });
|
||||
return;
|
||||
}
|
||||
this.syncQueue();
|
||||
const src = next.stream_url;
|
||||
if (!src) {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
this.update({ playing: false });
|
||||
return;
|
||||
}
|
||||
this.audio.src = src;
|
||||
if (at > 0) this.audio.currentTime = at;
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
|
||||
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
|
||||
this.updateMediaMetadata(next);
|
||||
void this.audio.play().finally(() => {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
});
|
||||
}
|
||||
|
||||
private bindAudio() {
|
||||
const a = this.audio;
|
||||
a.addEventListener('playing', () => {
|
||||
@@ -441,20 +602,28 @@ export class Player {
|
||||
if (a.ended) return;
|
||||
this.update({ playing: false });
|
||||
const t = this.queue.current;
|
||||
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||
if (t && this.attempt) {
|
||||
this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||
this.saveServer();
|
||||
}
|
||||
});
|
||||
a.addEventListener('waiting', () => {
|
||||
if (this.attempt) this.update({ loading: true });
|
||||
});
|
||||
a.addEventListener('waiting', () => this.update({ loading: true }));
|
||||
a.addEventListener('timeupdate', () => {
|
||||
const ms = Math.round(a.currentTime * 1000);
|
||||
this.attempt?.tick(ms);
|
||||
this.update({ positionMs: ms });
|
||||
this.updatePositionState();
|
||||
if (this.state.playing) {
|
||||
this.maybePreload(ms);
|
||||
this.maybeCrossfade(ms);
|
||||
}
|
||||
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||
this.saveLocal();
|
||||
});
|
||||
a.addEventListener('ended', () => {
|
||||
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
||||
this.finishAttempt('play_end');
|
||||
if (this.queue.next(true)) {
|
||||
this.syncQueue();
|
||||
@@ -465,18 +634,24 @@ export class Player {
|
||||
}
|
||||
});
|
||||
a.addEventListener('error', () => void this.onError());
|
||||
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
|
||||
}
|
||||
|
||||
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||
private async onError() {
|
||||
const track = this.queue.current;
|
||||
if (!track) return;
|
||||
// A speculative load must never start playback or skip the saved track.
|
||||
if (!this.attempt) {
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
return;
|
||||
}
|
||||
if (this.refreshedFor !== track.id) {
|
||||
this.refreshedFor = track.id;
|
||||
try {
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
||||
const fresh = res.items[0];
|
||||
if (this.queue.current !== track || !this.attempt) return;
|
||||
if (fresh?.stream_url) {
|
||||
Object.assign(track, fresh);
|
||||
void this.load(true, this.state.positionMs);
|
||||
@@ -618,6 +793,7 @@ export class Player {
|
||||
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
||||
*/
|
||||
async restore() {
|
||||
const version = ++this.restoreVersion;
|
||||
let local: Saved | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_KEY);
|
||||
@@ -647,8 +823,9 @@ export class Player {
|
||||
} catch {
|
||||
// офлайн — остаёмся с локальной копией
|
||||
}
|
||||
if (!saved?.queue.length) return;
|
||||
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||
if (version !== this.restoreVersion) return;
|
||||
const wanted = saved.queue[saved.index];
|
||||
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||
if (!tracks.length) return;
|
||||
@@ -662,12 +839,20 @@ export class Player {
|
||||
this.syncQueue();
|
||||
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||
this.updateMediaMetadata(tracks[idx]!);
|
||||
// Prepare at the saved position without creating a listening attempt.
|
||||
await this.load(false, pos);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.restoreVersion++;
|
||||
this.fading = false;
|
||||
this.finishAttempt('skip');
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.overlap.pause();
|
||||
this.overlap.removeAttribute('src');
|
||||
this.overlap.load();
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
this.timers = [];
|
||||
void this.events.flush();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { AuthSession, Track } from '@pipu/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Player } from './player';
|
||||
|
||||
const track: Track = {
|
||||
id: 'a', title: 'A', duration_ms: 200_000, artists: [],
|
||||
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [], disc_no: null, track_no: null, available: true,
|
||||
stream_url: '/s/a', reaction: null, features: null,
|
||||
};
|
||||
const ok = (data: unknown) => ({ data, response: new Response() });
|
||||
|
||||
function fixture() {
|
||||
localStorage.removeItem('pipu.player');
|
||||
const client = {
|
||||
GET: vi.fn().mockResolvedValue(ok({
|
||||
track_id: 'a', queue: ['a'], queue_index: 0, position_ms: 42_000,
|
||||
volume: 0.8, shuffle: false, repeat_mode: 'off', updated_at: '2026-10-21',
|
||||
})),
|
||||
POST: vi.fn().mockResolvedValue(ok({ items: [track] })),
|
||||
PUT: vi.fn().mockResolvedValue(ok({})),
|
||||
};
|
||||
const audio = document.createElement('audio');
|
||||
const player = new Player({ client, state: { status: 'signed_out' } } as unknown as AuthSession, audio);
|
||||
return { player, audio, client };
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
|
||||
|
||||
describe('restored audio preparation', () => {
|
||||
it('buffers at the saved position without playback or listening events, then reuses the source', async () => {
|
||||
const { player, audio, client } = fixture();
|
||||
const play = vi.spyOn(audio, 'play');
|
||||
const source = vi.spyOn(audio, 'src', 'set');
|
||||
await player.restore();
|
||||
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||
expect(audio.currentTime).toBe(42);
|
||||
expect(audio.preload).toBe('auto');
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(client.PUT).not.toHaveBeenCalled();
|
||||
expect(player.getState()).toMatchObject({ playing: false, loading: false, positionMs: 42_000 });
|
||||
player.onHide();
|
||||
expect(client.POST).toHaveBeenCalledTimes(1); // Only the track lookup, no listening events.
|
||||
player.play();
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
expect(source).toHaveBeenCalledTimes(1);
|
||||
expect(player.getState().playing).toBe(true);
|
||||
player.dispose();
|
||||
});
|
||||
|
||||
it('does not autoplay or skip after a preload failure; Play retries the source', async () => {
|
||||
const { player, audio } = fixture();
|
||||
const play = vi.spyOn(audio, 'play');
|
||||
await player.restore();
|
||||
audio.dispatchEvent(new Event('error'));
|
||||
audio.dispatchEvent(new Event('ended'));
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(player.getState()).toMatchObject({ track: { id: 'a' }, positionMs: 42_000, playing: false });
|
||||
player.play();
|
||||
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
player.dispose();
|
||||
});
|
||||
|
||||
it.each(['selection', 'dispose'])('ignores a restore lookup completed after %s', async (action) => {
|
||||
const { player, client, audio } = fixture();
|
||||
let resolve!: (value: unknown) => void;
|
||||
client.POST.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
const restoring = player.restore();
|
||||
await vi.waitFor(() => expect(client.POST).toHaveBeenCalled());
|
||||
if (action === 'selection') player.playList([{ ...track, id: 'b', stream_url: '/s/b' }], 0, { type: 'search' });
|
||||
else player.dispose();
|
||||
resolve(ok({ items: [track] }));
|
||||
await restoring;
|
||||
expect(audio.getAttribute('src')).toBe(action === 'selection' ? '/s/b' : null);
|
||||
if (action === 'selection') player.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
|
||||
export type Role = components['schemas']['Role'];
|
||||
export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
|
||||
export type UserAction = 'reset_password' | 'reset_recommendations' | 'block' | 'change_role' | 'delete';
|
||||
|
||||
/**
|
||||
* Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI:
|
||||
@@ -9,9 +9,9 @@ export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
|
||||
*/
|
||||
export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) {
|
||||
const self = actor.id === target.id;
|
||||
if (self && action !== 'reset_password') return false;
|
||||
if (self && (action === 'block' || action === 'change_role' || action === 'delete')) return false;
|
||||
if (actor.role === 'admin') return true;
|
||||
if (actor.role === 'moderator') return (action === 'reset_password' || action === 'block') && target.role === 'user';
|
||||
if (actor.role === 'moderator') return action !== 'change_role' && action !== 'delete' && target.role === 'user';
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { shareTrack } from './share';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('shareTrack', () => {
|
||||
it('copies on desktop even when native sharing exists', async () => {
|
||||
const share = vi.fn();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Windows');
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows', share, clipboard: { writeText } });
|
||||
try {
|
||||
expect(await shareTrack('id', 'Title')).toBe('copied');
|
||||
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/track/id`);
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('opens Android sharing with a page URL', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||
try {
|
||||
expect(await shareTrack('id', 'Title')).toBe('shared');
|
||||
expect(share).toHaveBeenCalledWith({ title: 'Title', url: `${window.location.origin}/track/id` });
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('does not copy or report an error when the share sheet is dismissed', async () => {
|
||||
const share = vi.fn().mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
|
||||
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||
try { expect(await shareTrack('id', 'Title')).toBe('cancelled'); }
|
||||
finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('reports clipboard failures instead of pretending the link was copied', async () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Denied')) } });
|
||||
try { await expect(shareTrack('id', 'Title')).rejects.toThrow('Denied'); }
|
||||
finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
|
||||
/** Desktop always copies; mobile browsers can open their native share sheet. */
|
||||
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||
const url = trackShareUrl(window.location.origin, id);
|
||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, url });
|
||||
return 'shared';
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError') return 'cancelled';
|
||||
// If sharing is unavailable, still allow copying the link.
|
||||
}
|
||||
}
|
||||
await navigator.clipboard.writeText(url);
|
||||
return 'copied';
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
|
||||
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
|
||||
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
|
||||
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
|
||||
export { default as shareIcon } from '@material-symbols/svg-400/rounded/share.svg';
|
||||
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
|
||||
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
|
||||
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
|
||||
@@ -23,6 +24,8 @@ export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.sv
|
||||
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
|
||||
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
|
||||
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
|
||||
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
|
||||
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
|
||||
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
|
||||
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
|
||||
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 16px;
|
||||
background: var(--overlay);
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
@@ -268,7 +269,9 @@
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 40;
|
||||
width: max-content;
|
||||
min-width: 220px;
|
||||
max-width: min(360px, calc(100vw - 24px));
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--elev);
|
||||
@@ -292,6 +295,7 @@
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
@@ -314,3 +318,22 @@
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.button { white-space: normal; height: auto; min-height: 44px; padding: 10px 14px; }
|
||||
.iconButton { width: 44px; height: 44px; flex: none; }
|
||||
.input, .select { font-size: 16px; min-width: 0; }
|
||||
.segmented { flex-wrap: wrap; max-width: 100%; }
|
||||
.segmented button { min-height: 44px; height: auto; padding: 8px 12px; }
|
||||
.overlay { padding: 12px; z-index: 60; }
|
||||
.dialog { min-width: 0; overflow-wrap: anywhere; max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)); }
|
||||
.dialogHead { padding: 16px 16px 0; align-items: flex-start; }
|
||||
.dialogHead h2 { overflow-wrap: anywhere; font-size: 18px; }
|
||||
.dialogBody { padding: 16px; min-width: 0; }
|
||||
.field, .inputWrap { min-width: 0; }
|
||||
.dialogFoot { padding: 0 16px 16px; flex-wrap: wrap; }
|
||||
.dialogFoot > * { flex: 1 1 auto; }
|
||||
.menu { position: fixed; inset: auto 12px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
width: auto; min-width: 0; max-width: none; max-height: 75dvh; overflow-y: auto; z-index: 70; }
|
||||
.menuItem { min-height: 44px; height: auto; padding: 12px; white-space: normal; overflow-wrap: anywhere; }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons';
|
||||
@@ -162,6 +163,18 @@ export function Dialog({
|
||||
(first ?? ref.current)?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Tab') {
|
||||
const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
|
||||
) ?? [])].filter((el) => el.getClientRects().length > 0);
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) {
|
||||
e.preventDefault(); last?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault(); first?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
@@ -169,7 +182,7 @@ export function Dialog({
|
||||
prev?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
return (
|
||||
return createPortal(
|
||||
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
||||
<div className={s.dialogHead}>
|
||||
@@ -181,7 +194,8 @@ export function Dialog({
|
||||
<div className={s.dialogBody}>{children}</div>
|
||||
{footer && <div className={s.dialogFoot}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,17 +219,18 @@ export function Menu({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
if (!ref.current?.contains(e.target as Node) && !menuRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||
menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
@@ -224,25 +239,13 @@ export function Menu({
|
||||
const onMenuKey = (e: ReactKeyboardEvent) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
e.preventDefault();
|
||||
const els = [...(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
|
||||
const els = [...(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
|
||||
const i = els.indexOf(document.activeElement as HTMLElement);
|
||||
const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
|
||||
els[next]?.focus();
|
||||
};
|
||||
return (
|
||||
<div className={s.menuWrap} ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={triggerClassName ?? s.iconButton}
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
{open && (
|
||||
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||
const menu = (
|
||||
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||
{header}
|
||||
{items.map((it) => (
|
||||
<button
|
||||
@@ -260,7 +263,22 @@ export function Menu({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
return (
|
||||
<div className={s.menuWrap} ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={triggerClassName ?? s.iconButton}
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
{open && (window.matchMedia?.('(max-width: 767px)').matches
|
||||
? createPortal(menu, document.body)
|
||||
: menu)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,19 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Элементы управления не выделяются при кликах и двойных кликах — текст остаётся выделяемым на страницах. */
|
||||
button,
|
||||
[role='button'],
|
||||
[role='tab'],
|
||||
[role='option'],
|
||||
[role='menuitem'],
|
||||
[role='listitem'],
|
||||
nav a,
|
||||
label {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fakeApi, makeUser } from './fakeApi';
|
||||
import { mobileFixtures } from './mobileFixtures';
|
||||
|
||||
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
|
||||
export function installE2EFetch() {
|
||||
const api = fakeApi([
|
||||
makeUser('admin', 'admin', { must_change_credentials: true }),
|
||||
makeUser('alice', 'user'),
|
||||
makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
|
||||
makeUser('moderator', 'moderator', { reviews_uploads: true }),
|
||||
]);
|
||||
const orig = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
const path = new URL(req.url, window.location.origin).pathname;
|
||||
if (req.method === 'GET' && path in mobileFixtures) {
|
||||
return new Response(JSON.stringify(mobileFixtures[path]), { headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (new URL(req.url, window.location.origin).pathname.startsWith('/api/')) return api.fetch(req);
|
||||
return orig(input, init);
|
||||
};
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export function makeUser(username: string, role: Role, extra: Partial<FakeUser>
|
||||
is_blocked: false,
|
||||
must_change_password: false,
|
||||
must_change_credentials: false,
|
||||
reviews_uploads: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
last_login_at: null,
|
||||
password: `${username}-password`,
|
||||
@@ -74,12 +75,14 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
let token = 0;
|
||||
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||
const playlists = new Map<string, components['schemas']['Playlist']>();
|
||||
|
||||
const me = (u: FakeUser): Me => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
display_name: u.display_name,
|
||||
role: u.role,
|
||||
reviews_uploads: u.reviews_uploads,
|
||||
locale: u.locale,
|
||||
ui_prefs: prefs.get(u.id) ?? {},
|
||||
must_change_password: u.must_change_password,
|
||||
@@ -125,6 +128,23 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
|
||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||
|
||||
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
|
||||
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
|
||||
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
|
||||
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
|
||||
const old = playlists.get(id);
|
||||
if (playlistPath && !old) return problem(404, 'playlist.not_found');
|
||||
if (req.method === 'GET') return json(old);
|
||||
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
|
||||
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
|
||||
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
|
||||
const item = { id, name: body?.name as string, description: body?.description as string, tracks,
|
||||
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
|
||||
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
playlists.set(id, item);
|
||||
return json(item, old ? 200 : 201);
|
||||
}
|
||||
|
||||
switch (route) {
|
||||
case 'POST /api/v1/auth/logout':
|
||||
case 'POST /api/v1/auth/logout-all':
|
||||
@@ -153,6 +173,16 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
return tokens(user);
|
||||
case 'GET /api/v1/me/sessions':
|
||||
return json({ items: [] });
|
||||
case 'GET /api/v1/uploads/unseen':
|
||||
return json({ count: 0 });
|
||||
case 'POST /api/v1/uploads/seen':
|
||||
return new Response(null, { status: 204 });
|
||||
case 'GET /api/v1/onboarding':
|
||||
return json({ required: false, artists: [], genres: [] });
|
||||
case 'GET /api/v1/me/likes':
|
||||
case 'GET /api/v1/me/dislikes':
|
||||
case 'GET /api/v1/me/history':
|
||||
return json({ items: [], next_cursor: null, total: 0 });
|
||||
case 'GET /api/v1/admin/users': {
|
||||
if (user.role === 'user') return problem(403, 'auth.forbidden');
|
||||
const q = (url.searchParams.get('q') ?? '').toLowerCase();
|
||||
@@ -169,6 +199,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
display_name: (body?.display_name as string) ?? null,
|
||||
must_change_password: true,
|
||||
password: 'Temp-Pass-123',
|
||||
reviews_uploads: body?.reviews_uploads === true && user.role === 'admin',
|
||||
});
|
||||
users.set(u.id, u);
|
||||
return json({ user: admin(u), temporary_password: u.password }, 201);
|
||||
@@ -180,11 +211,14 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
const hit = q === 'кино' || q === 'rbyj';
|
||||
const tracks = path === '/api/v1/search' && hit
|
||||
? albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null }))
|
||||
: [];
|
||||
return json({
|
||||
artists: hit ? [kino] : [],
|
||||
albums: hit ? [albumSummary] : [],
|
||||
tracks: [],
|
||||
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
|
||||
tracks,
|
||||
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: tracks.length },
|
||||
corrected_query: q === 'rbyj' ? 'кино' : null,
|
||||
next_cursor: null,
|
||||
});
|
||||
@@ -223,7 +257,35 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
case `GET /api/v1/artists/${kino.id}/albums`:
|
||||
return json({ items: [albumSummary] });
|
||||
}
|
||||
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path))
|
||||
return new Response(null, { status: 204 });
|
||||
const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path);
|
||||
if (userPath && req.method === 'PATCH') {
|
||||
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
|
||||
const target = users.get(userPath[1]!);
|
||||
if (!target) return problem(404, 'common.not_found');
|
||||
if (typeof body?.username === 'string') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
const username = body.username.trim().toLowerCase();
|
||||
if ([...users.values()].some((u) => u.id !== target.id && u.username.toLowerCase() === username))
|
||||
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
|
||||
target.username = username;
|
||||
}
|
||||
if (typeof body?.reviews_uploads === 'boolean') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
target.reviews_uploads = body.reviews_uploads;
|
||||
}
|
||||
if (typeof body?.role === 'string' && user.role === 'admin') target.role = body.role as Role;
|
||||
if (typeof body?.is_blocked === 'boolean') target.is_blocked = body.is_blocked;
|
||||
users.set(target.id, target);
|
||||
return json(admin(target));
|
||||
}
|
||||
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
||||
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
|
||||
if (trackPath && req.method === 'GET') {
|
||||
const track = albumTracks.find(t => t.id === trackPath[1]);
|
||||
return track ? json({ ...track, reaction: reactions.get(track.id) ?? null }) : problem(404, 'common.not_found');
|
||||
}
|
||||
if (reactionPath) {
|
||||
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
|
||||
else reactions.delete(reactionPath[1]!);
|
||||
@@ -232,5 +294,5 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
return problem(404, 'common.not_found');
|
||||
};
|
||||
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
|
||||
const source: components['schemas']['Source'] = {
|
||||
id: '00000000-0000-4000-8000-000000000099', name: 'Музыкальное хранилище', type: 'local',
|
||||
nfs_server: null, nfs_export: null, nfs_options: '', local_path: 'music/very-long-library-folder/collection',
|
||||
tag_priority: 'tags_first', scan_interval_minutes: null, accepts_uploads: true,
|
||||
write_template: null, status: 'mounted', status_message: null, last_scan_at: null,
|
||||
created_at: '2026-01-01T00:00:00Z', scan: null,
|
||||
};
|
||||
const file: components['schemas']['UploadFile'] = {
|
||||
id: '00000000-0000-4000-8000-000000000098', batch_id: '00000000-0000-4000-8000-000000000097',
|
||||
batch_comment: 'Альбом для проверки', author: { id: '00000000-0000-4000-8000-000000000096', username: 'moderator' },
|
||||
status: 'pending_review', original_name: 'Длинное_название_трека_для_проверки_переноса.flac',
|
||||
client_rel_path: 'music/album/Длинное_название_трека_для_проверки_переноса.flac', size_bytes: 42000000,
|
||||
received_bytes: 42000000, format: 'FLAC', codec: 'flac', bitrate: 1000000, sample_rate: 44100, bit_depth: 16,
|
||||
duration_ms: 200000, tags: {}, metadata: null, metadata_edited: false, matched_artist_id: null,
|
||||
matched_album_id: null, duplicate_of_track_id: null, duplicate_level: null, possible_duplicate: true,
|
||||
quality_upgrade: false, target_source_id: source.id, target_rel_path: null, status_reason: null,
|
||||
reviewed_at: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', tus_url: null,
|
||||
stream_url: '/api/v1/stream/mobile-preview',
|
||||
};
|
||||
|
||||
/** Populated staff screens for browser layout checks; loaded only by the E2E bridge. */
|
||||
export const mobileFixtures: Record<string, unknown> = {
|
||||
'/api/v1/admin/sources': { items: [source] },
|
||||
[`/api/v1/admin/sources/${source.id}`]: source,
|
||||
[`/api/v1/admin/sources/${source.id}/patterns`]: { patterns: [] },
|
||||
'/api/v1/admin/uploads': { items: [file], next_cursor: null },
|
||||
'/api/v1/uploads/quota': { limit_bytes: 10000000000, used_bytes: 42000000, per_user_limit_bytes: null,
|
||||
user_used_bytes: 42000000, max_file_bytes: 1000000000 },
|
||||
};
|
||||
@@ -11,8 +11,9 @@
|
||||
.ambient span {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(30px);
|
||||
will-change: transform;
|
||||
scale: var(--music-scale, 1);
|
||||
filter: blur(30px) brightness(var(--music-light, 1));
|
||||
}
|
||||
|
||||
.a {
|
||||
@@ -104,5 +105,6 @@
|
||||
.ambient,
|
||||
.ambient span {
|
||||
animation: none;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useRef, type CSSProperties } from 'react';
|
||||
|
||||
import s from './Ambient.module.css';
|
||||
|
||||
@@ -6,9 +6,32 @@ import s from './Ambient.module.css';
|
||||
* Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека.
|
||||
* Отключается в настройках; при prefers-reduced-motion пятна неподвижны.
|
||||
*/
|
||||
export function Ambient({ color }: { color: string }) {
|
||||
export function Ambient({ color, readAudio }: {
|
||||
color: string;
|
||||
readAudio?: () => { bass: number; mid: number; treble: number };
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!readAudio) return;
|
||||
const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
let level = 0;
|
||||
const frame = (now: number) => {
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
const audio = !mq?.matches && !document.hidden ? readAudio() : undefined;
|
||||
const target = audio ? audio.bass * 0.65 + audio.mid * 0.25 + audio.treble * 0.1 : 0;
|
||||
level += (target - level) * (1 - Math.exp(-dt * 7));
|
||||
root.current?.style.setProperty('--music-scale', String(1 + level * 0.16));
|
||||
root.current?.style.setProperty('--music-light', String(1 + level * 0.3));
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
raf = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [readAudio]);
|
||||
return (
|
||||
<div className={s.ambient} aria-hidden>
|
||||
<div ref={root} className={s.ambient} aria-hidden>
|
||||
<span className={s.a} />
|
||||
<span className={s.b} />
|
||||
<span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} />
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
.shell {
|
||||
--side-w: 240px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
grid-template-rows: 60px 1fr 88px;
|
||||
grid-template-columns: var(--side-w) minmax(0, 1fr);
|
||||
grid-template-rows: 60px minmax(0, 1fr) 88px;
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
'side content'
|
||||
'player player';
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.shell > :where(:not([aria-hidden])) {
|
||||
@@ -17,7 +19,7 @@
|
||||
|
||||
/* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */
|
||||
.island {
|
||||
grid-template-rows: 60px 1fr;
|
||||
grid-template-rows: 60px minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
'side content';
|
||||
@@ -31,10 +33,11 @@
|
||||
.top {
|
||||
grid-area: top;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
/* Три зоны: логотип, середина, профиль; поиск — в одной из них (настройка «Расположение поиска»). */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bar-a);
|
||||
@@ -53,9 +56,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.topLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topCenter {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -70,7 +86,7 @@
|
||||
|
||||
.profileHead {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: auto max-content;
|
||||
gap: 4px 12px;
|
||||
align-items: center;
|
||||
padding: 10px 10px 12px;
|
||||
@@ -86,11 +102,13 @@
|
||||
|
||||
.profileName {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profileMeta {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.side {
|
||||
@@ -104,12 +122,52 @@
|
||||
}
|
||||
|
||||
.navSection {
|
||||
margin: 18px 12px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 2px;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navSection:hover {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.navSection:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.navSectionTitle {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navChevron {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.navChevronOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Обёртка не мешает раскладке: пункты остаются детьми .side (на телефоне это нижняя панель). */
|
||||
.navGroup {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.navGroupFolded {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
@@ -171,6 +229,8 @@
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 32px 36px;
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
@@ -178,7 +238,7 @@
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.shell {
|
||||
grid-template-columns: 72px 1fr;
|
||||
--side-w: 72px;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
@@ -195,41 +255,83 @@
|
||||
|
||||
.navSection {
|
||||
margin: 14px 0 6px;
|
||||
text-align: center;
|
||||
font-size: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 60px 1fr 72px 64px;
|
||||
grid-template-areas:
|
||||
'top'
|
||||
'content'
|
||||
'player'
|
||||
'side';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.side {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
padding: 6px 8px;
|
||||
border-right: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.navSection {
|
||||
.navSection > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
flex: 1;
|
||||
/* Без подписей свернуть раздел нельзя — пункты видны всегда. */
|
||||
.navGroupFolded {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px 16px;
|
||||
.mobileBottom { display: none; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.shell, .shell.island {
|
||||
--side-w: 0px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr) 72px calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
grid-template-areas: 'top' 'content' 'player' 'mobile';
|
||||
}
|
||||
|
||||
.top {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
padding: calc(8px + env(safe-area-inset-top, 0px)) 16px 10px;
|
||||
}
|
||||
.topLeft, .topCenter, .topRight { display: contents; }
|
||||
.logoLink { grid-area: 1 / 1; justify-self: start; }
|
||||
.topRight > :last-child { grid-area: 1 / 2; justify-self: end; }
|
||||
.topLeft > :not(.logoLink), .topCenter > *, .topRight > :not(:last-child) {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
.profileButton { min-width: 44px; min-height: 44px; }
|
||||
.profileHead { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.profileName, .profileMeta { white-space: normal; overflow-wrap: anywhere; }
|
||||
.side { display: none; }
|
||||
.mobileBottom {
|
||||
grid-area: mobile;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 4px 8px calc(4px + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bar-a);
|
||||
}
|
||||
.mobileBottom .navItem {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
height: 56px;
|
||||
padding: 6px 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mobileBottom .navLabel { display: block; flex: none; max-width: 100%; }
|
||||
.mobileDot {
|
||||
position: absolute; top: 6px; right: 24%;
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--accent2);
|
||||
}
|
||||
.mobileMenu { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mobileMenu .navItem {
|
||||
justify-content: flex-start; height: auto; min-height: 48px; padding: 8px 12px; font-size: 14px;
|
||||
}
|
||||
.mobileMenu .navLabel, .mobileMenu .navCount, .mobileMenu .navBadge { display: block; }
|
||||
.mobileMenu .navLabel { white-space: normal; }
|
||||
.mobileMenu .navGroup, .mobileMenu .navGroupFolded { display: contents; }
|
||||
.mobileMenu .navSection {
|
||||
display: flex; margin: 12px 0 0; padding: 10px 12px 4px;
|
||||
border: 0; pointer-events: none;
|
||||
}
|
||||
.mobileMenu .navSectionTitle { display: block; }
|
||||
.mobileMenu .navChevron { display: none; }
|
||||
.content, .island .content { padding: 20px 16px 24px; }
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { queueIcon } from '../shared/ui/icons';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
import { useCollectionCounts } from '../entities/collection';
|
||||
import {
|
||||
moreIcon,
|
||||
auditFillIcon,
|
||||
auditIcon,
|
||||
groupFillIcon,
|
||||
@@ -22,11 +25,13 @@ import {
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
settingsIcon,
|
||||
androidIcon,
|
||||
chevronRightIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from './LanguageSwitch';
|
||||
import { PlayerProvider, usePlayerState } from '../player/context';
|
||||
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
|
||||
import { Ambient } from './Ambient';
|
||||
import { PlayerBar } from './PlayerBar';
|
||||
import { SearchBox } from './SearchBox';
|
||||
@@ -42,6 +47,7 @@ type NavPath =
|
||||
| '/uploads/new'
|
||||
| '/uploads/mine'
|
||||
| '/admin/settings'
|
||||
| '/collection/playlists'
|
||||
| '/collection/history'
|
||||
| '/collection/likes'
|
||||
| '/collection/dislikes';
|
||||
@@ -62,7 +68,7 @@ function NavLink({
|
||||
badge?: number; // яркий бейдж (ждут решения)
|
||||
}) {
|
||||
return (
|
||||
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
|
||||
<Link to={to} aria-label={label} title={label} className={s.navItem} activeOptions={{ exact: to === '/' }}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
|
||||
@@ -75,6 +81,67 @@ function NavLink({
|
||||
);
|
||||
}
|
||||
|
||||
const FOLDED_KEY = 'pipu.nav.folded';
|
||||
const TOUCHED_KEY = 'pipu.nav.touched';
|
||||
|
||||
function readIds(key: string): string[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(key) ?? '[]');
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Раздел меню: заголовок сворачивает пункты; явный выбор помнит браузер. Свёрнутый раздел показывает сумму бейджей. */
|
||||
function NavSection({
|
||||
id,
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
defaultFolded = false,
|
||||
mobile = false,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
badge?: number;
|
||||
children: ReactNode;
|
||||
defaultFolded?: boolean;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const [folded, setFolded] = useState(() => {
|
||||
if (readIds(FOLDED_KEY).includes(id)) return true;
|
||||
if (readIds(TOUCHED_KEY).includes(id)) return false;
|
||||
return defaultFolded;
|
||||
});
|
||||
const toggle = () => {
|
||||
const next = !folded;
|
||||
setFolded(next);
|
||||
try {
|
||||
const rest = readIds(FOLDED_KEY).filter((x) => x !== id);
|
||||
localStorage.setItem(FOLDED_KEY, JSON.stringify(next ? [...rest, id] : rest));
|
||||
const touched = readIds(TOUCHED_KEY);
|
||||
if (!touched.includes(id)) localStorage.setItem(TOUCHED_KEY, JSON.stringify([...touched, id]));
|
||||
} catch {
|
||||
// Без хранилища раздел просто не запомнит состояние.
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{mobile ? (
|
||||
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3>
|
||||
) : (
|
||||
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}>
|
||||
<span className={s.navSectionTitle}>{title}</span>
|
||||
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
|
||||
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
|
||||
</button>
|
||||
)}
|
||||
<div className={cx(s.navGroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
|
||||
export function AppShell() {
|
||||
return useAuthState().status === 'signed_in' ? (
|
||||
@@ -92,18 +159,81 @@ function Shell() {
|
||||
const name = user.display_name ?? user.username;
|
||||
const staff = user.role !== 'user';
|
||||
const island = user.ui_prefs.web_player === 'island';
|
||||
const searchAt = user.ui_prefs.search_position ?? 'center';
|
||||
const search = <SearchBox align={searchAt} collapsible={user.ui_prefs.search_collapse === true} />;
|
||||
const counts = useCollectionCounts();
|
||||
const pending = usePendingUploads(user.role === 'admin');
|
||||
const reviews = user.reviews_uploads;
|
||||
const pending = usePendingUploads(reviews);
|
||||
const unseen = useUnseenUploads(staff);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
const navigation = (mobile = false) => (
|
||||
<>
|
||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
|
||||
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
|
||||
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||
<NavLink
|
||||
to="/collection/dislikes"
|
||||
label={t('nav.dislikes')}
|
||||
icon={dislikeIcon}
|
||||
activeIcon={dislikeFillIcon}
|
||||
count={counts.dislikes}
|
||||
/>
|
||||
</NavSection>
|
||||
{!staff && reviews && (
|
||||
<NavLink
|
||||
to="/admin/uploads"
|
||||
label={t('nav.upload_queue')}
|
||||
icon={inboxIcon}
|
||||
activeIcon={inboxFillIcon}
|
||||
badge={pending}
|
||||
/>
|
||||
)}
|
||||
{staff && (
|
||||
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
|
||||
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||
)}
|
||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
|
||||
{reviews && (
|
||||
<NavLink
|
||||
to="/admin/uploads"
|
||||
label={t('nav.upload_queue')}
|
||||
icon={inboxIcon}
|
||||
activeIcon={inboxFillIcon}
|
||||
badge={pending}
|
||||
/>
|
||||
)}
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
</>
|
||||
)}
|
||||
</NavSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className={cx(s.shell, island && s.island)}>
|
||||
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
||||
<header className={s.top}>
|
||||
<div className={s.topLeft}>
|
||||
<Link to="/" className={s.logoLink}>
|
||||
<Logo />
|
||||
</Link>
|
||||
<SearchBox />
|
||||
{searchAt === 'left' && search}
|
||||
</div>
|
||||
<div className={s.topCenter}>{searchAt === 'center' && search}</div>
|
||||
<div className={s.topRight}>
|
||||
{searchAt === 'right' && search}
|
||||
<Menu
|
||||
label={t('profile.menu')}
|
||||
triggerClassName={s.profileButton}
|
||||
@@ -122,53 +252,38 @@ function Shell() {
|
||||
}
|
||||
items={[
|
||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
||||
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className={s.side} aria-label={t('nav.main')}>
|
||||
<nav className={s.side} aria-label={t('nav.main')}>{navigation()}</nav>
|
||||
<nav className={s.mobileBottom} aria-label={t('nav.main')}>
|
||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||
<div className={s.navSection}>{t('nav.collection')}</div>
|
||||
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||
<NavLink
|
||||
to="/collection/dislikes"
|
||||
label={t('nav.dislikes')}
|
||||
icon={dislikeIcon}
|
||||
activeIcon={dislikeFillIcon}
|
||||
count={counts.dislikes}
|
||||
/>
|
||||
{staff && (
|
||||
<>
|
||||
<div className={s.navSection}>{t('nav.admin')}</div>
|
||||
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||
)}
|
||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<NavLink
|
||||
to="/admin/uploads"
|
||||
label={t('nav.upload_queue')}
|
||||
icon={inboxIcon}
|
||||
activeIcon={inboxFillIcon}
|
||||
badge={pending}
|
||||
/>
|
||||
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
|
||||
<button type="button" className={s.navItem} aria-label={t('nav.more')}
|
||||
aria-haspopup="dialog" aria-expanded={mobileOpen} onClick={() => setMobileOpen(true)}>
|
||||
<Icon src={moreIcon} className={s.navIcon} />
|
||||
<span className={s.navLabel}>{t('nav.more')}</span>
|
||||
{(pending ?? 0) + (unseen ?? 0) > 0 && <span className={s.mobileDot} />}
|
||||
</button>
|
||||
</nav>
|
||||
{mobileOpen && (
|
||||
<Dialog title={t('nav.main')} onClose={closeMobile}>
|
||||
<nav className={s.mobileMenu} aria-label={t('nav.main')}
|
||||
onClick={(e) => { if ((e.target as HTMLElement).closest('a')) closeMobile(); }}>
|
||||
{navigation(true)}
|
||||
</nav>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<main className={s.content}>
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||
@@ -178,8 +293,9 @@ function Shell() {
|
||||
|
||||
/** Третье пятно фона — в цвете обложки текущего трека. */
|
||||
function ShellAmbient() {
|
||||
const player = usePlayer();
|
||||
const track = usePlayerState().track;
|
||||
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />;
|
||||
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} readAudio={player.readAudio} />;
|
||||
}
|
||||
|
||||
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
|
||||
@@ -196,3 +312,15 @@ function usePendingUploads(enabled: boolean) {
|
||||
});
|
||||
return q.data;
|
||||
}
|
||||
|
||||
/** Новые решения по своим загрузкам — бейдж «Мои предложения» у персонала. */
|
||||
function useUnseenUploads(enabled: boolean) {
|
||||
const session = useSession();
|
||||
const q = useQuery({
|
||||
queryKey: ['uploads-unseen'],
|
||||
enabled,
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/unseen')).count,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
return q.data || undefined;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
.bar {
|
||||
position: relative;
|
||||
grid-area: player;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
||||
@@ -297,3 +298,37 @@ a.source:hover {
|
||||
.queueCurrent .title {
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; padding: 0 8px; }
|
||||
.bar.island { position: relative; left: auto; bottom: auto; width: auto; height: auto;
|
||||
transform: none; margin: 0 8px; border-radius: 16px; }
|
||||
.empty { grid-column: 1 / -1; }
|
||||
.placeholder { flex: none; }
|
||||
.now { gap: 8px; }
|
||||
.now > :first-child { width: 44px !important; height: 44px !important; }
|
||||
.source { display: none; }
|
||||
.controls { gap: 2px; }
|
||||
.play, .icon { width: 44px; height: 44px; }
|
||||
.right { display: flex; gap: 0; }
|
||||
.right > :not(.queueButton):not(.shareButton) { display: none; }
|
||||
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.now > :first-child { display: none; }
|
||||
}
|
||||
|
||||
.shareStatus {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 8px;
|
||||
max-width: calc(100% - 16px);
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface3);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { shareTrack } from '../shared/lib/share';
|
||||
import {
|
||||
albumIcon,
|
||||
closeIcon,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
repeatIcon,
|
||||
repeatOneIcon,
|
||||
shuffleIcon,
|
||||
shareIcon,
|
||||
volumeIcon,
|
||||
volumeOffIcon,
|
||||
waveIcon,
|
||||
@@ -35,6 +37,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
useEffect(() => {
|
||||
setShareStatus(null);
|
||||
}, [st.track?.id]);
|
||||
useEffect(() => {
|
||||
if (!shareStatus) return;
|
||||
const timer = setTimeout(() => setShareStatus(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [shareStatus]);
|
||||
const queueButton = useRef<HTMLButtonElement>(null);
|
||||
const queueId = useId();
|
||||
const closeQueue = useCallback(() => {
|
||||
@@ -57,6 +69,18 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
}
|
||||
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const share = async () => {
|
||||
setSharing(true);
|
||||
setShareStatus(null);
|
||||
try {
|
||||
const result = await shareTrack(track.id, track.title);
|
||||
if (result === 'copied') setShareStatus('copied');
|
||||
} catch {
|
||||
setShareStatus('error');
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||
const ctx = st.context;
|
||||
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
|
||||
@@ -67,7 +91,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
? t('player.source_wave_seed', { label: ctx.label })
|
||||
: t('player.source_wave')
|
||||
: ctx.type === 'collection'
|
||||
? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
|
||||
? ctx.label ?? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
|
||||
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
|
||||
|
||||
return (
|
||||
@@ -154,6 +178,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
</div>
|
||||
|
||||
<div className={s.right}>
|
||||
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||
<Icon src={shareIcon} size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, st.shuffle && s.accent)}
|
||||
@@ -175,7 +202,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
<button
|
||||
ref={queueButton}
|
||||
type="button"
|
||||
className={cx(s.icon, queueOpen && s.accent)}
|
||||
className={cx(s.icon, s.queueButton, queueOpen && s.accent)}
|
||||
aria-label={t('player.queue')}
|
||||
aria-expanded={queueOpen}
|
||||
aria-controls={queueOpen ? queueId : undefined}
|
||||
@@ -204,6 +231,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
/>
|
||||
</div>
|
||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
.box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 520px;
|
||||
flex: none;
|
||||
width: 40px;
|
||||
transition: width 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.expanded {
|
||||
width: min(520px, 46vw);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text3);
|
||||
@@ -16,13 +21,36 @@
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 40px 0 42px;
|
||||
padding: 0 40px 0 40px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
/* Свёрнутое поле — круглая кнопка: без подсказки и курсора ввода. */
|
||||
.box:not(.expanded) .input {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.box:not(.expanded) .input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.box:not(.expanded) .input:hover {
|
||||
background: var(--surface3);
|
||||
}
|
||||
|
||||
.box:not(.expanded) .kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
@@ -56,8 +84,7 @@
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
top: calc(100% + 8px);
|
||||
z-index: 45;
|
||||
max-height: min(70vh, 560px);
|
||||
@@ -70,6 +97,20 @@
|
||||
animation: pipuIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Подсказки раскрываются в сторону свободного места: слева — вправо, справа — влево, по центру — от середины. */
|
||||
.left .popover {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right .popover {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.center .popover {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.corrected {
|
||||
margin: 4px 10px 8px;
|
||||
font-size: 13px;
|
||||
@@ -94,6 +135,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option:hover,
|
||||
.active {
|
||||
background: var(--hover);
|
||||
}
|
||||
@@ -144,3 +186,13 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.box, .expanded { width: 100%; min-width: 0; }
|
||||
.input { height: 44px; font-size: 16px; }
|
||||
.box:not(.expanded) .input { padding: 0 40px; color: var(--text); }
|
||||
.box:not(.expanded) .input::placeholder { color: var(--text3); }
|
||||
.left .popover, .center .popover, .right .popover { width: 100%; left: 0; right: auto; transform: none; max-height: 55dvh; }
|
||||
.option { min-height: 48px; }
|
||||
.all { min-height: 44px; height: auto; padding: 10px; }
|
||||
}
|
||||
@@ -23,8 +23,17 @@ type Item = {
|
||||
go: () => void;
|
||||
};
|
||||
|
||||
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
|
||||
export function SearchBox() {
|
||||
/**
|
||||
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
|
||||
* Без фокуса и текста свёрнут в кнопку с лупой (если collapsible); поле раскрывается с анимацией.
|
||||
*/
|
||||
export function SearchBox({
|
||||
align = 'center',
|
||||
collapsible = false,
|
||||
}: {
|
||||
align?: 'left' | 'center' | 'right';
|
||||
collapsible?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const navigate = useNavigate();
|
||||
@@ -34,6 +43,10 @@ export function SearchBox() {
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
|
||||
const expanded = !collapsible || focused || q !== '';
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
||||
@@ -141,7 +154,7 @@ export function SearchBox() {
|
||||
const optionId = (i: number) => `${listId}-${i}`;
|
||||
|
||||
return (
|
||||
<div className={s.box}>
|
||||
<div className={cx(s.box, expanded && s.expanded, s[align])}>
|
||||
<Icon src={searchIcon} size={18} className={s.icon} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -160,8 +173,16 @@ export function SearchBox() {
|
||||
setOpen(true);
|
||||
setActive(-1);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(close, 120)}
|
||||
onFocus={() => {
|
||||
// Вернулись в поле раньше, чем сработало закрытие по blur, — подсказки не прячем.
|
||||
clearTimeout(blurTimer.current);
|
||||
setFocused(true);
|
||||
setOpen(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
blurTimer.current = setTimeout(close, 120);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<kbd className={s.kbd} aria-hidden>
|
||||
@@ -188,7 +209,6 @@ export function SearchBox() {
|
||||
aria-selected={i === active}
|
||||
className={cx(s.option, i === active && s.active)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => choose(i)}
|
||||
>
|
||||
<Cover album={it.cover} size={36} round={it.round} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { drawLiquid, drawOrb, step, type WaveState } from './draw';
|
||||
export interface WaveButtonProps {
|
||||
variant?: 'orb' | 'liquid';
|
||||
playing?: boolean;
|
||||
readAudio?: () => { bass: number; mid: number; treble: number };
|
||||
bpm?: number;
|
||||
energy?: number;
|
||||
colors?: [string, string, string];
|
||||
@@ -28,6 +29,8 @@ function prefersReducedMotion(): boolean {
|
||||
*/
|
||||
export function WaveButton(props: WaveButtonProps) {
|
||||
const size = props.size ?? 320;
|
||||
const audioRef = useRef(props.readAudio);
|
||||
audioRef.current = props.readAudio;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
@@ -55,6 +58,7 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
const onMq = () => (st.current.reduced = prefersReducedMotion());
|
||||
mq?.addEventListener?.('change', onMq);
|
||||
let raf = 0;
|
||||
let livePulse = 0;
|
||||
let last = performance.now();
|
||||
const loop = (now: number) => {
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
@@ -67,12 +71,23 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
cv.height = Math.round(S * dpr);
|
||||
}
|
||||
step(w, dt);
|
||||
const audio = !w.reduced && w.playing ? audioRef.current?.() : undefined;
|
||||
if (audioRef.current && !w.reduced) {
|
||||
const target = audio ? Math.min(1, audio.bass * 0.7 + audio.mid * 0.2) : 0;
|
||||
livePulse += (target - livePulse) * (1 - Math.exp(-dt * (target > livePulse ? 35 : 9)));
|
||||
w.pulse = livePulse;
|
||||
w.amp = audio ? Math.min(1, audio.mid * 0.8 + audio.treble * 0.7) : 0;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, S, S);
|
||||
const sc = 1 - w.press * 0.07 + w.hover * 0.02;
|
||||
const r = w.size * 0.5 * sc;
|
||||
const r = w.size * 0.5 * sc * (1 + w.pulse * 0.055);
|
||||
const cx = S / 2 + w.mx * w.hover * 6;
|
||||
const cy = S / 2 + w.my * w.hover * 6;
|
||||
const ripple = audio ? w.amp * 0.025 * Math.sin(w.t * 13) : 0;
|
||||
ctx.translate(cx, cy);
|
||||
ctx.scale(1 + ripple, 1 - ripple);
|
||||
ctx.translate(-cx, -cy);
|
||||
if (w.variant === 'liquid') drawLiquid(ctx, cx, cy, r, w, w.t);
|
||||
else drawOrb(ctx, cx, cy, r, w, w.t);
|
||||
if (labelRef.current) labelRef.current.style.transform = `scale(${sc + w.pulse * 0.03})`;
|
||||
|
||||
@@ -65,16 +65,9 @@ export function drawOrb(ctx: CanvasRenderingContext2D, cx: number, cy: number, r
|
||||
|
||||
export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) {
|
||||
const cols = s.colors.map(rgb) as [Rgb, Rgb, Rgb];
|
||||
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();
|
||||
// Reuse the animated silhouettes for the halo so it follows every deformation.
|
||||
const shapes = Array.from({ length: 3 }, (_, L) => {
|
||||
const path = new Path2D();
|
||||
const P = 140;
|
||||
for (let j = 0; j <= P; j++) {
|
||||
const th = (j / P) * Math.PI * 2;
|
||||
@@ -84,13 +77,29 @@ export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number
|
||||
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.018 * s.amp * Math.sin(11 * th + t * 18 + 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;
|
||||
const y = cy + Math.sin(th) * rr;
|
||||
if (j) ctx.lineTo(x, y);
|
||||
else ctx.moveTo(x, y);
|
||||
if (j) path.lineTo(x, y);
|
||||
else path.moveTo(x, y);
|
||||
}
|
||||
path.closePath();
|
||||
return path;
|
||||
});
|
||||
|
||||
ctx.save();
|
||||
// Two soft falloffs avoid a visible circular boundary, even at peak pulse.
|
||||
for (const [blur, opacity] of [[0.2, 0.14], [0.09, 0.18]] as const) {
|
||||
ctx.filter = `blur(${r * blur}px)`;
|
||||
ctx.fillStyle = rgba(cols[0], opacity * (0.8 + s.pulse * 0.6));
|
||||
ctx.fill(shapes[0]!);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.globalCompositeOperation = s.dark ? 'lighter' : 'source-over';
|
||||
for (let L = 0; L < shapes.length; L++) {
|
||||
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3;
|
||||
const oy = Math.sin(t * 0.5 + L) * r * 0.3;
|
||||
const g = ctx.createRadialGradient(cx + ox, cy + oy, 0, cx, cy, r * 1.1);
|
||||
@@ -98,7 +107,7 @@ export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number
|
||||
g.addColorStop(1, rgba(cols[L]!, 0.25));
|
||||
ctx.fillStyle = g;
|
||||
ctx.globalAlpha = s.dark ? 0.6 : 0.75;
|
||||
ctx.fill();
|
||||
ctx.fill(shapes[L]!);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
@@ -20,5 +20,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/test/setup.ts'],
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
+4
-2
@@ -45,6 +45,8 @@ SUPPORTED_LOCALES=ru,en
|
||||
MIN_ANDROID_VERSION_CODE=0
|
||||
|
||||
# --- Самообновление Android (M5) ---
|
||||
GITEA_URL=https://git.example.lan
|
||||
GITEA_REPO=owner/pipu-music
|
||||
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
|
||||
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
|
||||
GITEA_URL=
|
||||
GITEA_REPO=
|
||||
GITEA_TOKEN=
|
||||
@@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Резервные копии PostgreSQL (docs/deployment.md#бэкапы). Работает в контейнере backup (compose.yaml) и в CI;
|
||||
# подключение — стандартные PGHOST, PGUSER, PGPASSWORD, PGDATABASE.
|
||||
# backup.sh loop — каждый день в BACKUP_HOUR (UTC): копия и ротация; раз в неделю — проверка восстановления
|
||||
# backup.sh once — копия сейчас; печатает путь к файлу
|
||||
# backup.sh verify FILE — восстановить FILE во временную базу и сверить таблицы и число строк
|
||||
# backup.sh restore FILE — заменить рабочую базу копией (сервисы, пишущие в базу, должны быть остановлены);
|
||||
# прежняя база остаётся рядом как <db>_before_restore_<время>
|
||||
set -euo pipefail
|
||||
|
||||
DIR="${BACKUP_DIR:-/backups}"
|
||||
DB="${PGDATABASE:-pipu}"
|
||||
KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}"
|
||||
KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}"
|
||||
|
||||
log() { echo "$(date -u +%FT%TZ) backup: $*" >&2; }
|
||||
|
||||
# Число строк в каждой таблице базы $1: «таблица<TAB>строк», по алфавиту.
|
||||
counts() {
|
||||
psql -X -d "$1" -Atq -F $'\t' -c "
|
||||
SELECT table_name,
|
||||
(xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name),
|
||||
false, true, '')))[1]::text
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||
ORDER BY 1"
|
||||
}
|
||||
|
||||
rotate() {
|
||||
# shellcheck disable=SC2012 # имена файлов наши, без пробелов
|
||||
ls -1t "$DIR"/daily/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_DAILY + 1)) | xargs -r rm -f --
|
||||
# shellcheck disable=SC2012
|
||||
ls -1t "$DIR"/weekly/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_WEEKLY + 1)) | xargs -r rm -f --
|
||||
find "$DIR/daily" "$DIR/weekly" -name '*.counts' | while read -r c; do
|
||||
[ -f "${c%.counts}.dump" ] || rm -f -- "$c"
|
||||
done
|
||||
}
|
||||
|
||||
once() {
|
||||
mkdir -p "$DIR/daily" "$DIR/weekly"
|
||||
local name file
|
||||
name="pipu-$(date -u +%Y%m%d-%H%M%S)"
|
||||
file="$DIR/daily/$name.dump"
|
||||
pg_dump -Fc -d "$DB" -f "$file.part"
|
||||
pg_restore -l "$file.part" >/dev/null # оглавление читается — архив не обрезан
|
||||
counts "$DB" >"$file.counts" # для verify: что было в базе в момент копии
|
||||
mv "$file.part" "$file"
|
||||
# Еженедельная копия — первая за 7 дней (не зависит от того, в какой день сервер был выключен).
|
||||
if ! find "$DIR/weekly" -name 'pipu-*.dump' -mtime -7 | grep -q .; then
|
||||
cp "$file" "$DIR/weekly/$name.dump"
|
||||
cp "$file.counts" "$DIR/weekly/$name.dump.counts"
|
||||
fi
|
||||
rotate
|
||||
log "$file ($(du -h "$file" | cut -f1))"
|
||||
echo "$file"
|
||||
}
|
||||
|
||||
verify() {
|
||||
local file="$1" tmpdb="${DB}_verify" got want
|
||||
[ -f "$file" ] || { log "нет файла $file"; return 1; }
|
||||
dropdb --if-exists "$tmpdb"
|
||||
createdb "$tmpdb"
|
||||
if ! pg_restore --no-owner --exit-on-error -d "$tmpdb" "$file"; then
|
||||
dropdb --if-exists "$tmpdb"
|
||||
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file не восстанавливается"
|
||||
return 1
|
||||
fi
|
||||
got="$(counts "$tmpdb")"
|
||||
dropdb "$tmpdb"
|
||||
want="$(cat "$file.counts" 2>/dev/null || counts "$DB")"
|
||||
# Таблица пропала или опустела — копия негодна. Расхождение в числе строк — рабочая база менялась во время
|
||||
# копии (предупреждение); VERIFY_EXACT=1 (CI, база в покое) — тоже ошибка.
|
||||
local bad=0 table n m
|
||||
while IFS=$'\t' read -r table n; do
|
||||
m="$(printf '%s\n' "$got" | awk -F'\t' -v t="$table" '$1 == t { print $2 }')"
|
||||
if [ -z "$m" ]; then
|
||||
log "в копии нет таблицы $table"
|
||||
bad=1
|
||||
elif [ "$m" = 0 ] && [ "$n" != 0 ]; then
|
||||
log "в копии пуста таблица $table (было $n)"
|
||||
bad=1
|
||||
elif [ "$m" != "$n" ]; then
|
||||
log "$table: $m строк в копии, $n при копировании"
|
||||
[ "${VERIFY_EXACT:-}" = 1 ] && bad=1
|
||||
fi
|
||||
done <<<"$want"
|
||||
if [ "$bad" = 1 ]; then
|
||||
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file"
|
||||
return 1
|
||||
fi
|
||||
log "проверено: $file восстанавливается ($(printf '%s\n' "$got" | wc -l) таблиц)"
|
||||
date -u +%FT%TZ >"$DIR/.last-verify"
|
||||
}
|
||||
|
||||
restore() {
|
||||
local file="$1" old
|
||||
verify "$file"
|
||||
old="${DB}_before_restore_$(date -u +%Y%m%d%H%M%S)"
|
||||
psql -X -d postgres -Atqc "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||
WHERE datname = '$DB' AND pid <> pg_backend_pid()" >/dev/null
|
||||
psql -X -d postgres -qc "ALTER DATABASE \"$DB\" RENAME TO \"$old\""
|
||||
createdb "$DB"
|
||||
if ! pg_restore --no-owner --exit-on-error -d "$DB" "$file"; then
|
||||
dropdb "$DB"
|
||||
psql -X -d postgres -qc "ALTER DATABASE \"$old\" RENAME TO \"$DB\""
|
||||
log "восстановление не удалось — рабочая база не тронута"
|
||||
return 1
|
||||
fi
|
||||
log "база $DB восстановлена из $file; прежняя сохранена как $old (удалить: dropdb $old)"
|
||||
}
|
||||
|
||||
loop() {
|
||||
local hour="${BACKUP_HOUR:-3}" now next file
|
||||
log "ежедневная копия в $hour:30 UTC, хранится $KEEP_DAILY ежедневных и $KEEP_WEEKLY еженедельных"
|
||||
while true; do
|
||||
now="$(date -u +%s)"
|
||||
next="$(date -u -d "today $hour:30" +%s)"
|
||||
[ "$next" -gt "$now" ] || next="$(date -u -d "tomorrow $hour:30" +%s)"
|
||||
sleep $((next - now))
|
||||
if ! file="$(once)"; then
|
||||
log "КОПИЯ НЕ СДЕЛАНА"
|
||||
continue
|
||||
fi
|
||||
if [ -z "$(find "$DIR" -maxdepth 1 -name .last-verify -mtime -7)" ]; then
|
||||
verify "$file" || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
loop) loop ;;
|
||||
once) once ;;
|
||||
verify) verify "${2:?укажите файл копии}" ;;
|
||||
restore) restore "${2:?укажите файл копии}" ;;
|
||||
*)
|
||||
sed -n '2,9p' "$0" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -145,26 +145,6 @@ services:
|
||||
interval: 5s
|
||||
retries: 20
|
||||
|
||||
# Ежедневная копия базы и еженедельная проверка восстановления (deploy/backup.sh, docs/deployment.md#бэкапы).
|
||||
backup:
|
||||
<<: *restart
|
||||
image: pgvector/pgvector:0.8.7-pg17
|
||||
entrypoint: ["bash", "/backup.sh"]
|
||||
command: ["loop"]
|
||||
environment:
|
||||
PGHOST: postgres
|
||||
PGUSER: pipu
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATABASE: pipu
|
||||
BACKUP_HOUR: ${BACKUP_HOUR:-3}
|
||||
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
|
||||
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8}
|
||||
volumes:
|
||||
- ./backup.sh:/backup.sh:ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/backups:/backups
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
||||
redis:
|
||||
<<: *restart
|
||||
image: redis:7.4-alpine
|
||||
|
||||
+34
-1
@@ -10,7 +10,7 @@ OWNER="${SUDO_USER:-root}"
|
||||
DATA="${PIPU_DATA:-/srv/pipu}"
|
||||
|
||||
echo "→ каталоги в $DATA"
|
||||
mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,backups,caddy/data,caddy/config}
|
||||
mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,caddy/data,caddy/config}
|
||||
|
||||
# Каталоги, куда пишут сервисы без root: api (distroless nonroot, uid 65532) и analyzer (uid 10001).
|
||||
# Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш.
|
||||
@@ -76,6 +76,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
|
||||
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
|
||||
fill_gitea() {
|
||||
local url repo origin host path
|
||||
url="$(sed -n 's/^GITEA_URL=//p' .env)"
|
||||
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
|
||||
case "$url" in "" | *example.*) url="" ;; esac
|
||||
case "$repo" in "" | owner/*) repo="" ;; esac
|
||||
[ -n "$url" ] && [ -n "$repo" ] && return 0
|
||||
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
|
||||
[ -n "$origin" ] || return 0
|
||||
case "$origin" in
|
||||
http://* | https://*)
|
||||
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
|
||||
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
|
||||
ssh://*)
|
||||
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
|
||||
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
|
||||
*:*)
|
||||
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
|
||||
path="${origin#*:}" ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
path="${path%.git}"
|
||||
[ -n "$url" ] || url="$host"
|
||||
[ -n "$repo" ] || repo="$path"
|
||||
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
|
||||
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
|
||||
done
|
||||
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
|
||||
}
|
||||
fill_gitea
|
||||
|
||||
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
|
||||
if [ -n "$LIBRARY_HOST" ]; then
|
||||
if grep -q '^LIBRARY_HOST=' .env; then
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Восстановление базы из резервной копии (docs/deployment.md#бэкапы).
|
||||
# ./restore.sh — список копий
|
||||
# ./restore.sh daily/pipu-20261020-033000.dump
|
||||
# Сервисы, пишущие в базу, останавливаются на время восстановления; прежняя база сохраняется рядом.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
|
||||
DATA="$(sed -n 's/^PIPU_DATA=//p' .env)"
|
||||
DATA="${DATA:-/srv/pipu}"
|
||||
|
||||
if [ -z "${1:-}" ]; then
|
||||
echo "Копии в $DATA/backups (новые сверху):"
|
||||
(cd "$DATA/backups" && ls -1t daily/*.dump weekly/*.dump 2>/dev/null) || echo " копий нет"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rel="${1#"$DATA/backups/"}"
|
||||
[ -f "$DATA/backups/$rel" ] || { echo "нет файла $DATA/backups/$rel" >&2; exit 1; }
|
||||
|
||||
echo "→ восстановление из $rel: api, рекомендации и анализ будут остановлены на время восстановления"
|
||||
read -r -p "Продолжить? [y/N] " yes
|
||||
[ "$yes" = y ] || [ "$yes" = Y ] || exit 1
|
||||
|
||||
docker compose stop api recommender analyzer storage-agent
|
||||
if docker compose run --rm backup restore "/backups/$rel"; then
|
||||
status=0
|
||||
else
|
||||
status=1
|
||||
fi
|
||||
docker compose up -d
|
||||
[ "$status" = 0 ] || { echo "восстановление не удалось — база не изменена" >&2; exit 1; }
|
||||
echo "готово. Поиск переиндексируется при старте api (или вручную: «Администрирование → Библиотека»)."
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user