Compare commits

...
18 Commits
Author SHA1 Message Date
mrleo1nid 0a59059738 feat(playlists): share playlists with revocable read-only links
ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
2026-10-22 14:57:14 +03:00
mrleo1nid bfcf6e48b2 fix: harmonize wave colors from album covers
ci / ts / ts (packages + web) (push) Canceled after 5m28s
ci / module boundaries / boundaries (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
2026-10-22 14:52:57 +03:00
mrleo1nid 02307802bc style(recommender): format demographic integration test
ci / recommender / recommender (push) Successful in 37s
release / release (версия и заметки) (push) Successful in 5s
release / prune (старые файлы и прогоны) (push) Successful in 5s
ci / db / db (push) Successful in 54s
release / android (APK в релиз) (push) Successful in 3m54s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m38s
2026-10-22 14:25:11 +03:00
mrleo1nid fff91c446b feat(recommendations): collect demographics and advance age cohorts
ci / ts / ts (packages + web) (push) Successful in 3m19s
ci / db / db (push) Successful in 53s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 51s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
2026-10-22 14:19:52 +03:00
mrleo1nid e158558da3 refactor: isolate playback and upload use cases
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
2026-10-22 13:52:16 +03:00
mrleo1nid c84f90e2cc fix(web): scope playlist creation locator in e2e test
ci / ts / ts (packages + web) (push) Successful in 2m1s
2026-10-22 12:57:25 +03:00
mrleo1nidandCursor 72567acd28 feat(web): polish catalog motion, empty states, and mobile now-playing
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:53:37 +03:00
mrleo1nidandCursor 39aaaed969 feat(web): list previous app builds on the download page
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 2m1s
ci / db / db (push) Successful in 1m4s
GET /app/clients now returns up to 8 Gitea releases per OS so users can download older installers while they still exist after prune.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:40:08 +03:00
mrleo1nidandCursor 8eb6b36888 ci(release): keep files and Actions runs for the last 8 releases
ci / ts / ts (packages + web) (push) Successful in 3m30s
release / android (APK в релиз) (push) Successful in 8m57s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 16m4s
release / prune (старые файлы и прогоны) (push) Successful in 8s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:32:18 +03:00
mrleo1nidandCursor 83b604437c perf(desktop): ship a .deb instead of an 80MB AppImage
ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Canceled after 2m4s
ci / desktop / rust (desktop proxy) (push) Successful in 5m33s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Failing after 4m6s
release / desktop (Linux и Windows в релиз) (push) Successful in 17m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:26:50 +03:00
mrleo1nidandCursor 73ad61e7a5 feat(genius): load song metadata, lyrics, and artwork
ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:23:54 +03:00
mrleo1nid fb20258489 feat(player): add current track to playlists 2026-10-22 07:54:57 +03:00
mrleo1nid 78c006184b feat(branding): add unified Pipu Music app icons
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m30s
ci / ts / ts (packages + web) (push) Successful in 3m29s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m10s
ci / desktop / rust (desktop proxy) (push) Successful in 3m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
2026-10-22 07:48:00 +03:00
mrleo1nidandCursor d3116aba96 perf(desktop): stop packing GStreamer into the AppImage
ci / ts / ts (packages + web) (push) Successful in 2m54s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Successful in 7m28s
ci / desktop / rust (desktop proxy) (push) Successful in 7m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m56s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 01:30:01 +03:00
mrleo1nidandCursor b7518aa450 fix(web): keep the track title readable in the 320px player
ci / ts / ts (packages + web) (push) Successful in 3m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:50:34 +03:00
mrleo1nidandCursor eb8031b126 ci(release): cross-compile Windows NSIS on the Linux runner
ci / ts / ts (packages + web) (push) Failing after 1m41s
release / release (версия и заметки) (push) Successful in 7s
release / android (APK в релиз) (push) Successful in 6m0s
ci / desktop / rust (desktop proxy) (push) Successful in 10m12s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m8s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:43:16 +03:00
mrleo1nidandCursor e4b841a8d8 ci(release): attach Linux and Windows desktop installers
ci / ts / ts (packages + web) (push) Failing after 1m42s
release / android (APK в релиз) (push) Successful in 7m49s
release / release (версия и заметки) (push) Successful in 8s
ci / desktop / rust (desktop proxy) (push) Successful in 7m35s
release / desktop (Linux в релиз) (push) Canceled after 2m48s
release / desktop (Windows в релиз) (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:32:33 +03:00
mrleo1nidandCursor 597067689a feat: add a platform-aware apps download page
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m10s
ci / db / db (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / desktop / rust (desktop proxy) (push) Successful in 5m37s
ci / ts / ts (packages + web) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 11s
release / android (APK в релиз) (push) Successful in 4m22s
Serve desktop and Android builds from the latest Gitea release on /download, and fix the desktop compile and CI flakes that blocked v1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:10:03 +03:00
187 changed files with 9719 additions and 2910 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: ci / module boundaries
on:
push:
branches: [main]
paths:
- 'packages/core/src/**'
- 'apps/web/src/features/uploads/**'
- 'services/api/internal/uploads/**'
- 'scripts/check-boundaries.mjs'
- '.gitea/workflows/ci-boundaries.yml'
pull_request:
paths:
- 'packages/core/src/**'
- 'apps/web/src/features/uploads/**'
- 'services/api/internal/uploads/**'
- 'scripts/check-boundaries.mjs'
- '.gitea/workflows/ci-boundaries.yml'
jobs:
boundaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: node scripts/check-boundaries.mjs
+2 -2
View File
@@ -1,5 +1,5 @@
# Rust-часть десктопа: прокси и проверки URL. Полная сборка инсталлятора — локально # Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (`pnpm --filter @pipu/desktop bundle`): на раннере нет Windows и WebKitGTK. # (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
name: ci / desktop name: ci / desktop
on: on:
+5 -1
View File
@@ -57,5 +57,9 @@ jobs:
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }} key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium # git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
- run: |
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
pnpm --filter @pipu/web exec playwright install chromium
- run: pnpm --filter @pipu/web test:e2e - run: pnpm --filter @pipu/web test:e2e
+147 -5
View File
@@ -1,6 +1,8 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK. # Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы # Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs). # и переписывают статус: собран / не удалась (scripts/release.mjs).
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh). # Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
# #
# Секреты (Settings → Actions → Secrets): # Секреты (Settings → Actions → Secrets):
@@ -132,9 +134,10 @@ jobs:
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace # Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки. # metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново" echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
./gradlew --stop || true ./gradlew --stop || true
find ~/.gradle -name "*.lock" -delete || true
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-* rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
"${gradle[@]}" assembleRelease -x lintVitalRelease "${gradle[@]}" assembleRelease -x lintVitalRelease
else else
@@ -196,3 +199,142 @@ jobs:
- name: Статус в релизе — сборка отменена - name: Статус в релизе — сборка отменена
if: cancelled() if: cancelled()
run: node scripts/release.mjs android cancelled run: node scripts/release.mjs android cancelled
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
desktop:
name: desktop (Linux и Windows в релиз)
needs: [release, android]
if: always() && needs.release.result == 'success'
timeout-minutes: 90
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
CARGO_BUILD_JOBS: "2"
XWIN_CACHE_DIR: /opt/ci-cache/xwin
steps:
- uses: actions/checkout@v4
- name: Статус в релизе — собирается
run: |
node scripts/release.mjs desktop linux building
node scripts/release.mjs desktop windows building
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libdbus-1-dev libsecret-1-dev patchelf \
nsis lld llvm clang
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-xwin
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
restore-keys: cargo-desktop-
- run: pnpm install --frozen-lockfile
- name: Сборка deb
id: linux
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:linux
bundle=src-tauri/target/release/bundle
shopt -s nullglob
for f in "$bundle"/deb/*.deb; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
done
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
rm -rf src-tauri/target/release
- name: Linux в релиз
id: linux-upload
run: |
set -euo pipefail
f="pipu-music-${PIPU_VERSION}-linux.deb"
if [ ! -f "$f" ]; then
echo "::error::нет $f после сборки"
exit 1
fi
node scripts/release.mjs desktop linux success "$f"
- name: Windows toolchain
if: ${{ !cancelled() }}
run: |
set -euo pipefail
rustup target add x86_64-pc-windows-msvc
if ! command -v cargo-xwin >/dev/null; then
cargo install --locked cargo-xwin
fi
if [ ! -d /opt/ci-cache ]; then
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
mkdir -p "$HOME/.cache/xwin"
else
mkdir -p /opt/ci-cache/xwin
fi
- name: Сборка NSIS
id: win
if: ${{ !cancelled() }}
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:windows:cross
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
shopt -s nullglob
copied=
for f in "$bundle"/*-setup.exe; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
copied=1
done
if [ -z "$copied" ]; then
echo "::error::нет NSIS setup.exe после сборки в $bundle"
ls -la "$bundle" || true
exit 1
fi
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Windows в релиз
id: win-upload
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Статус Linux — сборка не удалась
if: failure() && steps.linux-upload.outcome != 'success'
run: node scripts/release.mjs desktop linux failure
- name: Статус Windows — сборка не удалась
if: failure() && steps.win-upload.outcome != 'success'
run: node scripts/release.mjs desktop windows failure
- name: Статус в релизе — сборка отменена
if: cancelled()
run: |
node scripts/release.mjs desktop linux cancelled
node scripts/release.mjs desktop windows cancelled
prune:
name: prune (старые файлы и прогоны)
needs: [release, android, desktop]
if: always() && needs.release.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}" }
- name: Оставить 8 релизов и 8 прогонов
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: node scripts/release.mjs prune
+3
View File
@@ -41,3 +41,6 @@ apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/ apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/tauri.release.json apps/desktop/src-tauri/tauri.release.json
apps/desktop/release/ apps/desktop/release/
# Intermediate platform icon renders (final assets live under apps/).
/artifacts/icon-build/
+10
View File
@@ -0,0 +1,10 @@
# Работа с проектом
- Начинай с ближайшего локального `AGENTS.md` и файлов конкретной возможности. Карта документов: `docs/README.md`; реализация архитектуры: `docs/architecture.md`.
- Web/desktop UI: `apps/web/src`; Android: `apps/mobile/src`; общие правила клиентов: `packages/core/src`; Go API: `services/api/internal`; рекомендации: `services/recommender/pipu_rec`.
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
- Промежуточно запускай проверки затронутой области; перед сдачей — необходимые связанные проверки. Команды запускаются из корня, если не сказано иначе.
- Общие проверки: `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
+2
View File
@@ -5,6 +5,7 @@ Self-hosted музыкальный сервис: веб, десктоп и Andro
- Идея продукта: [docs/IDEA.md](docs/IDEA.md) - Идея продукта: [docs/IDEA.md](docs/IDEA.md)
- Документация: [docs/README.md](docs/README.md) - Документация: [docs/README.md](docs/README.md)
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md) - Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
## Структура ## Структура
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
```bash ```bash
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
pnpm check:boundaries
``` ```
```bash ```bash
+264 -3
View File
@@ -22,6 +22,51 @@ tags:
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
/api/v1/admin/genius:
get:
operationId: getGeniusStats
tags: [admin]
responses:
"200":
description: Persistent enrichment statistics (admin only)
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
post:
operationId: controlGenius
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action:
type: string
enum: [start, pause, retry]
responses:
"200":
description: Updated statistics
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
/api/v1/tracks/{id}/genius:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: getTrackGenius
tags: [catalog]
responses:
"200":
description: Cached lyrics and annotations; never makes an upstream request
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusContent"
/healthz: /healthz:
get: get:
operationId: getHealthz operationId: getHealthz
@@ -1382,9 +1427,10 @@ paths:
get: get:
operationId: getOnboarding operationId: getOnboarding
tags: [wave] tags: [wave]
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора summary: Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
description: | description: |
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет. `required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации.
`choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере.
responses: responses:
"200": "200":
description: Варианты и состояние description: Варианты и состояние
@@ -1497,11 +1543,58 @@ paths:
$ref: "#/components/schemas/WaveBatch" $ref: "#/components/schemas/WaveBatch"
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/app/clients:
get:
operationId: getAppClients
tags: [app]
security: [] # публично: страница /download без входа
summary: Клиенты последних релизов (Android и десктоп)
description: |
Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
responses:
"200":
description: Доступные сборки
content:
application/json:
schema:
$ref: "#/components/schemas/AppClients"
default:
$ref: "#/components/responses/Problem"
/api/v1/app/download/{client}/{version}:
get:
operationId: downloadAppClient
tags: [app]
security: []
summary: Файл клиента (кэшируется на сервере, поддерживает Range)
parameters:
- name: client
in: path
required: true
schema:
$ref: "#/components/schemas/AppClient"
- name: version
in: path
required: true
schema:
type: string
pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$'
responses:
"200":
description: Установщик или APK
content:
application/octet-stream:
schema:
type: string
format: binary
default:
$ref: "#/components/responses/Problem"
/api/v1/app/android/latest: /api/v1/app/android/latest:
get: get:
operationId: getAndroidLatest operationId: getAndroidLatest
tags: [app] tags: [app]
security: [] # публично: страница /android раздаёт приложение без входа security: [] # публично: страница /download раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер) summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: | description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -1614,6 +1707,54 @@ paths:
'204': '204':
description: Deleted description: Deleted
/api/v1/me/playlists/{id}/share:
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
post:
tags: [collection]
operationId: sharePlaylist
summary: Создать или вернуть ссылку только для чтения (только владелец)
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistShare'
delete:
tags: [collection]
operationId: revokePlaylistShare
summary: Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку
responses:
default:
$ref: '#/components/responses/Problem'
'204':
description: Доступ по прежней ссылке отключён
/api/v1/shared/playlists/{shareId}:
parameters:
- in: path
name: shareId
required: true
schema: { type: string, format: uuid }
get:
tags: [collection]
operationId: getSharedPlaylist
summary: Прочитать плейлист по действующей ссылке (требуется вход)
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Плейлист с реакциями и ссылками на стрим для текущего слушателя
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
components: components:
parameters: parameters:
SourceId: SourceId:
@@ -1674,6 +1815,11 @@ components:
schema: schema:
$ref: "#/components/schemas/Problem" $ref: "#/components/schemas/Problem"
schemas: schemas:
PlaylistShare:
type: object
required: [share_id]
properties:
share_id: { type: string, format: uuid }
PlaylistInput: PlaylistInput:
type: object type: object
required: [name, description, track_ids] required: [name, description, track_ids]
@@ -1710,6 +1856,12 @@ components:
- type: object - type: object
required: [tracks] required: [tracks]
properties: properties:
share_id:
type: string
format: uuid
nullable: true
readOnly: true
description: Действующая ссылка; возвращается только владельцу через /me/playlists
tracks: tracks:
type: array type: array
items: { $ref: '#/components/schemas/Track' } items: { $ref: '#/components/schemas/Track' }
@@ -1727,6 +1879,8 @@ components:
properties: properties:
required: required:
type: boolean type: boolean
choice:
$ref: "#/components/schemas/OnboardingChoice"
artists: artists:
type: array type: array
items: items:
@@ -1762,6 +1916,19 @@ components:
type: object type: object
required: [artist_ids, genres] required: [artist_ids, genres]
properties: properties:
gender:
type: string
nullable: true
enum: [male, female, other]
age_group:
type: string
nullable: true
enum: [under_13, "13_19", "20_29", "30_39", "40_49", "50_59", 60_plus]
description: Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
age_group_year:
type: integer
readOnly: true
description: Год последнего выбора возрастной группы.
artist_ids: artist_ids:
type: array type: array
maxItems: 50 maxItems: 50
@@ -2970,10 +3137,51 @@ components:
description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости
allOf: allOf:
- $ref: "#/components/schemas/TrackFeatures" - $ref: "#/components/schemas/TrackFeatures"
GeniusStats:
type: object
required: [configured, paused, running, total, pending, matched, lyrics, annotations, images, unmatched, errors, last_error, cooldown_until]
properties:
configured: { type: boolean }
paused: { type: boolean }
running: { type: boolean }
total: { type: integer }
pending: { type: integer }
matched: { type: integer }
lyrics: { type: integer }
annotations: { type: integer }
images: { type: integer }
unmatched: { type: integer }
errors: { type: integer }
last_error: { type: string }
cooldown_until: { type: string, format: date-time }
GeniusContent:
type: object
required: [status, lyrics, source_url, song_image_url, artist_image_url, description, annotations]
properties:
status: { type: string }
lyrics: { type: string }
source_url: { type: string }
song_image_url: { type: string }
artist_image_url: { type: string }
description: { type: string }
annotations:
type: array
items:
$ref: "#/components/schemas/GeniusAnnotation"
GeniusAnnotation:
type: object
required: [fragment, text, url, verified, authors]
properties:
fragment: { type: string }
text: { type: string }
url: { type: string }
verified: { type: boolean }
authors: { type: string }
Artist: Artist:
type: object type: object
required: [id, name, album_count, track_count] required: [id, name, album_count, track_count]
properties: properties:
image_url: { type: string }
id: id:
type: string type: string
format: uuid format: uuid
@@ -3446,6 +3654,59 @@ components:
like_rate: like_rate:
type: number type: number
nullable: true nullable: true
AppClient:
type: string
enum: [android, windows, linux, macos]
AppClientBuild:
type: object
required: [version, filename, size, download_url]
properties:
version:
type: string
filename:
type: string
size:
type: integer
format: int64
sha256:
type: string
download_url:
type: string
AppClientFile:
type: object
required: [client, filename, size, download_url]
properties:
client:
$ref: "#/components/schemas/AppClient"
version:
type: string
filename:
type: string
size:
type: integer
format: int64
sha256:
type: string
download_url:
type: string
older:
type: array
items:
$ref: "#/components/schemas/AppClientBuild"
AppClients:
type: object
required: [version, version_code, notes_md, clients]
properties:
version:
type: string
version_code:
type: integer
notes_md:
type: string
clients:
type: array
items:
$ref: "#/components/schemas/AppClientFile"
AndroidRelease: AndroidRelease:
type: object type: object
required: [version, version_code, notes_md, size, sha256, download_url, min_supported] required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
+8 -6
View File
@@ -5,12 +5,14 @@ import { fileURLToPath } from 'node:url';
const root = dirname(fileURLToPath(import.meta.url)); const root = dirname(fileURLToPath(import.meta.url));
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8')); const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
let version = '0.0.0'; let version = process.env.PIPU_VERSION ?? '0.0.0';
try { if (!process.env.PIPU_VERSION) {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim(); try {
version = tag.replace(/^v/, '').replace(/-rc\.\d+$/, ''); const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
} catch { version = tag.replace(/^v/, '');
// local untagged build } catch {
// local untagged build
}
} }
conf.version = version; conf.version = version;
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2)); writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
+4 -1
View File
@@ -6,8 +6,11 @@
"scripts": { "scripts": {
"dev": "tauri dev", "dev": "tauri dev",
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json", "bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles deb",
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
"tauri": "tauri", "tauri": "tauri",
"icons": "tauri icon ../web/public/favicon.svg" "icons": "node ../../scripts/generate-icons.mjs"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.0" "@tauri-apps/cli": "^2.9.0"
+3 -1
View File
@@ -38,5 +38,7 @@ keyring = { version = "3.6.3", features = ["apple-native"] }
[profile.release] [profile.release]
strip = true strip = true
lto = "thin" lto = true
codegen-units = 1 codegen-units = 1
opt-level = "s"
panic = "abort"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+1 -1
View File
@@ -126,7 +126,7 @@ pub fn run() {
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?; let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &quit])?; let menu = Menu::with_items(app, &[&open, &quit])?;
let icon: Image = include_image!("../icons/32x32.png"); let icon: Image = include_image!("icons/32x32.png");
TrayIconBuilder::new() TrayIconBuilder::new()
.icon(icon) .icon(icon)
.menu(&menu) .menu(&menu)
+4 -2
View File
@@ -95,7 +95,9 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|| origin.as_deref().is_some_and(|s| !allowed_origin(s)) || origin.as_deref().is_some_and(|s| !allowed_origin(s))
{ return StatusCode::FORBIDDEN.into_response(); } { return StatusCode::FORBIDDEN.into_response(); }
let prefix = format!("/{}/api/", proxy.secret); let prefix = format!("/{}/api/", proxy.secret);
let Some(tail) = request.uri().path().strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); }; let path = request.uri().path().to_owned();
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
let tail = tail.to_owned();
// Prevent URL normalization escaping the configured API path, including encoded segments. // Prevent URL normalization escaping the configured API path, including encoded segments.
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e") if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f") || tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
@@ -106,7 +108,7 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
let mut response = if request.method() == Method::OPTIONS { let mut response = if request.method() == Method::OPTIONS {
StatusCode::NO_CONTENT.into_response() StatusCode::NO_CONTENT.into_response()
} else { } else {
match transfer(&proxy, request, tail.to_owned()).await { match transfer(&proxy, request, tail).await {
Ok(response) => response, Ok(response) => response,
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(), Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
} }
+1 -1
View File
@@ -24,7 +24,7 @@
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
"linux": { "linux": {
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] }, "deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
"appimage": { "bundleMediaFramework": true } "appimage": { "bundleMediaFramework": false }
}, },
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } } "windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
} }
+7
View File
@@ -0,0 +1,7 @@
# Android
- `src/player/controller.ts` — фасад и зеркало native-очереди; `service.ts` — фоновые события. Владельцем воспроизводимой очереди остаётся TrackPlayer.
- Общая политика волны — `packages/core/src`; офлайн-адаптация — `src/player/offlineWave.ts`, `src/offline`.
- Сессия/настройки — `src/lib`; маршруты — `src/app`. Сохраняй работу при выгруженном UI и изоляцию офлайн-данных по пользователю/серверу.
- `pnpm --filter @pipu/mobile typecheck`, `pnpm --filter @pipu/mobile test`.
- Изменения native playback требуют проверки на устройстве: background, смена трека, offline→online. Unit-тесты не заменяют эту проверку.
+6
View File
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
name: 'Pipu Music', name: 'Pipu Music',
slug: 'pipu-music', slug: 'pipu-music',
scheme: 'pipu', scheme: 'pipu',
icon: './assets/icon.png',
version, version,
orientation: 'portrait', orientation: 'portrait',
userInterfaceStyle: 'automatic', userInterfaceStyle: 'automatic',
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
newArchEnabled: false, newArchEnabled: false,
backgroundColor: '#05070D', backgroundColor: '#05070D',
android: { android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
monochromeImage: './assets/monochrome-icon.png',
backgroundColor: '#05070D',
},
package: 'app.pipu.music', package: 'app.pipu.music',
versionCode, versionCode,
edgeToEdgeEnabled: true, edgeToEdgeEnabled: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+5
View File
@@ -9,6 +9,7 @@ import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks'; import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme'; import { useColors } from '../../theme';
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit'; import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
import { OnboardingGate } from '../../ui/Onboarding';
import { WaveOrb } from '../../ui/wave/WaveOrb'; import { WaveOrb } from '../../ui/wave/WaveOrb';
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */ /** Имена из /wave/options (Material Symbols) → MaterialIcons. */
@@ -22,6 +23,10 @@ function iconFor(name: string): IconName {
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md). * На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
*/ */
export default function HomeScreen() { export default function HomeScreen() {
return <OnboardingGate><WaveHome /></OnboardingGate>;
}
function WaveHome() {
const colors = useColors(); const colors = useColors();
const { t } = useTranslation(); const { t } = useTranslation();
const tr = t as unknown as (k: string) => string; const tr = t as unknown as (k: string) => string;
+3
View File
@@ -15,6 +15,7 @@ import { player } from '../../player/controller';
import { radius, useColors } from '../../theme'; import { radius, useColors } from '../../theme';
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit'; import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
import { useDownloads } from '../../ui/downloads'; import { useDownloads } from '../../ui/downloads';
import { RecommendationSettings } from '../../ui/Onboarding';
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings'; import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
import { useUpdateState } from '../../ui/UpdateDialog'; import { useUpdateState } from '../../ui/UpdateDialog';
@@ -74,6 +75,8 @@ export default function ProfileScreen() {
@{user.username} · {t(`roles.${user.role}`)} @{user.username} · {t(`roles.${user.role}`)}
</Muted> </Muted>
<RecommendationSettings />
<Section> <Section>
<Row label={t('language.label')}> <Row label={t('language.label')}>
<Segmented<Locale> <Segmented<Locale>
+6
View File
@@ -91,6 +91,12 @@ export default function PlayerScreen() {
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}> <Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')} {track.artists.map((a) => a.name).join(', ')}
</Text> </Text>
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
<Icon name="playlist-add" size={24} color={colors.text2} />
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
</Pressable>
</View> </View>
<View style={{ gap: 6 }}> <View style={{ gap: 6 }}>
+17 -4
View File
@@ -1,11 +1,11 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { playlistShareUrl, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native'; import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
import { useSession, useUser } from '../lib/session'; import { useSession, useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller'; import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit'; import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
@@ -78,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
function Detail({ id }: { id: string }) { function Detail({ id }: { id: string }) {
const session = useSession(); const session = useSession();
const { server } = useSessionCtx();
const user = useUser(); const user = useUser();
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -89,6 +90,15 @@ function Detail({ id }: { id: string }) {
const key = ['m-playlists', user.id, id]; 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 query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
const p = query.data; const p = query.data;
const sharing = useMutation({ mutationFn: async () => {
if (!server || !p) throw new Error('Server unavailable');
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') });
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
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 } })), 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'] }); }, onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } }); onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
@@ -105,11 +115,14 @@ function Detail({ id }: { id: string }) {
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching} return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
ListHeaderComponent={<View style={{ padding:16,gap:12 }}> ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} /> <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> <ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
{query.isPending && <Muted>{t('playlists.loading')}</Muted>} {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> {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.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('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
<Button title={t('playlists.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
<Muted>{t('playlists.share_hint')}</Muted>
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(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.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}),[ <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}),[
+6 -1
View File
@@ -272,7 +272,7 @@ class Downloads {
if (track.album.cover_url) { if (track.album.cover_url) {
const cover = this.cover(track.album.id); const cover = this.cover(track.album.id);
const info = await FileSystem.getInfoAsync(cover); const info = await FileSystem.getInfoAsync(cover);
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined); if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
} }
const info = await FileSystem.getInfoAsync(this.file(id)); const info = await FileSystem.getInfoAsync(this.file(id));
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track); await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
@@ -286,4 +286,9 @@ class Downloads {
} }
} }
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
export function sizedCover(url: string, size: number): string {
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
}
export const downloads = new Downloads(); export const downloads = new Downloads();
+53 -51
View File
@@ -1,6 +1,11 @@
import NetInfo from '@react-native-community/netinfo'; import NetInfo from '@react-native-community/netinfo';
import { import {
EventBuffer, EventBuffer,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveInfo,
type WaveOutcome,
type WaveFeedback,
PlayAttempt, PlayAttempt,
normalizeGain, normalizeGain,
unwrap, unwrap,
@@ -17,19 +22,14 @@ import TrackPlayer, {
} from 'react-native-track-player'; } from 'react-native-track-player';
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs'; import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads } from '../offline/downloads'; import { downloads, sizedCover } from '../offline/downloads';
import { PersistentEvents } from '../offline/events'; import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave'; import { offlineWave } from './offlineWave';
import { eventId } from './eventId'; import { eventId } from './eventId';
export { offlineWave }; export { offlineWave };
export interface WaveInfo { export type { WaveInfo } from '@pipu/core';
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export interface PlayerState { export interface PlayerState {
ready: boolean; ready: boolean;
@@ -44,10 +44,6 @@ export interface PlayerState {
wave: WaveInfo | null; wave: WaveInfo | null;
} }
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_000; const SAVE_EVERY_MS = 10_000;
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id)); const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
@@ -84,8 +80,13 @@ class Controller {
private attempt: PlayAttempt | null = null; private attempt: PlayAttempt | null = null;
private lastPositionMs = 0; private lastPositionMs = 0;
private lastSave = 0; private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = []; private readonly wavePolicy = new WaveCoordinator({
private waveLoading = false; next: async (id: string, feedback: WaveFeedback[]) => unwrap(
await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id } }, body: { feedback },
}),
),
});
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
private setupDone: Promise<void> | null = null; private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
@@ -224,15 +225,18 @@ class Controller {
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> { private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality(); const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети. // Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
return tracks.map((t) => ({ return tracks.map((t) => {
id: t.id, const cover = absolute(this.server, t.album.cover_url);
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`, return {
title: t.title, id: t.id,
artist: t.artists.map((a) => a.name).join(', '), url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
album: t.album.title, title: t.title,
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined), artist: t.artists.map((a) => a.name).join(', '),
duration: t.duration_ms / 1000, album: t.album.title,
})); artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
duration: t.duration_ms / 1000,
};
});
} }
async playList(tracks: Track[], index: number, context: PlayContext) { async playList(tracks: Track[], index: number, context: PlayContext) {
@@ -305,18 +309,22 @@ class Controller {
if (!this.session) return; if (!this.session) return;
await this.setup(); await this.setup();
this.leaveWave(); this.leaveWave();
const version = this.wavePolicy.version;
let res; let res;
try { try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } })); res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
} catch (e) { } catch (e) {
if (!this.wavePolicy.isCurrent(version)) return;
// Нет связи с сервером — офлайн-волна из скачанного. // Нет связи с сервером — офлайн-волна из скачанного.
const local = offlineWave(downloads.downloaded()); const local = offlineWave(downloads.downloaded());
if (!local.length) throw e; if (!local.length) throw e;
return this.playList(local, 0, { type: 'collection', id: 'downloads' }); return this.playList(local, 0, { type: 'collection', id: 'downloads' });
} }
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter(playable); const tracks = res.tracks.filter(playable);
if (!tracks.length) return; if (!tracks.length) return;
this.finishAttempt(this.lastPositionMs / 1000); this.finishAttempt(this.lastPositionMs / 1000);
this.wavePolicy.activate(res.session_id);
this.update({ this.update({
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id }, queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback }, wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
@@ -329,13 +337,16 @@ class Controller {
async setWaveContext(activity: string | null, mood: string | null) { async setWaveContext(activity: string | null, mood: string | null) {
const wave = this.state.wave; const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood); if (!wave || !this.session) return this.startWave(activity, mood);
const version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap( const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', { await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } }, params: { path: { id: wave.sessionId } },
body: { activity, mood }, body: { activity, mood },
}), }),
); );
await this.replaceUpcoming(res.tracks); if (!this.wavePolicy.isCurrent(version)) return;
await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version));
if (!this.wavePolicy.isCurrent(version)) return;
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } }); this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе). // Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
if (res.tracks.some(playable)) { if (res.tracks.some(playable)) {
@@ -348,8 +359,8 @@ class Controller {
private leaveWave() { private leaveWave() {
const wave = this.state.wave; const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return; if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null }); this.update({ wave: null });
void this.session?.client void this.session?.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } }) .DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
@@ -357,46 +368,36 @@ class Controller {
} }
private noteWave(trackId: string, outcome: WaveOutcome) { private noteWave(trackId: string, outcome: WaveOutcome) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome }); this.wavePolicy.note(trackId, outcome);
} }
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */ /** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
private async replaceUpcoming(tracks: Track[]) { private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
const keep = this.state.queue.slice(0, this.state.index + 1); const keep = this.state.queue.slice(0, this.state.index + 1);
await TrackPlayer.removeUpcomingTracks();
const add = tracks.filter(playable); const add = tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add)); const native = await this.toRntp(add);
this.update({ queue: [...keep, ...add] }); if (!current()) return;
await TrackPlayer.removeUpcomingTracks();
if (!current()) return;
await TrackPlayer.add(native);
if (current()) this.update({ queue: [...keep, ...add] });
} }
private async refillWave(refresh = false) { private async refillWave(refresh = false) {
const wave = this.state.wave; if (!this.session) return;
if (!wave || !this.session || this.waveLoading) return;
const remaining = this.state.queue.length - this.state.index - 1; const remaining = this.state.queue.length - this.state.index - 1;
if (!refresh && remaining > WAVE_REFILL_AT) return; await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
this.waveLoading = true; if (refresh) await this.replaceUpcoming(res.tracks, current);
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return;
if (refresh) await this.replaceUpcoming(res.tracks);
else { else {
const add = res.tracks.filter(playable); const add = res.tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add)); const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
this.update({ queue: [...this.state.queue, ...add] }); this.update({ queue: [...this.state.queue, ...add] });
} }
this.update({ wave: { ...wave, fallback: res.fallback } }); if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
} catch { });
this.waveFeedback = [...feedback, ...this.waveFeedback];
} finally {
this.waveLoading = false;
}
} }
// --- состояние --- // --- состояние ---
@@ -440,6 +441,7 @@ class Controller {
} }
async reset() { async reset() {
this.wavePolicy.activate(null);
this.attempt = null; this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null }); this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined); if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
+85
View File
@@ -0,0 +1,85 @@
import type { components } from '@pipu/api-client';
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
import { useColors } from '../theme';
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit';
type Options = components['schemas']['Onboarding'];
type Choice = components['schemas']['OnboardingChoice'];
function useOnboarding() {
const session = useSession();
const user = useUser();
const { server } = useSessionCtx();
return useQuery({ queryKey: ['onboarding', server, user.id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: 0, refetchInterval: 30_000 });
}
export function OnboardingGate({ children }: { children: ReactNode }) {
const query = useOnboarding();
const client = useQueryClient();
if (query.isPending) return <Screen><ActivityIndicator /></Screen>;
if (!query.data?.required) return <>{children}</>;
return <Screen><ScrollView contentContainerStyle={{ padding: 16 }}>
<Onboarding options={query.data} onDone={() => void client.invalidateQueries({ queryKey: ['onboarding'] })} />
</ScrollView></Screen>;
}
export function RecommendationSettings() {
const { t } = useTranslation();
const query = useOnboarding();
const client = useQueryClient();
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
return <View>
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
<ErrorBox>{query.error ? errorText(query.error) : null}</ErrorBox>
</View>;
}
function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [choice, setChoice] = useState<Choice>(options.choice ?? { artist_ids: [], genres: [], gender: null, age_group: null });
const save = useMutation({ mutationFn: async (skip: boolean) => unwrap(await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : choice,
})), onSuccess: onDone });
const toggle = (key: 'artist_ids' | 'genres', value: string) => setChoice((old) => ({ ...old,
[key]: old[key].includes(value) ? old[key].filter((x) => x !== value) : [...old[key], value],
}));
return <View style={{ gap: 20 }}>
<H1>{t('onboarding.title')}</H1><Muted>{t('onboarding.subtitle')}</Muted>
<Choices title={t('onboarding.gender')} items={[{ value: '', label: t('onboarding.unspecified') }, ...GENDERS.map((g) => ({ value: g, label: t(`onboarding.genders.${g}`) }))]}
selected={[choice.gender ?? '']} onPick={(v) => setChoice((old) => ({ ...old, gender: (v || null) as Choice['gender'] }))} />
<Choices title={t('onboarding.age_group')} items={[{ value: '', label: t('onboarding.unspecified') }, ...AGE_GROUPS.map((g) => ({ value: g, label: t(`onboarding.ages.${g}`) }))]}
selected={[choice.age_group ?? '']} onPick={(v) => setChoice((old) => ({ ...old, age_group: (v || null) as Choice['age_group'] }))} />
<Muted>{t('onboarding.age_hint')}</Muted>
<Choices title={t('onboarding.artists')} items={options.artists.map((a) => ({ value: a.id, label: a.name }))} selected={choice.artist_ids} onPick={(v) => toggle('artist_ids', v)} />
<Choices title={t('onboarding.genres')} items={options.genres.map((g) => ({ value: g.name, label: g.name }))} selected={choice.genres} onPick={(v) => toggle('genres', v)} />
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
<Button title={t(editing ? 'settings.save' : 'onboarding.done')} busy={save.isPending} onPress={() => save.mutate(false)} />
<Button title={t(editing ? 'form.cancel' : 'onboarding.skip')} variant="ghost" disabled={save.isPending} onPress={() => editing ? onDone() : save.mutate(true)} />
</View>;
}
function Choices({ title, items, selected, onPick }: { title: string; items: { value: string; label: string }[]; selected: string[]; onPick: (value: string) => void }) {
const colors = useColors();
return <View style={{ gap: 10 }}><Text style={{ color: colors.text, fontWeight: '600' }}>{title}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>{items.map((item) => {
const on = selected.includes(item.value);
return <Pressable key={item.value} accessibilityRole="button" accessibilityState={{ selected: on }} onPress={() => onPick(item.value)}
style={{ padding: 12, borderRadius: 20, backgroundColor: on ? colors['accent-fill'] : colors.surface2 }}>
<Text style={{ color: on ? '#fff' : colors.text }}>{item.label}</Text>
</Pressable>;
})}</View>
</View>;
}
+2 -2
View File
@@ -20,7 +20,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session'; import { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads'; import { downloads, sizedCover } from '../offline/downloads';
import { absolute } from '../player/controller'; import { absolute } from '../player/controller';
import { radius, useColors, type Colors } from '../theme'; import { radius, useColors, type Colors } from '../theme';
@@ -161,7 +161,7 @@ export function Cover({
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200; const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети. // У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined)); const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined); const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
const bg = album.cover_colors?.[0] ?? colors.surface3; const bg = album.cover_colors?.[0] ?? colors.surface3;
return ( return (
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}> <View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
+3 -3
View File
@@ -1,7 +1,7 @@
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core'; import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia'; import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { AppState, Pressable, View } from 'react-native'; import { AppState, Pressable, View } from 'react-native';
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated'; import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
}, []), }, []),
); );
const [foreground, setForeground] = useState(AppState.currentState === 'active'); const [foreground, setForeground] = useState(AppState.currentState === 'active');
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string]; const palette = useMemo(() => wavePalette(colors), [colors]);
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced }); const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
const state = useSharedValue<WaveState>(initialWaveState(size)); const state = useSharedValue<WaveState>(initialWaveState(size));
+72
View File
@@ -0,0 +1,72 @@
import type { AuthSession, Track } from '@pipu/core';
import { beforeEach, expect, it, vi } from 'vitest';
vi.mock('@react-native-community/netinfo', () => ({ default: {
fetch: async () => ({ type: 'wifi' }), addEventListener: () => () => {},
} }));
vi.mock('../src/lib/prefs', () => ({
qualityPrefs: { load: async () => ({ wifi: 'high', cellular: 'medium' }) },
playerCache: { clearIfRequested: async () => {} },
}));
vi.mock('../src/offline/events', () => ({ PersistentEvents: class {} }));
vi.mock('../src/offline/downloads', () => ({
downloads: { isDownloaded: () => false, localTrack: () => undefined, localCover: () => undefined, downloaded: () => [] },
sizedCover: (s: string) => s,
}));
vi.mock('react-native-track-player', () => ({
default: {
isServiceRunning: async () => true, setupPlayer: async () => {}, updateOptions: async () => {},
addEventListener: vi.fn(), reset: vi.fn().mockResolvedValue(undefined),
add: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined),
skip: vi.fn().mockResolvedValue(undefined),
},
AppKilledPlaybackBehavior: {}, Capability: {}, Event: {}, State: {},
}));
function track(id: string): Track {
return { id, title: id, duration_ms: 1000, artists: [],
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
genres: [], disc_no: 1, track_no: 1, available: true, stream_url: `/s/${id}?sig=x`, reaction: null, features: null };
}
function deferred<T>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((yes) => { resolve = yes; });
return { promise, resolve };
}
const reply = (id: string) => ({ data: { session_id: id, activity: null, mood: null, fallback: false, tracks: [track(id)] }, response: new Response() });
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); });
it('ignores a wave creation response after the player is reset', async () => {
const { player } = await import('../src/player/controller');
const native = (await import('react-native-track-player')).default;
const pending = deferred<ReturnType<typeof reply>>();
const post = vi.fn().mockReturnValue(pending.promise);
player.attach({ client: { POST: post } } as unknown as AuthSession, 'http://localhost', null);
const start = player.startWave(null, null);
await vi.waitFor(() => expect(post).toHaveBeenCalled());
await player.reset();
pending.resolve(reply('old'));
await start;
expect(player.getState().wave).toBeNull();
expect(player.getState().queue).toEqual([]);
expect(native.add).not.toHaveBeenCalled();
});
it('does not let a late context response replace a manually selected playlist', async () => {
const { player } = await import('../src/player/controller');
const pending = deferred<ReturnType<typeof reply>>();
const session = { client: {
POST: vi.fn().mockResolvedValue(reply('wave')),
PATCH: vi.fn().mockReturnValue(pending.promise), DELETE: vi.fn().mockResolvedValue({}),
} };
player.attach(session as unknown as AuthSession, 'http://localhost', null);
await player.startWave(null, null);
const change = player.setWaveContext('focus', null);
await player.playList([track('manual')], 0, { type: 'album', id: 'album' });
pending.resolve(reply('stale'));
await change;
expect(player.getState().wave).toBeNull();
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
expect(player.getState().context?.type).toBe('album');
});
+8
View File
@@ -0,0 +1,8 @@
# Web и desktop UI
- Маршруты: `src/app/router.tsx`; сессия: `src/app/session.tsx`; страницы собирают возможности, `src/features` владеет сценариями.
- Воспроизведение: `src/player/player.ts` — фасад; адаптеры рядом. Общие правила очереди, событий и волны — `packages/core/src`.
- Загрузки: `src/features/uploads`; страница очереди — `src/pages/admin/UploadQueuePage.tsx`. Сохраняй query keys/invalidation, права и поведение диалогов.
- `pnpm --filter @pipu/web typecheck`; `pnpm --filter @pipu/web test`. Для локальной проверки можно передать путь теста после `test`.
- UI/навигация/диалоги: `pnpm --filter @pipu/web test:e2e`. Проверки используют fake API; реальная серверная авторизация покрывается Go отдельно.
- Restore не запускает музыку и не создаёт событие прослушивания. Ошибка preload не пропускает трек. Dispose должен освобождать обработчики.
+20 -2
View File
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
for (const width of [320, 1280]) { for (const width of [320, 1280]) {
test(`playlists at ${width}px`, async ({ page }, info) => { test(`playlists at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice'); await page.getByLabel('Логин', { exact: true }).fill('alice');
@@ -10,14 +11,19 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Войти', exact: true }).click(); await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (width < 768) 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('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click(); const playlistsHeader = page.locator('header').filter({
has: page.getByRole('heading', { name: 'Плейлисты', exact: true }),
});
await playlistsHeader.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги'); await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом'); await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click(); await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино'); await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter'); await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click(); await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click(); await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByRole('dialog')).toHaveCount(0);
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click(); if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
@@ -25,10 +31,22 @@ for (const width of [320, 1280]) {
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click(); await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
await page.getByRole('button', { name: 'Управлять треками' }).click(); await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible(); await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => { await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!; const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth); return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1); })).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('playlist.png') }); await page.screenshot({ path: info.outputPath('playlist.png') });
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
}); });
} }
+9
View File
@@ -48,6 +48,15 @@ test('settings expose crossfade and loudness normalization', async ({ page }) =>
const slider = page.getByRole('slider', { name: /Кроссфейд/ }); const slider = page.getByRole('slider', { name: /Кроссфейд/ });
await slider.fill('4'); await slider.fill('4');
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible(); await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
await page.getByRole('button', { name: 'Мужской', exact: true }).click();
await page.getByRole('button', { name: '13–19', exact: true }).click();
const form = page.getByRole('heading', { name: 'Что вы любите слушать?' }).locator('../..');
await form.getByRole('button', { name: 'Сохранить', exact: true }).click();
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
await expect(page.getByRole('button', { name: 'Мужской', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: '13–19', exact: true })).toHaveAttribute('aria-pressed', 'true');
}); });
test('9. login page switches between Russian and English', async ({ page }) => { test('9. login page switches between Russian and English', async ({ page }) => {
await page.goto('/login'); await page.goto('/login');
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<title>pipu</title> <title>pipu</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+17 -1
View File
@@ -1 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#9CC6FF"/><stop offset=".55" stop-color="#2F80FF"/><stop offset="1" stop-color="#3A1FA8"/></radialGradient></defs><circle cx="16" cy="16" r="14" fill="url(#g)"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="orb" cx="28%" cy="18%" r="95%">
<stop stop-color="#9CC6FF"/>
<stop offset=".42" stop-color="#2F80FF"/>
<stop offset=".78" stop-color="#6354E8"/>
<stop offset="1" stop-color="#3A1FA8"/>
</radialGradient>
<linearGradient id="note" x1="0" y1="0" x2=".3" y2="1">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#DFEBFF"/>
</linearGradient>
</defs>
<circle cx="256" cy="256" r="240" fill="url(#orb)"/>
<path d="M208 348V164H270C356 164 356 280 270 280H208" fill="none" stroke="url(#note)" stroke-width="44" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="181" cy="348" rx="49" ry="36" fill="url(#note)" transform="rotate(-18 181 348)"/>
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 840 B

+101 -2
View File
@@ -10,7 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App'; import { App } from './App';
import { createAppRouter, safeRedirect } from './app/router'; import { createAppRouter, safeRedirect } from './app/router';
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi'; import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup); afterEach(cleanup);
@@ -291,7 +291,7 @@ describe('player', () => {
await user.click(screen.getByRole('button', { name: 'Войти' })); await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'Закрой за мной дверь' }); await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
expect(router.state.location.pathname).toBe(path); expect(router.state.location.pathname).toBe(path);
await user.click(screen.getByRole('listitem')); await user.click(screen.getByRole('button', { name: 'Слушать' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' })); await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована'); expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
@@ -409,6 +409,26 @@ describe('safeRedirect', () => {
}); });
describe('playlists', () => { describe('playlists', () => {
it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('listitem')[0]!);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Из плеера');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(api.playlists.size).toBe(1));
expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
// Simulate automatic advancement while the picker is open.
await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
});
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => { it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice }); const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
@@ -459,3 +479,82 @@ describe('playlists', () => {
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство'); expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
}); });
}); });
describe('recommendation preferences', () => {
it('saves demographics, edits the age group and asks again after an admin reset', async () => {
const admin = makeUser('owner', 'admin');
const { user, api, router, session } = await renderApp([admin], '/settings', { signedInAs: admin });
await user.click(await screen.findByRole('button', { name: 'Настроить рекомендации' }));
await user.click(await screen.findByRole('button', { name: 'Мужской' }));
await user.click(screen.getByRole('button', { name: '13–19' }));
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
await screen.findByRole('button', { name: 'Настроить рекомендации' });
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toMatchObject({ gender: 'male', age_group: '13_19' });
await user.click(screen.getByRole('button', { name: 'Настроить рекомендации' }));
expect(await screen.findByRole('button', { name: '13–19' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: '20–29' }));
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
await screen.findByRole('button', { name: 'Настроить рекомендации' });
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: admin.id } } });
await router.navigate({ to: '/' });
await screen.findByRole('heading', { name: 'Что вы любите слушать?' });
expect(screen.getByRole('button', { name: 'Мужской' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '20–29' })).toHaveAttribute('aria-pressed', 'false');
await user.click(screen.getByRole('button', { name: 'Пропустить' }));
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
});
});
describe('playlist sharing', () => {
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
const alice = makeUser('alice', 'user');
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
const playlist = created.data!;
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await screen.findByRole('heading', { name: 'Общий микс' });
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
const shareId = api.playlists.get(playlist.id)!.share_id!;
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('heading', { name: 'Общий микс' });
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
await screen.findByRole('contentinfo', { name: 'Плеер' });
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('alert');
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
});
it('returns to the shared playlist after login without starting playback', async () => {
const bob = makeUser('bob', 'user');
const shareId = '00000000-0000-4000-9001-000000000001';
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
await screen.findByRole('heading', { name: 'Вход' });
await user.type(screen.getByLabelText('Логин'), bob.username);
await user.type(screen.getByLabelText('Пароль'), bob.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'От друга' });
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
});
});
+13 -2
View File
@@ -69,8 +69,16 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError, errorComponent: RouteError,
}); });
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве. // Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage }); const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage });
const androidAppRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/android',
beforeLoad: () => {
throw redirect({ to: '/download' });
},
component: AppDownloadPage,
});
const connectRoute = createRoute({ const connectRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -137,6 +145,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage }); 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 trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage }); 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 playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
@@ -262,6 +271,7 @@ const adminSettingsRoute = createRoute({
}); });
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
downloadAppRoute,
androidAppRoute, androidAppRoute,
connectRoute, connectRoute,
loginRoute, loginRoute,
@@ -271,6 +281,7 @@ const routeTree = rootRoute.addChildren([
homeRoute, homeRoute,
trackRoute, trackRoute,
playlistsRoute, playlistsRoute,
sharedPlaylistRoute,
playlistRoute, playlistRoute,
settingsRoute, settingsRoute,
searchRoute, searchRoute,
+57 -2
View File
@@ -8,12 +8,18 @@
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
} }
.cover img { .coverImg {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease;
}
.coverImgOn {
opacity: 1;
} }
.round { .round {
@@ -59,10 +65,52 @@
width: 128px; width: 128px;
} }
.cardCoverWrap {
position: relative;
margin-bottom: 4px;
overflow: hidden;
border-radius: 12px;
}
.cardCover { .cardCover {
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 12px; border-radius: 12px;
margin-bottom: 4px; transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card:hover .cardCover,
.card:focus-within .cardCover {
transform: scale(1.04);
}
.cardPlay {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 1;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
color: #05070d;
opacity: 0;
transform: translateY(6px);
transition:
opacity 0.18s,
transform 0.18s;
}
.card:hover .cardPlay,
.card:focus-within .cardPlay,
.cardPlay:focus-visible {
opacity: 1;
transform: none;
}
.cardPlay:active {
transform: scale(0.94);
} }
.cardTitle { .cardTitle {
@@ -104,6 +152,12 @@
min-height: 56px; min-height: 56px;
padding: 6px 12px; padding: 6px 12px;
border-radius: 10px; border-radius: 10px;
animation: pipuIn 0.2s ease-out both;
animation-delay: var(--stagger, 0ms);
}
.trackStatic {
animation: none;
} }
.track:has(.trackAlbum) { .track:has(.trackAlbum) {
@@ -272,6 +326,7 @@
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.cardPlay { opacity: 1; transform: none; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; } .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; } .track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
+37 -4
View File
@@ -1,10 +1,12 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, type ReactNode } from 'react'; import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists'; import { AddToPlaylist } from './playlists';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context'; import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player'; import type { PlayContext } from '../player/player';
@@ -28,6 +30,7 @@ function fallbackColors(id: string): string[] {
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */ /** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string { export function coverSrc(url: string, cssPx: number): string {
if (!url.includes('/api/v1/covers/')) return url;
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1); const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200; const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`; return `${url}&size=${size}`;
@@ -58,7 +61,7 @@ export function Cover({
aria-hidden aria-hidden
> >
{album.cover_url ? ( {album.cover_url ? (
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" /> <CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
) : ( ) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} /> <Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)} )}
@@ -82,10 +85,39 @@ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
); );
} }
function CoverImg({ src }: { src: string }) {
const [ready, setReady] = useState(false);
return (
<img
src={src}
alt=""
loading="lazy"
className={cx(s.coverImg, ready && s.coverImgOn)}
onLoad={() => setReady(true)}
/>
);
}
export function AlbumCard({ album }: { album: AlbumSummary }) { export function AlbumCard({ album }: { album: AlbumSummary }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const play = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
void (async () => {
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
})();
};
return ( return (
<Link to="/album/$id" params={{ id: album.id }} className={s.card}> <Link to="/album/$id" params={{ id: album.id }} className={s.card}>
<Cover album={album} size="100%" className={s.cardCover} /> <span className={s.cardCoverWrap}>
<Cover album={album} size="100%" className={s.cardCover} />
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
<Icon src={playIcon} size={22} />
</button>
</span>
<span className={s.cardTitle}>{album.title}</span> <span className={s.cardTitle}>{album.title}</span>
<span className={s.cardMeta}> <span className={s.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')} {[album.artist.name, album.year].filter(Boolean).join(' · ')}
@@ -154,7 +186,8 @@ export function TrackList({
role="listitem" role="listitem"
tabIndex={tr.available ? 0 : -1} tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined} aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)} className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
title={tr.available ? undefined : t('catalog.unavailable')} title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => { onClick={(e) => {
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play(); if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
+17 -13
View File
@@ -1,12 +1,12 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession, useUser } from '../app/session';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui'; import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { queueIcon } from '../shared/ui/icons'; import { playlistAddIcon } from '../shared/ui/icons';
import s from './catalog.module.css'; import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist']; export type Playlist = components['schemas']['Playlist'];
@@ -18,10 +18,13 @@ export function usePlaylists(enabled = true) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) }); queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
} }
export function usePlaylist(id: string) { export function usePlaylist(id: string, shared = false) {
const session = useSession(); const session = useSession();
return useQuery({ queryKey: ['playlists', id], const user = useUser();
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) }); return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
queryFn: async () => shared
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
: unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
} }
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } { export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
@@ -61,7 +64,7 @@ export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
} }
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */ /** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) { const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const cache = useQueryClient(); const cache = useQueryClient();
@@ -84,14 +87,15 @@ function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: ()
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button> <Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div> </div>
</Dialog>; </Dialog>;
} });
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) { export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
const close = useCallback(() => setSelectedIds(null), []);
return <> 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> {compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>} : <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />} {selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
</>; </>;
} }
@@ -0,0 +1,69 @@
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useErrorText } from '../../shared/lib/errors';
import { useUploadsApi } from './queries';
import { useTranslation } from 'react-i18next';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
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: () => api.updateBatch(batchId, {
...(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>
);
}
@@ -0,0 +1,56 @@
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { mobileFixtures } from '../../test/mobileFixtures';
import { EditDialog } from './EditDialog';
import type { UploadFile } from './common';
afterEach(cleanup);
async function fixture(fail = false) {
const file = (mobileFixtures['/api/v1/admin/uploads'] as { items: UploadFile[] }).items[0]!;
const requests: unknown[] = [];
const fetchImpl: typeof fetch = async (input) => {
const req = input as Request;
if (req.method === 'PATCH') {
requests.push(await req.json());
return new Response(JSON.stringify(fail ? { status: 409, code: 'uploads.wrong_status' } : file), { status: fail ? 409 : 200 });
}
return new Response(JSON.stringify(mobileFixtures['/api/v1/admin/sources']));
};
const session = new AuthSession({ baseUrl: 'http://localhost', fetch: fetchImpl, clientType: 'web' });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
const onSaved = vi.fn();
const onClose = vi.fn();
const i18n = await createI18n('ru');
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={queryClient}>
<EditDialog file={file} onSaved={onSaved} onClose={onClose} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
const user = userEvent.setup();
await user.type(screen.getByLabelText('Исполнитель', { exact: true }), 'Кино');
await user.type(screen.getByLabelText('Название', { exact: true }), 'Перемен');
return { requests, onSaved, onClose, user };
}
it('saves edited metadata with the target source and closes only after success', async () => {
const f = await fixture();
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(f.onSaved).toHaveBeenCalledOnce());
expect(f.onClose).toHaveBeenCalledOnce();
expect(f.requests).toEqual([expect.objectContaining({ metadata: expect.objectContaining({ artist: 'Кино', title: 'Перемен' }), target_source_id: expect.any(String) })]);
});
it('keeps the editor and input when a concurrent review rejects the save', async () => {
const f = await fixture(true);
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.getByRole('alert')).toBeVisible());
expect(screen.getByRole('dialog')).toBeVisible();
expect(screen.getByLabelText('Название', { exact: true })).toHaveValue('Перемен');
expect(f.onSaved).not.toHaveBeenCalled();
expect(f.onClose).not.toHaveBeenCalled();
});
@@ -0,0 +1,85 @@
import type { components } from '@pipu/api-client';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import { useUploadsApi, useUploadSources } from './queries';
import type { UploadFile } from './common';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
type Meta = components['schemas']['UploadMeta'];
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
export function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const errorText = useErrorText();
const fieldError = useFieldError();
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
const [source, setSource] = useState(file.target_source_id ?? '');
const sources = useUploadSources();
const save = useMutation({
mutationFn: () => api.updateFile(file.id, { metadata: m, ...(source ? { target_source_id: source } : {}) }),
onSuccess: () => {
onSaved();
onClose();
},
});
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
);
const num = (k: 'year' | 'track' | 'disc') => (
<Field
label={t(`uploads.meta_${k}`)}
value={m[k] === null ? '' : String(m[k])}
inputMode="numeric"
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
/>
);
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
return (
<Dialog
title={t('uploads.edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
<div className={u.grid}>
{text('artist')}
{text('albumartist')}
{text('album')}
{text('title')}
{text('genre')}
{num('year')}
{num('track')}
{num('disc')}
</div>
<label className={s.sessions}>
<span>{t('uploads.target_source')}</span>
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">{t('uploads.target_default')}</option>
{accepting.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<p className={s.note}>{t('uploads.tags_note')}</p>
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
</div>
</Dialog>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { editIcon, refreshIcon } from '../../shared/ui/icons';
import { needsFormatAttention } from './files';
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
import { useTranslation } from 'react-i18next';
import { Button, cx, Icon } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
export function BatchCard({
files,
selectable,
selected,
onToggle,
onEdit,
onEditBatch,
onRetry,
}: {
files: UploadFile[];
selectable: boolean;
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');
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0);
return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}>
{selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)}
<b>{first.author.username}</b>
<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
key={f.id}
file={f}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</section>
);
}
function QueueRow({
file: f,
selectable,
checked,
onCheck,
onEdit,
onRetry,
}: {
file: UploadFile;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
onRetry: () => void;
}) {
const { t, i18n } = useTranslation('admin');
const reason = useReasonText()(f.status_reason);
const m = f.metadata;
const quality = [
f.format?.toUpperCase(),
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
f.bit_depth ? `${f.bit_depth} bit` : null,
f.duration_ms ? formatDuration(f.duration_ms) : null,
formatBytes(f.size_bytes, i18n.language),
]
.filter(Boolean)
.join(' · ');
const editable = f.status === 'pending_review' || f.status === 'import_failed';
return (
<div className={u.queueRow}>
{selectable ? (
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
) : (
<span />
)}
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
{m && (
<span className={u.sub}>
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
.filter(Boolean)
.join(' · ')}
{f.metadata_edited && ` · ${t('uploads.edited')}`}
</span>
)}
<span className={u.sub}>{quality}</span>
<span className={u.sub} title={f.client_rel_path ?? undefined}>
{f.client_rel_path ?? f.original_name}
</span>
</div>
<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>
) : (
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
</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} />
</Button>
)}
{f.status === 'import_failed' && (
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
{t('uploads.retry')}
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dialog } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
export function RejectDialog({
count,
busy,
onClose,
onReject,
}: {
count: number;
busy: boolean;
onClose: () => void;
onReject: (reason: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const [reason, setReason] = useState('');
return (
<Dialog
title={t('uploads.reject_title', { count })}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
{t('uploads.reject')}
</Button>
</>
}
>
<label className={s.sessions}>
<span>{t('uploads.reject_reason')}</span>
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
</label>
</Dialog>
);
}
@@ -0,0 +1,180 @@
import { ApiError } from '@pipu/core';
import type { components } from '@pipu/api-client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { checkIcon, closeIcon, inboxIcon } from '../../shared/ui/icons';
import { useErrorText } from '../../shared/lib/errors';
import { QuotaMeter, type UploadFile, type UploadStatus } from './common';
import { BatchCard } from './QueueRows';
import { BatchEditDialog } from './BatchEditDialog';
import { EditDialog } from './EditDialog';
import { RejectDialog } from './RejectDialog';
import { useUploadQueue, useUploadsApi } from './queries';
import { useTranslation } from 'react-i18next';
import { Button, EmptyState, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
const TABS = {
review: ['pending_review'],
failed: ['import_failed'],
progress: ['uploading', 'processing', 'approved', 'importing'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
} satisfies Record<string, UploadStatus[]>;
type Tab = keyof typeof TABS;
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
export function UploadQueue() {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [tab, setTab] = useState<Tab>('review');
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 = useUploadQueue(TABS[tab], tab, onlyPossible);
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
const refresh = async () => {
setSelected(new Set());
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
};
const review = useMutation({
mutationFn: (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) => api.review(v),
onSuccess: refresh,
});
const retry = useMutation({
mutationFn: api.retry,
onSuccess: refresh,
});
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
const batches = useMemo(() => {
const m = new Map<string, UploadFile[]>();
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
return [...m.values()];
}, [items]);
const toggle = (ids: string[], on: boolean) =>
setSelected((cur) => {
const next = new Set(cur);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
return next;
});
const selectable = tab === 'review' || tab === 'failed';
const ids = [...selected];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.queue_title')}</h1>
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(TABS) as Tab[]).map((k) => (
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
{t(`uploads.tab_${k}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
{t('uploads.only_possible')}
</label>
</div>
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
{batches.map((files) => (
<BatchCard
key={files[0]!.batch_id}
files={files}
selectable={selectable}
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)}
/>
))}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
{ids.length > 0 && (
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
<span>{t('uploads.selected', { count: ids.length })}</span>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setSelected(new Set())}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
{t('uploads.reject')}
</Button>
{tab === 'review' && (
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
{t('uploads.approve')}
</Button>
)}
</div>
)}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && (
<EditDialog
file={editing}
onClose={() => setEditing(null)}
onSaved={() => {
review.reset();
void refresh();
}}
/>
)}
{rejecting && (
<RejectDialog
count={ids.length}
busy={review.isPending}
onClose={() => setRejecting(false)}
onReject={(reason) => {
setRejecting(false);
review.mutate({ decision: 'reject', ids, reason });
}}
/>
)}
</div>
);
}
/** Problem из ответа массового решения → объект, понятный useErrorText. */
function problemError(p: components['schemas']['Problem']) {
return new ApiError(p.status, p.code, p.params ?? {});
}
@@ -0,0 +1,101 @@
import type { components } from '@pipu/api-client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Field, FormError } from '../../shared/ui/ui';
import { useUploadsApi, useUploadSources, useUploadSettings } from './queries';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
type Settings = components['schemas']['UploadSettings'];
const GB = 1024 ** 3;
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
export function UploadSettingsSection() {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const queryClient = useQueryClient();
const errorText = useErrorText();
const settings = useUploadSettings();
const sources = useUploadSources();
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]);
const save = useMutation({
mutationFn: async () => {
const f = form!;
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
return api.updateSettings({
queue_limit_bytes: gb(f.queue), 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,
});
},
onSuccess: async (data) => {
queryClient.setQueryData(['upload-settings'], data);
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
if (!form) return null;
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
return (
<section className={s.section}>
<h2>{t('uploads.settings_title')}</h2>
<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')}
value={form.perUser}
hint={t('uploads.set_per_user_hint')}
onChange={(v) => setForm({ ...form, perUser: v })}
inputMode="decimal"
/>
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<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
?.filter((x) => x.accepts_uploads)
.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</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}>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</div>
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</section>
);
}
function toForm(st: Settings) {
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
return {
queue: gb(st.queue_limit_bytes),
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,
};
}
+26
View File
@@ -0,0 +1,26 @@
import type { ApiClient, components, paths } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
type FilePatch = paths['/api/v1/admin/uploads/files/{id}']['patch']['requestBody']['content']['application/json'];
type BatchPatch = paths['/api/v1/admin/uploads/batches/{id}']['patch']['requestBody']['content']['application/json'];
type SettingsPatch = paths['/api/v1/admin/settings/uploads']['patch']['requestBody']['content']['application/json'];
/** The feature owns transport mapping; views only provide form values. */
export function uploadsApi(client: ApiClient) {
return {
list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
await client.GET('/api/v1/admin/uploads', {
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } },
}),
),
review: async (v: { ids: string[]; decision: 'approve' | 'reject'; reason?: string }) => unwrap(
await client.POST('/api/v1/admin/uploads/review', { body: { file_ids: v.ids, decision: v.decision, reason: v.reason } }),
),
retry: async (id: string) => unwrap(await client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
updateFile: async (id: string, body: FilePatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/files/{id}', { params: { path: { id } }, body })),
updateBatch: async (id: string, body: BatchPatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/batches/{id}', { params: { path: { id } }, body })),
sources: async () => unwrap(await client.GET('/api/v1/admin/sources')).items,
settings: async () => unwrap(await client.GET('/api/v1/admin/settings/uploads')),
updateSettings: async (body: SettingsPatch) => unwrap(await client.PATCH('/api/v1/admin/settings/uploads', { body })),
};
}
+84
View File
@@ -0,0 +1,84 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { formatBytes } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
export type UploadStatus = components['schemas']['UploadStatus'];
export type UploadFile = components['schemas']['UploadFile'];
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
uploading: 'info',
processing: 'info',
pending_review: 'warn',
approved: 'info',
importing: 'info',
imported: 'ok',
import_failed: 'bad',
duplicate: 'muted',
invalid: 'bad',
rejected: 'bad',
withdrawn: 'muted',
expired: 'muted',
};
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
}
export function useUploadQuota() {
const session = useSession();
return useQuery({
queryKey: ['upload-quota'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
});
}
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
export function QuotaMeter() {
const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota();
const id = useId();
if (!quota.data) return null;
const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language);
return (
<div className={u.quota}>
<span className={u.quotaText} id={id}>
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
{q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</span>
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} />
</div>
</div>
);
}
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
export function useReasonText(): (reason: string | null) => string | null {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
return (reason) => {
if (!reason) return null;
const code = reason.split(':')[0]!.trim();
const key = `errors:${code}`;
if (i18n.exists(key)) {
const rest = reason.slice(code.length + 1).trim();
return rest ? `${tr(key)}: ${rest}` : tr(key);
}
return reason;
};
}
+27
View File
@@ -0,0 +1,27 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useSession } from '../../app/session';
import { uploadsApi } from './api';
import { MOVING, type UploadStatus } from './common';
export function useUploadsApi() { return uploadsApi(useSession().client); }
export function useUploadQueue(status: UploadStatus[], tab: string, onlyPossible: boolean) {
const api = useUploadsApi();
return useInfiniteQuery({
queryKey: ['admin-uploads', tab, onlyPossible],
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam }) => api.list(status, onlyPossible, pageParam),
getNextPageParam: (p) => p.next_cursor ?? undefined,
refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
}
export function useUploadSources() {
const api = useUploadsApi();
return useQuery({ queryKey: ['sources'], queryFn: api.sources });
}
export function useUploadSettings() {
const api = useUploadsApi();
return useQuery({ queryKey: ['upload-settings'], queryFn: api.settings });
}
+28 -17
View File
@@ -4,12 +4,12 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog'; import { Cover, TrackList, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists'; import { AddToPlaylist } from '../entities/playlists';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { playIcon, shuffleIcon } from '../shared/ui/icons'; import { playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui'; import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
@@ -23,30 +23,41 @@ export function AlbumPage({ id }: { id: string }) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })), queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
}); });
if (album.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (album.isError) return <NotFound error={album.error} />; if (album.isError) return <NotFound error={album.error} />;
const a = album.data; const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null; if (!a) return null;
const ctx = { type: 'album' as const, id, label: a?.title }; const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a.title };
return ( return (
<div className={s.page}> <div className={s.page}>
<header className={c.hero}> <header className={c.hero}>
{a.cover_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(a.cover_url, 300)} alt="" />
</span>
)}
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />} {colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
<Cover album={a ?? { id }} size={200} /> <Cover album={a} size={200} className={c.heroCover} />
<div className={c.heroBody}> <div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span> <span className={c.kind}>{t('catalog.album')}</span>
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1> <h1 className={c.heroTitle}>{a.title}</h1>
{a && ( <span className={c.heroMeta}>
<span className={c.heroMeta}> <Link to="/artist/$id" params={{ id: a.artist.id }}>
<Link to="/artist/$id" params={{ id: a.artist.id }}> {a.artist.name}
{a.artist.name} </Link>
</Link> {a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)} </span>
</span> {a.tracks.some((tr) => tr.available) && (
)}
{a && a.tracks.some((tr) => tr.available) && (
<div className={c.heroActions}> <div className={c.heroActions}>
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} /> <AddToPlaylist trackIds={a.tracks.map((tr) => tr.id)} />
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}> <Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
{t('catalog.play')} {t('catalog.play')}
</Button> </Button>
@@ -67,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
)} )}
</div> </div>
</header> </header>
{a && <TrackList tracks={a.tracks} numbered context={ctx} />} <TrackList tracks={a.tracks} numbered context={ctx} />
</div> </div>
); );
} }
+93 -32
View File
@@ -1,25 +1,27 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { formatBytes } from '../shared/lib/format'; import { formatBytes } from '../shared/lib/format';
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons'; import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui'; import { Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout'; import { AuthLayout } from './AuthLayout';
import s from './auth.module.css'; import s from './auth.module.css';
/** /**
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку. * Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление). * Сборки — до 8 последних релизов Gitea через сервер; первым предлагается клиент текущей ОС.
*/ */
export function AppDownloadPage() { export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']); const { t, i18n } = useTranslation(['auth', 'common']);
const session = useSession(); const session = useSession();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const detected = useMemo(() => detectAppClient(), []);
const latest = useQuery({ const latest = useQuery({
queryKey: ['android-latest'], queryKey: ['app-clients'],
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null, queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
}); });
const server = location.origin; const server = location.origin;
@@ -28,39 +30,69 @@ export function AppDownloadPage() {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
}); });
const r = latest.data; const data = latest.data;
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
const primary = detected ? files[detected] : undefined;
const others = otherAppClients(detected).filter((id) => files[id]);
return ( return (
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}> <AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
<span className={s.badge}>Android</span> <span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
<h1>{t('app.title')}</h1> <h1>{t('app.title')}</h1>
<p className={s.lead}>{t('app.subtitle')}</p> <p className={s.lead}>{t('app.subtitle')}</p>
{latest.isPending ? null : r ? ( {latest.isPending ? (
<p role="status">{t('common:loading')}</p>
) : latest.isError ? (
<div className={s.hint}>{t('app.unavailable')}</div>
) : data ? (
<> <>
<a className={s.download} href={r.download_url} download> {primary ? (
<Icon src={downloadIcon} size={22} /> <>
<span> <DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: primary.version ?? data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
{t('app.download')} <OlderVersions items={primary.older ?? []} />
<span className={s.downloadMeta}> </>
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)} ) : detected ? (
</span> <div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
</span> ) : null}
</a> {(primary || others.length > 0) && (
<ol className={s.steps}> <ol className={s.steps}>
<li>{t('app.step_install')}</li> {detected && <li>{t(`app.step_install.${detected}`)}</li>}
<li> <li>
{t('app.step_server')} {t('app.step_server')}
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}> <button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
<code>{server}</code> <code>{server}</code>
<Icon src={copied ? checkIcon : copyIcon} size={16} /> <Icon src={copied ? checkIcon : copyIcon} size={16} />
</button> </button>
</li> </li>
<li>{t('app.step_updates')}</li> {detected && <li>{t(`app.step_updates.${detected}`)}</li>}
</ol> </ol>
<details className={s.details}> )}
<summary>{t('app.checksum')}</summary> {primary?.sha256 && (
<code>{r.sha256}</code> <details className={s.details}>
</details> <summary>{t('app.checksum')}</summary>
<code>{primary.sha256}</code>
</details>
)}
{others.length > 0 && (
<div className={s.otherClients}>
<p className={s.otherTitle}>{t('app.other')}</p>
{others.map((id) => {
const file = files[id]!;
return (
<div key={id} className={s.otherBlock}>
<a className={s.downloadAlt} href={file.download_url} download={file.filename}>
{t(`app.download.${id}`)}
<span>
{t('app.version', { version: file.version ?? data.version })} · {formatBytes(file.size, i18n.language)}
</span>
</a>
<OlderVersions items={file.older ?? []} />
</div>
);
})}
</div>
)}
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
</> </>
) : ( ) : (
<div className={s.hint}>{t('app.unavailable')}</div> <div className={s.hint}>{t('app.unavailable')}</div>
@@ -68,3 +100,32 @@ export function AppDownloadPage() {
</AuthLayout> </AuthLayout>
); );
} }
function OlderVersions({ items }: { items: { version: string; filename?: string; size: number; download_url: string }[] }) {
const { t, i18n } = useTranslation(['auth', 'common']);
if (items.length === 0) return null;
return (
<details className={s.older}>
<summary>{t('app.older')}</summary>
{items.map((b) => (
<a key={b.version} className={s.downloadAlt} href={b.download_url} download={b.filename}>
{t('app.version', { version: b.version })}
<span>{formatBytes(b.size, i18n.language)}</span>
</a>
))}
</details>
);
}
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
const { t } = useTranslation('auth');
return (
<a className={s.download} href={href} download>
<Icon src={downloadIcon} size={22} />
<span>
{t(`app.download.${client}`)}
<span className={s.downloadMeta}>{meta}</span>
</span>
</a>
);
}
+35 -26
View File
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { AlbumGrid, Cover, TrackList } from '../entities/catalog'; import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { playIcon, waveIcon } from '../shared/ui/icons'; import { playIcon, waveIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui'; import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
@@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items, queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
}); });
if (artist.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (artist.isError) return <NotFound error={artist.error} />; if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data; const a = artist.data;
if (!a) return null;
const tracks = top.data ?? []; const tracks = top.data ?? [];
const ctx = { type: 'artist' as const, id, label: a?.name }; const ctx = { type: 'artist' as const, id, label: a.name };
const own = albums.data?.filter((al) => al.artist.id === id) ?? []; const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? []; const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
return ( return (
<div className={s.page}> <div className={s.page}>
<header className={c.hero}> <header className={c.hero}>
<Cover album={{ id }} size={168} round /> {a.image_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(a.image_url, 300)} alt="" />
</span>
)}
<Cover album={{ id, cover_url: a.image_url }} size={168} round className={c.heroCover} />
<div className={c.heroBody}> <div className={c.heroBody}>
<span className={c.kind}>{t('catalog.artist')}</span> <span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1> <h1 className={c.heroTitle}>{a.name}</h1>
{a && ( <span className={c.heroMeta}>
<span className={c.heroMeta}> {t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })} </span>
</span> <div className={c.heroActions}>
)} <Button
{a && ( variant="primary"
<div className={c.heroActions}> className={c.pill}
<Button icon={waveIcon}
variant="primary" onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
className={c.pill} >
icon={waveIcon} {t('catalog.artist_wave')}
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)} </Button>
> {tracks.length > 0 && (
{t('catalog.artist_wave')} <Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
{t('catalog.play')}
</Button> </Button>
{tracks.length > 0 && ( )}
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}> </div>
{t('catalog.play')}
</Button>
)}
</div>
)}
</div> </div>
</header> </header>
{tracks.length > 0 && ( {tracks.length > 0 && (
+6 -1
View File
@@ -2,16 +2,21 @@ import type { ReactNode } from 'react';
import { Logo } from '../shared/ui/ui'; import { Logo } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch'; import { LanguageSwitch } from '../widgets/LanguageSwitch';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './auth.module.css'; import s from './auth.module.css';
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */ /** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) { export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
return ( return (
<div className={s.screen}> <div className={s.screen}>
<div className={s.backdrop} aria-hidden> <div className={s.backdrop} aria-hidden>
<span className={s.blob1} /> <span className={s.blob1} />
<span className={s.blob2} /> <span className={s.blob2} />
<span className={s.blob3} /> <span className={s.blob3} />
<div className={s.waveDecor}>
<WaveButton interactive={false} size={620} label="" theme={theme} />
</div>
</div> </div>
<header className={s.top}> <header className={s.top}>
<Logo /> <Logo />
+18 -3
View File
@@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons'; import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui'; import { Button, Dialog, EmptyState, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import c from './catalog.module.css'; import c from './catalog.module.css';
import s from './page.module.css'; import s from './page.module.css';
@@ -109,7 +109,12 @@ function History() {
</div> </div>
)} )}
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError> <FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>} {history.isPending && <PageSkeleton />}
{history.isSuccess && items.length === 0 && (
<EmptyState icon={historyFillIcon} action={<Link to="/">{t('catalog.back_home')}</Link>}>
{t('collection.empty_history')}
</EmptyState>
)}
{groups.map((g) => ( {groups.map((g) => (
<section key={g.key} className={c.section}> <section key={g.key} className={c.section}>
<h2 className={c.day}>{g.title}</h2> <h2 className={c.day}>{g.title}</h2>
@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
</div> </div>
)} )}
<FormError>{list.error ? errorText(list.error) : null}</FormError> <FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <PageSkeleton />}
{list.isSuccess && tracks.length === 0 && ( {list.isSuccess && tracks.length === 0 && (
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div> <EmptyState
icon={reaction === 'like' ? likeFillIcon : dislikeFillIcon}
action={
<Link to="/search" search={{ q: '' }}>
{t('playlists.find_tracks')}
</Link>
}
>
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
</EmptyState>
)} )}
{tracks.length > 0 && ( {tracks.length > 0 && (
<TrackList <TrackList
+17 -7
View File
@@ -21,7 +21,7 @@ import {
waveSunIcon, waveSunIcon,
waveWeekendIcon, waveWeekendIcon,
} from '../shared/ui/icons'; } from '../shared/ui/icons';
import { cx, FormError, Icon } from '../shared/ui/ui'; import { cx, FormError, Icon, Skeleton } from '../shared/ui/ui';
import { WaveButton } from '../widgets/wave/WaveButton'; import { WaveButton } from '../widgets/wave/WaveButton';
import s from './home.module.css'; import s from './home.module.css';
import { Onboarding } from './Onboarding'; import { Onboarding } from './Onboarding';
@@ -41,8 +41,6 @@ const ICONS: Record<string, string> = {
rainy: waveRainyIcon, rainy: waveRainyIcon,
}; };
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
type Tr = (key: string) => string; type Tr = (key: string) => string;
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */ /** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
@@ -52,8 +50,16 @@ export function HomePage() {
const onboarding = useQuery({ const onboarding = useQuery({
queryKey: ['onboarding'], queryKey: ['onboarding'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: Infinity, staleTime: 0,
refetchInterval: 30_000,
}); });
if (onboarding.isPending) {
return (
<div className={s.home}>
<Skeleton round style={{ width: 320, height: 320, maxWidth: '70vw', maxHeight: '70vw' }} />
</div>
);
}
if (onboarding.data?.required) { if (onboarding.data?.required) {
return ( return (
<Onboarding <Onboarding
@@ -89,8 +95,6 @@ function WaveHome() {
const waveOn = st.wave !== null; const waveOn = st.wave !== null;
const playing = waveOn && st.playing; const playing = waveOn && st.playing;
const track = waveOn ? st.track : null; const track = waveOn ? st.track : null;
const colors: [string, string, string] =
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
const run = (fn: () => Promise<void> | void) => { const run = (fn: () => Promise<void> | void) => {
setError(null); setError(null);
@@ -121,7 +125,7 @@ function WaveHome() {
readAudio={player.readAudio} readAudio={player.readAudio}
bpm={track?.features?.bpm ?? undefined} bpm={track?.features?.bpm ?? undefined}
energy={track?.features?.energy ?? undefined} energy={track?.features?.energy ?? undefined}
colors={colors} colors={track?.album.cover_colors}
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'} theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
size={320} size={320}
label={t(playing ? 'wave.pause' : 'wave.start')} label={t(playing ? 'wave.pause' : 'wave.start')}
@@ -136,6 +140,12 @@ function WaveHome() {
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>} {st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
<FormError>{error ? errorText(error) : null}</FormError> <FormError>{error ? errorText(error) : null}</FormError>
{options.isPending && (showActivities || showMoods) && (
<div className={s.picker}>
<Skeleton style={{ width: 280, height: 38, borderRadius: 999 }} />
<Skeleton style={{ width: 240, height: 38, borderRadius: 999 }} />
</div>
)}
{options.data && (showActivities || showMoods) && ( {options.data && (showActivities || showMoods) && (
<div className={s.picker}> <div className={s.picker}>
{showActivities && ( {showActivities && (
+1 -1
View File
@@ -47,7 +47,7 @@ export function LoginPage() {
{desktop ? null : ( {desktop ? null : (
<> <>
{' · '} {' · '}
<a href="/android">{t('app.link')}</a> <a href="/download">{t('app.link')}</a>
</> </>
)} )}
</> </>
+27 -10
View File
@@ -1,5 +1,5 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -20,17 +20,19 @@ const MIN_ARTISTS = 3;
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны», * Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим. * пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
*/ */
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) { export function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const errorText = useErrorText(); const errorText = useErrorText();
const [artists, setArtists] = useState<Set<string>>(new Set()); const [artists, setArtists] = useState<Set<string>>(new Set(options.choice?.artist_ids));
const [genres, setGenres] = useState<Set<string>>(new Set()); const [genres, setGenres] = useState<Set<string>>(new Set(options.choice?.genres));
const [gender, setGender] = useState(options.choice?.gender ?? null);
const [ageGroup, setAgeGroup] = useState(options.choice?.age_group ?? null);
const save = useMutation({ const save = useMutation({
mutationFn: async (skip: boolean) => mutationFn: async (skip: boolean) =>
unwrap( unwrap(
await session.client.PUT('/api/v1/me/onboarding', { await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] }, body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : { artist_ids: [...artists], genres: [...genres], gender, age_group: ageGroup },
}), }),
), ),
onSuccess: onDone, onSuccess: onDone,
@@ -43,7 +45,7 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
else next.add(key); else next.add(key);
return next; return next;
}); });
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0); const enough = editing || artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0) || !!gender || !!ageGroup || options.artists.length === 0;
return ( return (
<div className={s.page}> <div className={s.page}>
@@ -52,6 +54,21 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
<p>{t('onboarding.subtitle')}</p> <p>{t('onboarding.subtitle')}</p>
</header> </header>
<section aria-label={t('onboarding.gender')}>
<h2 className={s.label}>{t('onboarding.gender')}</h2>
<div className={s.genres}>
<button type="button" className={s.genre} aria-pressed={!gender} onClick={() => setGender(null)}>{t('onboarding.unspecified')}</button>
{GENDERS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={gender === g} onClick={() => setGender(g)}>{t(`onboarding.genders.${g}`)}</button>)}
</div>
</section>
<section aria-label={t('onboarding.age_group')}>
<h2 className={s.label}>{t('onboarding.age_group')}</h2>
<p>{t('onboarding.age_hint')}</p>
<div className={s.genres}>
<button type="button" className={s.genre} aria-pressed={!ageGroup} onClick={() => setAgeGroup(null)}>{t('onboarding.unspecified')}</button>
{AGE_GROUPS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={ageGroup === g} onClick={() => setAgeGroup(g)}>{t(`onboarding.ages.${g}`)}</button>)}
</div>
</section>
<section aria-label={t('onboarding.artists')}> <section aria-label={t('onboarding.artists')}>
<h2 className={s.label}>{t('onboarding.artists')}</h2> <h2 className={s.label}>{t('onboarding.artists')}</h2>
<div className={s.artists}> <div className={s.artists}>
@@ -101,11 +118,11 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
<FormError>{save.error ? errorText(save.error) : null}</FormError> <FormError>{save.error ? errorText(save.error) : null}</FormError>
<div className={s.actions}> <div className={s.actions}>
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}> <Button variant="ghost" disabled={save.isPending} busy={save.isPending && save.variables} onClick={() => editing ? onDone() : save.mutate(true)}>
{t('onboarding.skip')} {t(editing ? 'form.cancel' : 'onboarding.skip')}
</Button> </Button>
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}> <Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough || save.isPending} onClick={() => save.mutate(false)}>
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })} {enough ? t(editing ? 'settings.save' : 'onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
</Button> </Button>
</div> </div>
</div> </div>
+97 -23
View File
@@ -1,16 +1,18 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router'; import { Link, useNavigate } from '@tanstack/react-router';
import { useState } from 'react'; import { useState, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog'; import { Cover, TrackList } from '../entities/catalog';
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists'; import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { sharePlaylist } from '../shared/lib/share';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, Field, FormError } from '../shared/ui/ui'; import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, shuffleIcon } from '../shared/ui/icons'; import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
import c from './catalog.module.css'; import c from './catalog.module.css';
import s from './page.module.css'; import s from './page.module.css';
import p from './playlists.module.css'; import p from './playlists.module.css';
@@ -27,30 +29,33 @@ export function PlaylistsPage() {
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header> <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" /> <Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
<FormError>{list.error ? errorText(list.error) : null}</FormError> <FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <p role="status">{t('playlists.loading')}</p>} {list.isPending && <PageSkeleton />}
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>} {list.isSuccess && !items.length && (
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}> <EmptyState
<Cover album={item} size="100%" /> icon={search ? searchIcon : playlistAddIcon}
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span> action={!search ? <Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button> : undefined}
{item.description && <span className={p.description}>{item.description}</span>} >
</Link>)}</div> {t(search ? 'playlists.no_results' : 'playlists.empty')}
</EmptyState>
)}
<div className={p.grid}>{items.map(item => <PlaylistCard key={item.id} item={item} />)}</div>
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />} {create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
</div>; </div>;
} }
export function PlaylistPage({ id }: { id: string }) { export function PlaylistPage({ id, shared = false }: { id: string; shared?: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const query = usePlaylist(id); const query = usePlaylist(id, shared);
const errorText = useErrorText(); const errorText = useErrorText();
return <div className={s.page}> return <div className={s.page}>
<Link to="/collection/playlists">← {t('playlists.title')}</Link> <Link to="/collection/playlists">← {t('playlists.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError> <FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p role="status">{t('playlists.loading')}</p>} {query.isPending && <PageSkeleton />}
{query.data && <PlaylistDetail key={id} playlist={query.data} />} {query.data && !query.isError && <PlaylistDetail key={id} playlist={query.data} readOnly={shared} shareId={shared ? id : undefined} />}
</div>; </div>;
} }
function PlaylistDetail({ playlist }: { playlist: Playlist }) { function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const cache = useQueryClient(); const cache = useQueryClient();
@@ -60,6 +65,21 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
const [edit, setEdit] = useState(false); const [edit, setEdit] = useState(false);
const [remove, setRemove] = useState(false); const [remove, setRemove] = useState(false);
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
const [copied, setCopied] = useState(false);
const sharing = useMutation({ mutationFn: async () => {
setCopied(false);
let token = shareId ?? playlist.share_id;
if (!readOnly) {
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
}
if (!token) throw new Error('Share link unavailable');
const result = await sharePlaylist(token, playlist.name);
if (result === 'copied') setCopied(true);
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
onSuccess: () => { setCopied(false); cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name }; 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}', { 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 }, params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
@@ -80,7 +100,7 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
return <> return <>
<header className={c.colHero}> <header className={c.colHero}>
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} /> <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> <div className={c.heroText}><span className={c.kicker}>{t(readOnly || playlist.share_id ? 'playlists.shared' : 'playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
<p className={p.description}>{playlist.description}</p> <p className={p.description}>{playlist.description}</p>
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span> <span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
</div> </div>
@@ -88,22 +108,76 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<div className={s.toolbar}> <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 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 icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
<Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
{!readOnly && <>
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button> <Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</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> <Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
{playlist.share_id && <Button variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onClick={() => revoke.mutate()}>{t('playlists.revoke_share')}</Button>}
</>}
</div> </div>
{!readOnly && <p>{t('playlists.share_hint')}</p>}
{copied && <p role="status">{t('playlists.link_copied')}</p>}
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
<FormError>{save.error ? errorText(save.error) : null}</FormError> <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>} {!playlist.tracks.length && (
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
{t('playlists.empty_tracks')}
</EmptyState>
)}
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)} <TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
actions={manage ? (track, i) => <span className={p.actions}> actions={!readOnly && 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 === 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={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> <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} /> </span> : undefined} />
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />} {!readOnly && edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<> {!readOnly && remove && (
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button> <Dialog
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button> title={t('playlists.delete')}
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>} 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>
)}
</>; </>;
} }
function PlaylistCard({ item }: { item: components['schemas']['PlaylistSummary'] }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const play = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
void (async () => {
const full = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id: item.id } } }));
const tracks = full.tracks.filter((x) => x.available);
if (tracks.length) player.playList(tracks, 0, { type: 'collection', id: item.id, label: item.name });
})();
};
return (
<Link to="/playlist/$id" params={{ id: item.id }} className={p.card}>
<span className={p.coverWrap}>
<Cover album={item} size="100%" />
{item.track_count > 0 && (
<button type="button" className={p.play} aria-label={t('catalog.play')} onClick={play}>
<Icon src={playIcon} size={22} />
</button>
)}
</span>
<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>
);
}
+7 -2
View File
@@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog'; import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { Button, FormError } from '../shared/ui/ui'; import { searchIcon } from '../shared/ui/icons';
import { Button, EmptyState, FormError, PageSkeleton } from '../shared/ui/ui';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
@@ -68,7 +69,11 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
</div> </div>
<FormError>{results.error ? errorText(results.error) : null}</FormError> <FormError>{results.error ? errorText(results.error) : null}</FormError>
{empty && <div className={s.empty}>{t('search.nothing')}</div>} {query === '' && <EmptyState icon={searchIcon}>{t('search.type_query')}</EmptyState>}
{query !== '' && results.isPending && <PageSkeleton />}
{empty && (
<EmptyState icon={searchIcon}>{t('search.nothing')}</EmptyState>
)}
{artists.length > 0 && ( {artists.length > 0 && (
<section className={c.section}> <section className={c.section}>
+16
View File
@@ -12,6 +12,7 @@ import { formatDateTime } from '../shared/lib/format';
import { Button, Field, FormError, Segmented } from '../shared/ui/ui'; import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch'; import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css'; import s from './page.module.css';
import { Onboarding } from './Onboarding';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>; type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>; type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
@@ -24,6 +25,7 @@ export function SettingsPage() {
<h1>{t('settings.title')}</h1> <h1>{t('settings.title')}</h1>
</div> </div>
<ProfileSection /> <ProfileSection />
<RecommendationSettings />
<AppearanceSection /> <AppearanceSection />
<PasswordSection /> <PasswordSection />
<SessionsSection /> <SessionsSection />
@@ -357,3 +359,17 @@ function SessionsSection() {
</section> </section>
); );
} }
function RecommendationSettings() {
const { t } = useTranslation();
const session = useSession();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
return <section className={s.section}>
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
<FormError>{options.error ? errorText(options.error) : null}</FormError>
</section>;
}
+36
View File
@@ -0,0 +1,36 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { FormError } from '../shared/ui/ui';
import s from './page.module.css';
import g from './genius.module.css';
export function TrackGenius({ id }: { id: string }) {
const session = useSession();
const { t } = useTranslation();
const errorText = useErrorText();
const query = useQuery({
queryKey: ['track-genius', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}/genius', { params: { path: { id } } })),
refetchInterval: (q) => ['pending', 'processing'].includes(q.state.data?.status ?? '') ? 10000 : false,
});
const data = query.data;
if (data?.status === 'disabled') return null;
return <section className={s.section}>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p>{t('genius.loading')}</p>}
{data?.description && <><h2>{t('genius.about')}</h2><p className={g.lyrics}>{data.description}</p></>}
<div className={s.head}><h2>{t('genius.lyrics')}</h2>{data?.source_url && <a href={data.source_url} target="_blank" rel="noopener noreferrer">Genius ↗</a>}</div>
{data && (data.lyrics ? <div className={g.lyrics}>{data.lyrics}</div> : <p className={s.subtitle}>{['pending', 'processing'].includes(data.status) ? t('genius.pending_text') : t('genius.no_lyrics')}</p>)}
{!!data?.annotations.length && <>
<h2>{t('genius.explanations')}</h2>
{data.annotations.map((a, i) => <details className={g.annotation} key={`${a.url}-${i}`}>
<summary>{a.fragment}{a.verified && <span className={g.badge}>{t('genius.verified')}</span>}</summary>
<p className={g.lyrics}>{a.text}</p>
<p className={s.subtitle}>{a.authors}{a.url && <> · <a href={a.url} target="_blank" rel="noopener noreferrer">Genius ↗</a></>}</p>
</details>)}
</>}
</section>;
}
+54 -12
View File
@@ -4,35 +4,77 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { ArtistLinks, Cover, TrackList } from '../entities/catalog'; import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { pauseIcon, playIcon } from '../shared/ui/icons';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
import { TrackGenius } from './TrackGenius';
export function TrackPage({ id }: { id: string }) { export function TrackPage({ id }: { id: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const player = usePlayer();
const st = usePlayerState();
const track = useQuery({ const track = useQuery({
queryKey: ['track', id], queryKey: ['track', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })), queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
}); });
if (track.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (track.isError) return <NotFound error={track.error} />; if (track.isError) return <NotFound error={track.error} />;
const tr = track.data; const tr = track.data;
if (!tr) return null;
const ctx = { type: 'queue' as const };
const current = st.track?.id === tr.id;
const playing = current && st.playing;
return ( return (
<div className={s.page}> <div className={s.page}>
<header className={c.hero}> <div className={c.listen}>
{tr && <Cover album={tr.album} size={200} />} <header className={c.hero}>
<div className={c.heroBody}> {tr.album.cover_url && (
<span className={c.kind}>{t('player.track_page')}</span> <span className={c.heroBg} aria-hidden>
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1> <img src={coverSrc(tr.album.cover_url, 300)} alt="" />
{tr && <> </span>
<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> <Cover album={tr.album} size={280} className={c.heroCover} />
</>} <div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr.title}</h1>
<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 className={c.heroActions}>
{tr.available && (
<Button
variant="primary"
className={c.pill}
icon={playing ? pauseIcon : playIcon}
onClick={() => (current ? player.toggle() : player.playList([tr], 0, ctx))}
>
{t(playing ? 'player.pause' : 'catalog.play')}
</Button>
)}
<AddToPlaylist trackIds={[tr.id]} />
</div>
</div>
</header>
<div className={c.listenLyrics}>
<TrackGenius id={id} />
</div> </div>
</header> </div>
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import s from '../page.module.css'; import s from '../page.module.css';
import { UploadSettingsSection } from './UploadQueuePage'; import { UploadSettingsSection } from '../../features/uploads/UploadSettingsSection';
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */ /** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
export function AdminSettingsPage() { export function AdminSettingsPage() {
+3 -3
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { formatDateTime } from '../../shared/lib/format'; import { formatDateTime } from '../../shared/lib/format';
import { Button, FormError } from '../../shared/ui/ui'; import { Button, EmptyState, FormError, PageSkeleton } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
type Entry = components['schemas']['AuditEntry']; type Entry = components['schemas']['AuditEntry'];
@@ -67,8 +67,8 @@ export function AuditPage() {
))} ))}
</tbody> </tbody>
</table> </table>
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>} {list.isSuccess && entries.length === 0 && <EmptyState>{t('audit.empty')}</EmptyState>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>} {list.isPending && <PageSkeleton />}
</div> </div>
{list.hasNextPage && ( {list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}> <Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
+46
View File
@@ -0,0 +1,46 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import g from '../genius.module.css';
export function GeniusPanel() {
const { t } = useTranslation();
const session = useSession();
const queries = useQueryClient();
const errorText = useErrorText();
const stats = useQuery({
queryKey: ['genius-stats'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/genius')),
refetchInterval: 5000,
});
const control = useMutation({
mutationFn: async (action: 'start' | 'pause' | 'retry') =>
unwrap(await session.client.POST('/api/v1/admin/genius', { body: { action } })),
onSuccess: (data) => { queries.setQueryData(['genius-stats'], data); },
});
const data = stats.data;
const cooling = data && Date.parse(data.cooldown_until) > Date.now();
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting');
return <section className={s.section} aria-label="Genius">
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
<p className={s.subtitle}>{t('genius.description')}</p>
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
{data && <>
{!data.configured && <p>{t('genius.configure')}</p>}
<dl className={g.stats}>
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)}
</dl>
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
<div className={g.actions}>
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
</div>
</>}
</section>;
}
+10 -3
View File
@@ -5,10 +5,11 @@ import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession, useUser } from '../../app/session';
import { GeniusPanel } from './GeniusPanel';
import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons'; import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui'; import { Button, Dialog, EmptyState, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
import l from './library.module.css'; import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus'; import { ScanLine, StatusBadge } from './ScanStatus';
@@ -19,6 +20,7 @@ type TestResult = components['schemas']['SourceTestResult'];
const SCAN_POLL_MS = 2000; const SCAN_POLL_MS = 2000;
export function SourcesPage() { export function SourcesPage() {
const user = useUser();
const { t } = useTranslation(['admin', 'common']); const { t } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
const errorText = useErrorText(); const errorText = useErrorText();
@@ -43,7 +45,12 @@ export function SourcesPage() {
</Button> </Button>
</div> </div>
<FormError>{sources.error ? errorText(sources.error) : null}</FormError> <FormError>{sources.error ? errorText(sources.error) : null}</FormError>
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>} {user.role === 'admin' && <GeniusPanel />}
{sources.isSuccess && sources.data.length === 0 && (
<EmptyState icon={folderIcon} action={<Button variant="primary" onClick={() => setEditing('new')}>{t('sources.add')}</Button>}>
{t('sources.empty')}
</EmptyState>
)}
<div className={l.list}> <div className={l.list}>
{sources.data?.map((src) => ( {sources.data?.map((src) => (
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} /> <SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
+2 -639
View File
@@ -1,640 +1,3 @@
import type { components } from '@pipu/api-client'; import { UploadQueue } from '../../features/uploads/UploadQueue';
import { ApiError, unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; export function UploadQueuePage() { return <UploadQueue />; }
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';
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
import u from '../uploads/uploads.module.css';
type Meta = components['schemas']['UploadMeta'];
type Settings = components['schemas']['UploadSettings'];
const TABS = {
review: ['pending_review'],
failed: ['import_failed'],
progress: ['uploading', 'processing', 'approved', 'importing'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
} satisfies Record<string, UploadStatus[]>;
type Tab = keyof typeof TABS;
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
export function UploadQueuePage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [tab, setTab] = useState<Tab>('review');
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({
queryKey: ['admin-uploads', tab, onlyPossible],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(
await session.client.GET('/api/v1/admin/uploads', {
params: {
query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 },
},
}),
),
getNextPageParam: (p) => p.next_cursor ?? undefined,
refetchInterval: (q) =>
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
const refresh = async () => {
setSelected(new Set());
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
};
const review = useMutation({
mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) =>
unwrap(
await session.client.POST('/api/v1/admin/uploads/review', {
body: { file_ids: v.ids, decision: v.decision, reason: v.reason },
}),
),
onSuccess: refresh,
});
const retry = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
onSuccess: refresh,
});
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
const batches = useMemo(() => {
const m = new Map<string, UploadFile[]>();
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
return [...m.values()];
}, [items]);
const toggle = (ids: string[], on: boolean) =>
setSelected((cur) => {
const next = new Set(cur);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
return next;
});
const selectable = tab === 'review' || tab === 'failed';
const ids = [...selected];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.queue_title')}</h1>
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(TABS) as Tab[]).map((k) => (
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
{t(`uploads.tab_${k}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
{t('uploads.only_possible')}
</label>
</div>
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
{batches.map((files) => (
<BatchCard
key={files[0]!.batch_id}
files={files}
selectable={selectable}
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)}
/>
))}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
{ids.length > 0 && (
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
<span>{t('uploads.selected', { count: ids.length })}</span>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setSelected(new Set())}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
{t('uploads.reject')}
</Button>
{tab === 'review' && (
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
{t('uploads.approve')}
</Button>
)}
</div>
)}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && (
<EditDialog
file={editing}
onClose={() => setEditing(null)}
onSaved={() => {
review.reset();
void refresh();
}}
/>
)}
{rejecting && (
<RejectDialog
count={ids.length}
busy={review.isPending}
onClose={() => setRejecting(false)}
onReject={(reason) => {
setRejecting(false);
review.mutate({ decision: 'reject', ids, reason });
}}
/>
)}
</div>
);
}
/** Problem из ответа массового решения → объект, понятный useErrorText. */
function problemError(p: components['schemas']['Problem']) {
return new ApiError(p.status, p.code, p.params ?? {});
}
function BatchCard({
files,
selectable,
selected,
onToggle,
onEdit,
onEditBatch,
onRetry,
}: {
files: UploadFile[];
selectable: boolean;
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');
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0);
return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}>
{selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)}
<b>{first.author.username}</b>
<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
key={f.id}
file={f}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</section>
);
}
function QueueRow({
file: f,
selectable,
checked,
onCheck,
onEdit,
onRetry,
}: {
file: UploadFile;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
onRetry: () => void;
}) {
const { t, i18n } = useTranslation('admin');
const reason = useReasonText()(f.status_reason);
const m = f.metadata;
const quality = [
f.format?.toUpperCase(),
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
f.bit_depth ? `${f.bit_depth} bit` : null,
f.duration_ms ? formatDuration(f.duration_ms) : null,
formatBytes(f.size_bytes, i18n.language),
]
.filter(Boolean)
.join(' · ');
const editable = f.status === 'pending_review' || f.status === 'import_failed';
return (
<div className={u.queueRow}>
{selectable ? (
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
) : (
<span />
)}
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
{m && (
<span className={u.sub}>
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
.filter(Boolean)
.join(' · ')}
{f.metadata_edited && ` · ${t('uploads.edited')}`}
</span>
)}
<span className={u.sub}>{quality}</span>
<span className={u.sub} title={f.client_rel_path ?? undefined}>
{f.client_rel_path ?? f.original_name}
</span>
</div>
<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>
) : (
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
</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} />
</Button>
)}
{f.status === 'import_failed' && (
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
{t('uploads.retry')}
</Button>
)}
</div>
</div>
);
}
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();
const errorText = useErrorText();
const fieldError = useFieldError();
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
const [source, setSource] = useState(file.target_source_id ?? '');
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/files/{id}', {
params: { path: { id: file.id } },
body: { metadata: m, ...(source ? { target_source_id: source } : {}) },
}),
),
onSuccess: () => {
onSaved();
onClose();
},
});
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
);
const num = (k: 'year' | 'track' | 'disc') => (
<Field
label={t(`uploads.meta_${k}`)}
value={m[k] === null ? '' : String(m[k])}
inputMode="numeric"
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
/>
);
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
return (
<Dialog
title={t('uploads.edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
<div className={u.grid}>
{text('artist')}
{text('albumartist')}
{text('album')}
{text('title')}
{text('genre')}
{num('year')}
{num('track')}
{num('disc')}
</div>
<label className={s.sessions}>
<span>{t('uploads.target_source')}</span>
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">{t('uploads.target_default')}</option>
{accepting.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<p className={s.note}>{t('uploads.tags_note')}</p>
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
</div>
</Dialog>
);
}
function RejectDialog({
count,
busy,
onClose,
onReject,
}: {
count: number;
busy: boolean;
onClose: () => void;
onReject: (reason: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const [reason, setReason] = useState('');
return (
<Dialog
title={t('uploads.reject_title', { count })}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
{t('uploads.reject')}
</Button>
</>
}
>
<label className={s.sessions}>
<span>{t('uploads.reject_reason')}</span>
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
</label>
</Dialog>
);
}
const GB = 1024 ** 3;
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
export function UploadSettingsSection() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const settings = useQuery({
queryKey: ['upload-settings'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/settings/uploads')),
});
const sources = useQuery({
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; upgrade: boolean } | null>(null);
useEffect(() => {
if (settings.data) setForm(toForm(settings.data));
}, [settings.data]);
const save = useMutation({
mutationFn: async () => {
const f = form!;
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
return unwrap(
await session.client.PATCH('/api/v1/admin/settings/uploads', {
body: {
queue_limit_bytes: gb(f.queue),
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,
},
}),
);
},
onSuccess: async (data) => {
queryClient.setQueryData(['upload-settings'], data);
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
if (!form) return null;
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
return (
<section className={s.section}>
<h2>{t('uploads.settings_title')}</h2>
<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')}
value={form.perUser}
hint={t('uploads.set_per_user_hint')}
onChange={(v) => setForm({ ...form, perUser: v })}
inputMode="decimal"
/>
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<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
?.filter((x) => x.accepts_uploads)
.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</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}>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</div>
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</section>
);
}
function toForm(st: Settings) {
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
return {
queue: gb(st.queue_limit_bytes),
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,
};
}
+3 -3
View File
@@ -9,7 +9,7 @@ import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatRelative } from '../../shared/lib/format'; import { formatRelative } from '../../shared/lib/format';
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy'; import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, 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 { Avatar, Button, cx, Dialog, EmptyState, Field, FormError, Icon, Menu, PageSkeleton, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
type AdminUser = components['schemas']['AdminUser']; type AdminUser = components['schemas']['AdminUser'];
@@ -232,8 +232,8 @@ export function UsersPage() {
})} })}
</tbody> </tbody>
</table> </table>
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>} {list.isSuccess && users.length === 0 && <EmptyState icon={searchIcon}>{t('users.empty')}</EmptyState>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>} {list.isPending && <PageSkeleton />}
</div> </div>
{list.hasNextPage && ( {list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}> <Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
+64 -1
View File
@@ -20,6 +20,15 @@
filter: blur(30px); filter: blur(30px);
} }
.waveDecor {
position: absolute;
left: 50%;
top: 46%;
transform: translate(-50%, -50%);
opacity: 0.55;
filter: blur(0);
}
.blob1 { .blob1 {
width: 62vmax; width: 62vmax;
height: 62vmax; height: 62vmax;
@@ -165,7 +174,7 @@
color: var(--text2); color: var(--text2);
} }
/* Страница Android-приложения (/android). */ /* Страница приложений (/download). */
.download { .download {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -238,6 +247,60 @@
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.otherClients {
display: flex;
flex-direction: column;
gap: 8px;
}
.otherBlock {
display: flex;
flex-direction: column;
gap: 8px;
}
.otherTitle {
margin: 8px 0 0;
color: var(--text3);
font-size: 12px;
font-weight: 600;
}
.downloadAlt {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-lg);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.downloadAlt span {
color: var(--text3);
font-size: 12px;
font-weight: 500;
}
.older {
font-size: 13px;
color: var(--text3);
}
.older summary {
cursor: pointer;
font-weight: 600;
}
.older .downloadAlt {
margin-top: 8px;
}
.link { .link {
align-self: center; align-self: center;
} }
+41 -2
View File
@@ -60,12 +60,23 @@
.heroBg { .heroBg {
position: absolute; position: absolute;
inset: 0; inset: -20%;
opacity: 0.35; opacity: 0.4;
filter: blur(60px); filter: blur(60px);
pointer-events: none; pointer-events: none;
} }
.heroBg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.heroCover {
position: relative;
z-index: 1;
}
.tint { .tint {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -81,12 +92,40 @@
.heroBody { .heroBody {
position: relative; position: relative;
z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
} }
.listen {
display: grid;
gap: 28px;
}
.listenLyrics {
min-width: 0;
}
@media (min-width: 900px) {
.listen {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
align-items: start;
}
.listen .hero {
flex-direction: column;
align-items: stretch;
margin: 0;
padding: 0;
}
.listen .heroTitle {
font-size: clamp(28px, 3vw, 40px);
}
}
.kind { .kind {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
+8
View File
@@ -0,0 +1,8 @@
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; margin: 0; }
.stats dt { color: var(--text2); font-size: 13px; }
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
.annotation summary { cursor: pointer; line-height: 1.6; }
.badge { display: inline-block; margin-left: 12px; font-size: 12px; color: var(--text2); }
+4
View File
@@ -98,6 +98,10 @@
background: var(--surface3); background: var(--surface3);
} }
.chip:active {
transform: scale(0.97);
}
.chipOn, .chipOn,
.chipOn:hover { .chipOn:hover {
background: var(--accent-fill); background: var(--accent-fill);
+13 -4
View File
@@ -1,12 +1,21 @@
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; } .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 { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface); animation: pipuIn 0.2s ease-out both; }
.card > :first-child { aspect-ratio: 1; border-radius: 12px; } .coverWrap { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; }
.coverWrap > :first-child { width: 100%; height: 100%; }
.card strong { overflow-wrap: anywhere; } .card strong { overflow-wrap: anywhere; }
.card span { font-size: 13px; } .card span { font-size: 13px; }
.card:hover { background: #ffffff10; } .card:hover { background: var(--hover); }
.play {
position: absolute; right: 10px; bottom: 10px; z-index: 1; display: grid; place-items: center;
width: 44px; height: 44px; border-radius: 50%; background: #fff; color: #05070d;
opacity: 0; transform: translateY(6px); transition: opacity 0.18s, transform 0.18s;
}
.card:hover .play, .card:focus-within .play, .play:focus-visible { opacity: 1; transform: none; }
.description { white-space: pre-wrap; overflow-wrap: anywhere; } .description { white-space: pre-wrap; overflow-wrap: anywhere; }
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.actions { display: flex; align-items: center; } .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 { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
.actions button:disabled { opacity: .3; cursor: default; } .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; } } .hero { overflow-wrap: anywhere; }
@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } .play { opacity: 1; transform: none; } }
+6 -2
View File
@@ -8,7 +8,7 @@ import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { formatBytes, formatDateTime } from '../../shared/lib/format'; import { formatBytes, formatDateTime } from '../../shared/lib/format';
import { addIcon } from '../../shared/ui/icons'; import { addIcon } from '../../shared/ui/icons';
import { Button, cx, FormError } from '../../shared/ui/ui'; import { Button, cx, EmptyState, FormError } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common'; import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
import u from './uploads.module.css'; import u from './uploads.module.css';
@@ -86,7 +86,11 @@ export function MyUploadsPage() {
</Link> </Link>
</div> </div>
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError> <FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>} {list.isSuccess && items.length === 0 && (
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
{t('uploads.mine_empty')}
</EmptyState>
)}
{items.length > 0 && ( {items.length > 0 && (
<div className={s.tableWrap}> <div className={s.tableWrap}>
<table className={s.table}> <table className={s.table}>
+1 -84
View File
@@ -1,84 +1 @@
import type { components } from '@pipu/api-client'; export * from '../../features/uploads/common';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { formatBytes } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from '../page.module.css';
import u from './uploads.module.css';
export type UploadStatus = components['schemas']['UploadStatus'];
export type UploadFile = components['schemas']['UploadFile'];
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
uploading: 'info',
processing: 'info',
pending_review: 'warn',
approved: 'info',
importing: 'info',
imported: 'ok',
import_failed: 'bad',
duplicate: 'muted',
invalid: 'bad',
rejected: 'bad',
withdrawn: 'muted',
expired: 'muted',
};
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
}
export function useUploadQuota() {
const session = useSession();
return useQuery({
queryKey: ['upload-quota'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
});
}
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
export function QuotaMeter() {
const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota();
const id = useId();
if (!quota.data) return null;
const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language);
return (
<div className={u.quota}>
<span className={u.quotaText} id={id}>
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
{q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</span>
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} />
</div>
</div>
);
}
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
export function useReasonText(): (reason: string | null) => string | null {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
return (reason) => {
if (!reason) return null;
const code = reason.split(':')[0]!.trim();
const key = `errors:${code}`;
if (i18n.exists(key)) {
const rest = reason.slice(code.length + 1).trim();
return rest ? `${tr(key)}: ${rest}` : tr(key);
}
return reason;
};
}
+31
View File
@@ -0,0 +1,31 @@
export class AudioAnalysis {
constructor(private readonly getAnalyser: () => AnalyserNode | 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. */
read = (active: boolean) => {
const now = performance.now();
if (now - this.sampledAt < 12) return this.audioFrame;
this.sampledAt = now;
const analyser = this.getAnalyser();
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;
};
}
+28 -19
View File
@@ -1,48 +1,57 @@
import type { AuthSession } from '@pipu/core';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { Player } from './player'; import { AudioAnalysis } from './analysis';
import { AudioEngine } from './engine';
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
function fixture() { function fixture() {
const player = new Player({} as AuthSession, document.createElement('audio'));
const sample = vi.fn((data: Uint8Array) => { const sample = vi.fn((data: Uint8Array) => {
data.fill(0); data.fill(0);
// 48 kHz / 1024: bins 1..4 cover the bass band. // 48 kHz / 1024: bins 1..4 cover the bass band.
data.fill(255, 1, 5); data.fill(255, 1, 5);
}); });
Object.assign(player, { const analyser = { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample } as unknown as AnalyserNode;
state: { ...player.getState(), playing: true }, const analysis = new AudioAnalysis(() => analyser);
analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample }, return { analysis, sample };
});
return { player, sample };
} }
describe('audio reactivity', () => { describe('audio reactivity', () => {
it('separates bass from the other frequency bands and shares the frame', () => { it('separates bass from the other frequency bands and shares the frame', () => {
vi.spyOn(performance, 'now').mockReturnValue(100); vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture(); const { analysis, sample } = fixture();
expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 }); expect(analysis.read(true)).toEqual({ bass: 1, mid: 0, treble: 0 });
player.readAudio(); analysis.read(true);
expect(sample).toHaveBeenCalledTimes(1); expect(sample).toHaveBeenCalledTimes(1);
}); });
it('returns silence after pausing or muting', () => { it('returns silence after pausing or muting', () => {
const clock = vi.spyOn(performance, 'now').mockReturnValue(100); const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture(); const { analysis, sample } = fixture();
player.readAudio(); analysis.read(true);
Object.assign(player, { state: { ...player.getState(), playing: false } });
clock.mockReturnValue(120); clock.mockReturnValue(120);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 }); expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } });
clock.mockReturnValue(140); clock.mockReturnValue(140);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 }); expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
expect(sample).toHaveBeenCalledTimes(1); expect(sample).toHaveBeenCalledTimes(1);
}); });
it('works when Web Audio is unavailable', () => { it('works when Web Audio is unavailable', () => {
const player = new Player({} as AuthSession, document.createElement('audio')); const analysis = new AudioAnalysis(() => null);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 }); expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
});
});
describe('audio engine lifetime', () => {
it('removes audio listeners on disposal', () => {
const audio = document.createElement('audio');
const engine = new AudioEngine(audio);
const playing = vi.fn();
engine.on('playing', playing);
audio.dispatchEvent(new Event('playing'));
expect(playing).toHaveBeenCalledTimes(1);
engine.dispose();
audio.dispatchEvent(new Event('playing'));
expect(playing).toHaveBeenCalledTimes(1);
}); });
}); });
+66
View File
@@ -0,0 +1,66 @@
import { AudioAnalysis } from './analysis';
/** Browser audio graph and element lifetime; playback policy belongs to Player/core. */
export class AudioEngine {
readonly audio: HTMLAudioElement;
readonly overlap: HTMLAudioElement;
private gain: GainNode | null = null;
private wired = false;
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
readonly analysis = new AudioAnalysis(() => this.analyser);
private readonly unbind: (() => void)[] = [];
constructor(audio?: HTMLAudioElement) {
this.audio = audio ?? new Audio();
this.overlap = new Audio();
this.audio.preload = this.overlap.preload = 'auto';
}
resume() {
return this.audioContext?.state === 'suspended' ? this.audioContext.resume() : undefined;
}
on<K extends keyof HTMLMediaElementEventMap>(event: K, handler: (event: HTMLMediaElementEventMap[K]) => void) {
this.audio.addEventListener(event, handler);
this.unbind.push(() => this.audio.removeEventListener(event, handler));
}
applyLoudness(level: number) {
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);
}
dispose() {
for (const stop of this.unbind.splice(0)) stop();
for (const audio of [this.audio, this.overlap]) {
audio.pause();
audio.removeAttribute('src');
audio.load();
}
void this.audioContext?.close().catch(() => undefined);
}
}
+105
View File
@@ -0,0 +1,105 @@
import type { Track } from '@pipu/core';
import { desktopHandle } from '../desktop';
import type { PlayerState } from './types';
export interface PlaybackControls {
play(): void; pause(): void; toggle(): void; next(): void; prev(): void; seek(ms: number): void;
}
export class MediaControls {
private lastDesktop: { id?: string; playing?: boolean } = {};
private desktopUnlisten: (() => void) | null = null;
private disposed = false;
constructor(
private readonly controls: PlaybackControls,
private readonly getState: () => PlayerState,
private readonly getAudio: () => HTMLAudioElement,
) {
this.bindMediaSession();
this.bindDesktopMedia();
}
private bindDesktopMedia() {
void desktopHandle()?.listenMedia((action) => {
if (action === 'play') this.controls.play();
else if (action === 'pause' || action === 'stop') this.controls.pause();
else if (action === 'toggle') this.controls.toggle();
else if (action === 'next') this.controls.next();
else if (action === 'previous') this.controls.prev();
}).then((stop) => {
if (this.disposed) stop();
else this.desktopUnlisten = stop;
});
}
syncDesktopMedia() {
const desktop = desktopHandle();
const track = this.getState().track;
if (!desktop || !track) return;
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.getState().playing) return;
this.lastDesktop = { id: track.id, playing: this.getState().playing };
desktop.setMedia({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
coverUrl: track.album.cover_url,
playing: this.getState().playing,
});
}
private bindMediaSession() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try {
ms.setActionHandler(action, fn);
} catch {
// действие не поддерживается браузером
}
};
set('play', () => this.controls.play());
set('pause', () => this.controls.pause());
set('nexttrack', () => this.controls.next());
set('previoustrack', () => this.controls.prev());
set('seekbackward', (d) => this.controls.seek(this.getState().positionMs - (d.seekOffset ?? 10) * 1000));
set('seekforward', (d) => this.controls.seek(this.getState().positionMs + (d.seekOffset ?? 10) * 1000));
set('seekto', (d) => d.seekTime !== undefined && this.controls.seek(d.seekTime * 1000));
}
updateMediaMetadata(track: Track) {
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
const cover = track.album.cover_url;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
artwork: cover
? [
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
]
: [],
});
}
updatePositionState() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
const d = this.getAudio().duration;
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
try {
ms.setPositionState({ duration: d, position: Math.min(this.getAudio().currentTime, d), playbackRate: 1 });
} catch {
// некорректная позиция во время смены трека
}
}
dispose() {
this.disposed = true;
this.desktopUnlisten?.();
this.desktopUnlisten = null;
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
for (const action of ['play', 'pause', 'nexttrack', 'previoustrack', 'seekbackward', 'seekforward', 'seekto'] as const) {
try { ms?.setActionHandler(action, null); } catch { /* Unsupported action. */ }
}
}
}
+127
View File
@@ -0,0 +1,127 @@
import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core';
import type { PlayerState } from './types';
const LOCAL_KEY = 'pipu.player';
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
const MAX_SAVED_QUEUE = 500;
export interface Saved {
queue: string[];
index: number;
positionMs: number;
context: PlayContext | null;
volume: number;
shuffle: boolean;
repeat: RepeatMode;
savedAt: string;
}
function deviceId(): string {
try {
let id = localStorage.getItem('pipu.device');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('pipu.device', id);
}
return id;
} catch {
return 'web';
}
}
export class PlaybackPersistence {
private readonly device = deviceId();
lastServerSave = 0;
constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {}
snapshot(state: PlayerState): Saved {
const ids = state.queue.map((t) => t.id);
const start = Math.max(0, Math.min(state.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
return {
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
index: state.index - start,
positionMs: state.positionMs,
context: state.context,
volume: state.volume,
shuffle: state.shuffle,
repeat: state.repeat,
savedAt: new Date().toISOString(),
};
}
saveLocal() {
if (!this.getState().track) return;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState())));
} catch {
// недоступное хранилище не мешает воспроизведению
}
}
private saveTimer: ReturnType<typeof setTimeout> | null = null;
saveSoon() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
}
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
saveServer(keepalive = false) {
const cur = this.getState().track;
if (!cur) return;
this.lastServerSave = Date.now();
this.saveLocal();
const s = this.snapshot(this.getState());
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: cur.id,
position_ms: s.positionMs,
queue: s.queue,
queue_index: s.index,
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
shuffle: s.shuffle,
repeat_mode: s.repeat,
volume: s.volume,
device_id: this.device,
},
keepalive,
})
.catch(() => undefined);
}
async restore(): Promise<Saved | null> {
let local: Saved | null = null;
try {
const raw = localStorage.getItem(LOCAL_KEY);
local = raw ? (JSON.parse(raw) as Saved) : null;
} catch {
local = null;
}
let saved = local;
try {
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (
server.track_id &&
server.updated_at &&
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
) {
saved = {
queue: server.queue,
index: server.queue_index,
positionMs: server.position_ms,
context: (server.context?.type ? server.context : null) as PlayContext | null,
volume: server.volume,
shuffle: server.shuffle,
repeat: server.repeat_mode,
savedAt: server.updated_at,
};
}
} catch {
// офлайн — остаёмся с локальной копией
}
return saved;
}
dispose() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = null;
}
}
+60 -339
View File
@@ -1,5 +1,9 @@
import { import {
EventBuffer, EventBuffer,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveOutcome,
type WaveFeedback,
normalizeGain, normalizeGain,
PlayAttempt, PlayAttempt,
PlayQueue, PlayQueue,
@@ -10,80 +14,29 @@ import {
type Track, type Track,
} from '@pipu/core'; } from '@pipu/core';
import { desktopHandle } from '../desktop'; import { MediaControls } from './media';
import type { PlayerState } from './types';
import { AudioEngine } from './engine';
import { PlaybackPersistence } from './persistence';
export type { PlayContext, Track }; export type { PlayContext, Track };
export interface WaveInfo { export type { WaveInfo } from '@pipu/core';
sessionId: string;
activity: string | null;
mood: string | null;
/** Рекомендатель недоступен — играет запасная волна. */
fallback: boolean;
}
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike'; export type { PlayerState } from './types';
/** Когда в очереди волны остаётся столько треков, просим следующую пачку. */
const WAVE_REFILL_AT = 2;
/** Скип раньше этого — «ранний» (сильный отрицательный сигнал). */
const EARLY_SKIP_MS = 30_000;
export interface PlayerState {
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
index: number;
context: PlayContext | null;
playing: boolean;
loading: boolean;
positionMs: number;
durationMs: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: RepeatMode;
error: string | null;
}
const LOCAL_KEY = 'pipu.player';
const SAVE_EVERY_MS = 10_000; const SAVE_EVERY_MS = 10_000;
const FLUSH_EVERY_MS = 30_000; const FLUSH_EVERY_MS = 30_000;
const PRELOAD_BEFORE_MS = 20_000; const PRELOAD_BEFORE_MS = 20_000;
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
const MAX_SAVED_QUEUE = 500;
interface Saved {
queue: string[];
index: number;
positionMs: number;
context: PlayContext | null;
volume: number;
shuffle: boolean;
repeat: RepeatMode;
savedAt: string;
}
function deviceId(): string {
try {
let id = localStorage.getItem('pipu.device');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('pipu.device', id);
}
return id;
} catch {
return 'web';
}
}
/** /**
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement, * Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
* Media Session и сохранение «трек и момент» (локально и на сервере). * Media Session и сохранение «трек и момент» (локально и на сервере).
*/ */
export class Player { export class Player {
private readonly audio: HTMLAudioElement; private readonly engine: AudioEngine;
private readonly overlap: HTMLAudioElement; private get audio() { return this.engine.audio; }
private get overlap() { return this.engine.overlap; }
private fading = false; private fading = false;
private ignoreEnded = false; private ignoreEnded = false;
private readonly queue = new PlayQueue(); private readonly queue = new PlayQueue();
@@ -92,13 +45,17 @@ export class Player {
private context: PlayContext | null = null; private context: PlayContext | null = null;
private state: PlayerState; private state: PlayerState;
private readonly listeners = new Set<() => void>(); private readonly listeners = new Set<() => void>();
private readonly device = deviceId(); private readonly persistence: PlaybackPersistence;
private lastServerSave = 0;
private preloaded: string | null = null; private preloaded: string | null = null;
private refreshedFor: string | null = null; private refreshedFor: string | null = null;
private timers: ReturnType<typeof setInterval>[] = []; private timers: ReturnType<typeof setInterval>[] = [];
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = []; private readonly wavePolicy = new WaveCoordinator({
private waveLoading = false; next: async (id: string, feedback: WaveFeedback[]) => unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id } }, body: { feedback },
}),
),
});
private restoreVersion = 0; private restoreVersion = 0;
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */ /** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null; onReaction: (() => void) | null = null;
@@ -107,10 +64,7 @@ export class Player {
private readonly session: AuthSession, private readonly session: AuthSession,
audio?: HTMLAudioElement, audio?: HTMLAudioElement,
) { ) {
this.audio = audio ?? new Audio(); this.engine = new AudioEngine(audio);
this.audio.preload = 'auto';
this.overlap = new Audio();
this.overlap.preload = 'auto';
this.events = new EventBuffer({ this.events = new EventBuffer({
send: async (events) => { send: async (events) => {
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
@@ -121,9 +75,9 @@ export class Player {
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0, track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null, durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
}; };
this.persistence = new PlaybackPersistence(session, this.getState);
this.bindAudio(); this.bindAudio();
this.bindMediaSession(); this.media = new MediaControls(this, this.getState, () => this.audio);
this.bindDesktopMedia();
} }
/** Запуск фоновой отправки событий (парный вызов — dispose). */ /** Запуск фоновой отправки событий (парный вызов — dispose). */
@@ -140,13 +94,12 @@ export class Player {
getState = () => this.state; getState = () => this.state;
private lastDesktop: { id?: string; playing?: boolean } = {}; private readonly media: MediaControls;
private desktopUnlisten: (() => void) | null = null;
private update(patch: Partial<PlayerState>) { private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch }; this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn(); for (const fn of this.listeners) fn();
this.syncDesktopMedia(); this.media.syncDesktopMedia();
} }
private syncQueue() { private syncQueue() {
@@ -183,16 +136,19 @@ export class Player {
this.restoreVersion++; this.restoreVersion++;
this.finishAttempt('skip'); this.finishAttempt('skip');
this.leaveWave(); this.leaveWave();
const version = this.wavePolicy.version;
const res = unwrap( const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions', { await this.session.client.POST('/api/v1/wave/sessions', {
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) }, body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
}), }),
); );
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter((t) => t.available && t.stream_url); const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) { if (!tracks.length) {
this.update({ error: 'wave.empty' }); this.update({ error: 'wave.empty' });
return; return;
} }
this.wavePolicy.activate(res.session_id);
this.context = { type: 'wave', id: res.session_id, label: seed?.label }; this.context = { type: 'wave', id: res.session_id, label: seed?.label };
this.queue.shuffle = false; this.queue.shuffle = false;
this.queue.repeat = 'off'; this.queue.repeat = 'off';
@@ -210,12 +166,14 @@ export class Player {
const wave = this.state.wave; const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood); if (!wave) return this.startWave(activity, mood);
this.update({ wave: { ...wave, activity, mood } }); this.update({ wave: { ...wave, activity, mood } });
const version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap( const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', { await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } }, params: { path: { id: wave.sessionId } },
body: { activity, mood }, body: { activity, mood },
}), }),
); );
if (!this.wavePolicy.isCurrent(version)) return;
const fresh = res.tracks.filter((t) => t.available && t.stream_url); const fresh = res.tracks.filter((t) => t.available && t.stream_url);
this.queue.dropUpcoming(); this.queue.dropUpcoming();
this.queue.append(fresh); this.queue.append(fresh);
@@ -236,8 +194,8 @@ export class Player {
private leaveWave() { private leaveWave() {
const wave = this.state.wave; const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return; if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null }); this.update({ wave: null });
void this.session.client void this.session.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } }) .DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
@@ -245,7 +203,7 @@ export class Player {
} }
private noteWave(trackId: string, outcome: WaveOutcome) { private noteWave(trackId: string, outcome: WaveOutcome) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome }); this.wavePolicy.note(trackId, outcome);
} }
/** /**
@@ -253,29 +211,12 @@ export class Player {
* после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию. * после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию.
*/ */
private async refillWave(refresh = false) { private async refillWave(refresh = false) {
const wave = this.state.wave; await this.wavePolicy.refill(this.queue.remaining, refresh, (res) => {
if (!wave || this.waveLoading) return;
if (!refresh && this.queue.remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return; // волну уже выключили
if (refresh) this.queue.dropUpcoming(); if (refresh) this.queue.dropUpcoming();
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url)); this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
this.update({ wave: { ...this.state.wave, fallback: res.fallback } }); this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
this.syncQueue(); this.syncQueue();
} catch { });
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
} finally {
this.waveLoading = false;
}
} }
toggle() { toggle() {
@@ -289,7 +230,7 @@ export class Player {
void this.load(true, this.state.positionMs); void this.load(true, this.state.positionMs);
return; return;
} }
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined); void this.engine.resume()?.catch(() => undefined);
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса. // play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false })); void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
} }
@@ -402,7 +343,7 @@ export class Player {
// Keep the restored element's buffer when the user presses Play. // 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.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000; if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
this.updateMediaMetadata(track); this.media.updateMediaMetadata(track);
if (!autoplay) return; if (!autoplay) return;
this.applyLoudness(); this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs); this.attempt = new PlayAttempt(track, startMs);
@@ -418,7 +359,8 @@ export class Player {
void this.refillWave(); void this.refillWave();
if (autoplay) { if (autoplay) {
try { try {
if (this.audioContext?.state === 'suspended') await this.audioContext.resume(); const resumed = this.engine.resume();
if (resumed) await resumed;
await this.audio.play(); await this.audio.play();
} catch { } catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать». // Автозапуск заблокирован браузером — ждём нажатия «Слушать».
@@ -465,73 +407,11 @@ export class Player {
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0)); return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
} }
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */ readAudio = () => this.engine.analysis.read(this.state.playing && !this.state.loading && !this.state.muted);
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) { private applyLoudness(mix = 1) {
const track = this.state.track;
const user = this.state.muted ? 0 : this.state.volume; const user = this.state.muted ? 0 : this.state.volume;
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix; this.engine.applyLoudness(user * normalizeGain(this.state.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 секунд. */ /** В конце трека накладывает следующий на crossfade_s секунд. */
@@ -590,7 +470,7 @@ export class Player {
this.applyLoudness(); this.applyLoudness();
this.attempt = new PlayAttempt(next, Math.round(at * 1000)); 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.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
this.updateMediaMetadata(next); this.media.updateMediaMetadata(next);
void this.audio.play().finally(() => { void this.audio.play().finally(() => {
this.fading = false; this.fading = false;
this.ignoreEnded = false; this.ignoreEnded = false;
@@ -599,13 +479,13 @@ export class Player {
private bindAudio() { private bindAudio() {
const a = this.audio; const a = this.audio;
a.addEventListener('playing', () => { this.engine.on('playing', () => {
this.update({ playing: true, loading: false }); this.update({ playing: true, loading: false });
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) { if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs }); this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs });
} }
}); });
a.addEventListener('pause', () => { this.engine.on('pause', () => {
if (a.ended) return; if (a.ended) return;
this.update({ playing: false }); this.update({ playing: false });
const t = this.queue.current; const t = this.queue.current;
@@ -614,22 +494,22 @@ export class Player {
this.saveServer(); this.saveServer();
} }
}); });
a.addEventListener('waiting', () => { this.engine.on('waiting', () => {
if (this.attempt) this.update({ loading: true }); if (this.attempt) this.update({ loading: true });
}); });
a.addEventListener('timeupdate', () => { this.engine.on('timeupdate', () => {
const ms = Math.round(a.currentTime * 1000); const ms = Math.round(a.currentTime * 1000);
this.attempt?.tick(ms); this.attempt?.tick(ms);
this.update({ positionMs: ms }); this.update({ positionMs: ms });
this.updatePositionState(); this.media.updatePositionState();
if (this.state.playing) { if (this.state.playing) {
this.maybePreload(ms); this.maybePreload(ms);
this.maybeCrossfade(ms); this.maybeCrossfade(ms);
} }
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer(); if (this.state.playing && Date.now() - this.persistence.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal(); this.saveLocal();
}); });
a.addEventListener('ended', () => { this.engine.on('ended', () => {
if (this.ignoreEnded || this.fading || !this.attempt) return; if (this.ignoreEnded || this.fading || !this.attempt) return;
this.finishAttempt('play_end'); this.finishAttempt('play_end');
if (this.queue.next(true)) { if (this.queue.next(true)) {
@@ -640,7 +520,7 @@ export class Player {
this.saveServer(); this.saveServer();
} }
}); });
a.addEventListener('error', () => void this.onError()); this.engine.on('error', () => void this.onError());
} }
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */ /** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
@@ -683,138 +563,11 @@ export class Player {
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined); void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
} }
// --- Media Session ---
private bindDesktopMedia() {
void desktopHandle()?.listenMedia((action) => {
if (action === 'play') this.play();
else if (action === 'pause' || action === 'stop') this.pause();
else if (action === 'toggle') this.toggle();
else if (action === 'next') this.next();
else if (action === 'previous') this.prev();
}).then((stop) => {
this.desktopUnlisten = stop;
});
}
private syncDesktopMedia() {
const desktop = desktopHandle();
const track = this.state.track;
if (!desktop || !track) return;
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.state.playing) return;
this.lastDesktop = { id: track.id, playing: this.state.playing };
desktop.setMedia({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
coverUrl: track.album.cover_url,
playing: this.state.playing,
});
}
private bindMediaSession() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try {
ms.setActionHandler(action, fn);
} catch {
// действие не поддерживается браузером
}
};
set('play', () => this.play());
set('pause', () => this.pause());
set('nexttrack', () => this.next());
set('previoustrack', () => this.prev());
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
}
private updateMediaMetadata(track: Track) {
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
const cover = track.album.cover_url;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
artwork: cover
? [
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
]
: [],
});
}
private updatePositionState() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
const d = this.audio.duration;
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
try {
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
} catch {
// некорректная позиция во время смены трека
}
}
// --- сохранение состояния --- // --- сохранение состояния ---
private snapshot(): Saved { private saveLocal() { this.persistence.saveLocal(); }
const ids = this.queue.tracks.map((t) => t.id); private saveSoon() { this.persistence.saveSoon(); }
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE)); saveServer(keepalive = false) { this.persistence.saveServer(keepalive); }
return {
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
index: this.queue.index - start,
positionMs: this.state.positionMs,
context: this.context,
volume: this.state.volume,
shuffle: this.queue.shuffle,
repeat: this.queue.repeat,
savedAt: new Date().toISOString(),
};
}
private saveLocal() {
if (!this.queue.current) return;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
} catch {
// недоступное хранилище не мешает воспроизведению
}
}
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private saveSoon() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
}
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
saveServer(keepalive = false) {
const cur = this.queue.current;
if (!cur) return;
this.lastServerSave = Date.now();
this.saveLocal();
const s = this.snapshot();
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: cur.id,
position_ms: s.positionMs,
queue: s.queue,
queue_index: s.index,
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
shuffle: s.shuffle,
repeat_mode: s.repeat,
volume: s.volume,
device_id: this.device,
},
keepalive,
})
.catch(() => undefined);
}
/** Уход со страницы: дослать события и состояние. */ /** Уход со страницы: дослать события и состояние. */
onHide() { onHide() {
@@ -828,35 +581,7 @@ export class Player {
*/ */
async restore() { async restore() {
const version = ++this.restoreVersion; const version = ++this.restoreVersion;
let local: Saved | null = null; const saved = await this.persistence.restore();
try {
const raw = localStorage.getItem(LOCAL_KEY);
local = raw ? (JSON.parse(raw) as Saved) : null;
} catch {
local = null;
}
let saved = local;
try {
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (
server.track_id &&
server.updated_at &&
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
) {
saved = {
queue: server.queue,
index: server.queue_index,
positionMs: server.position_ms,
context: (server.context?.type ? server.context : null) as PlayContext | null,
volume: server.volume,
shuffle: server.shuffle,
repeat: server.repeat_mode,
savedAt: server.updated_at,
};
}
} catch {
// офлайн — остаёмся с локальной копией
}
if (version !== this.restoreVersion || !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 } })); const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
if (version !== this.restoreVersion) return; if (version !== this.restoreVersion) return;
@@ -872,25 +597,21 @@ export class Player {
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0; const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
this.syncQueue(); this.syncQueue();
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume }); this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
this.updateMediaMetadata(tracks[idx]!); this.media.updateMediaMetadata(tracks[idx]!);
// Prepare at the saved position without creating a listening attempt. // Prepare at the saved position without creating a listening attempt.
await this.load(false, pos); await this.load(false, pos);
} }
dispose() { dispose() {
this.restoreVersion++; this.restoreVersion++;
this.wavePolicy.activate(null);
this.fading = false; this.fading = false;
this.finishAttempt('skip'); this.finishAttempt('skip');
this.audio.pause(); this.engine.dispose();
this.audio.removeAttribute('src'); this.persistence.dispose();
this.audio.load();
this.overlap.pause();
this.overlap.removeAttribute('src');
this.overlap.load();
for (const t of this.timers) clearInterval(t); for (const t of this.timers) clearInterval(t);
this.timers = []; this.timers = [];
this.desktopUnlisten?.(); this.media.dispose();
this.desktopUnlisten = null;
void this.events.flush(); void this.events.flush();
} }
} }
+18
View File
@@ -0,0 +1,18 @@
import type { WaveInfo, Track, PlayContext, RepeatMode } from '@pipu/core';
export interface PlayerState {
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
index: number;
context: PlayContext | null;
playing: boolean;
loading: boolean;
positionMs: number;
durationMs: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: RepeatMode;
error: string | null;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { detectAppClient, otherAppClients } from './platform';
describe('detectAppClient', () => {
it('picks the native client from the user agent', () => {
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
});
it('lists the remaining platforms after the detected one', () => {
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
});
});
+17
View File
@@ -0,0 +1,17 @@
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
if (/Android/i.test(ua)) return 'android';
if (/iPhone|iPad|iPod/i.test(ua)) return null;
if (/Windows/i.test(ua)) return 'windows';
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
if (/Linux|X11/i.test(ua)) return 'linux';
return null;
}
export function otherAppClients(primary: AppClient | null): AppClient[] {
return ORDER.filter((c) => c !== primary);
}
+33 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { shareTrack } from './share'; import { sharePlaylist, shareTrack } from './share';
import * as desktop from '../../desktop';
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
@@ -38,3 +39,34 @@ describe('shareTrack', () => {
finally { vi.unstubAllGlobals(); } finally { vi.unstubAllGlobals(); }
}); });
}); });
describe('sharePlaylist', () => {
it('copies the shared page URL without exposing streaming links', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
try {
expect(await sharePlaylist('token', 'Mix')).toBe('copied');
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/shared/playlist/token`);
} finally { vi.unstubAllGlobals(); }
});
it('uses the configured music server when sharing from desktop', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.spyOn(desktop, 'desktopHandle').mockReturnValue({ server: 'https://music.example' } as desktop.DesktopHandle);
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
try {
await sharePlaylist('token', 'Mix');
expect(writeText).toHaveBeenCalledWith('https://music.example/shared/playlist/token');
} finally { vi.unstubAllGlobals(); }
});
it('opens the native share sheet on mobile browsers', async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { userAgent: 'Android', share });
try {
expect(await sharePlaylist('token', 'Mix')).toBe('shared');
expect(share).toHaveBeenCalledWith({ title: 'Mix', url: `${window.location.origin}/shared/playlist/token` });
} finally { vi.unstubAllGlobals(); }
});
});
+10 -2
View File
@@ -1,8 +1,16 @@
import { trackShareUrl } from '@pipu/core'; import { playlistShareUrl, trackShareUrl } from '@pipu/core';
import { desktopHandle } from '../../desktop';
/** Desktop always copies; mobile browsers can open their native share sheet. */ /** Desktop always copies; mobile browsers can open their native share sheet. */
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> { export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
const url = trackShareUrl(window.location.origin, id); return shareUrl(trackShareUrl(desktopHandle()?.server ?? window.location.origin, id), title);
}
export async function sharePlaylist(shareId: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
return shareUrl(playlistShareUrl(desktopHandle()?.server ?? window.location.origin, shareId), title);
}
async function shareUrl(url: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) { if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
try { try {
await navigator.share({ title, url }); await navigator.share({ title, url });
+1
View File
@@ -70,4 +70,5 @@ export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_e
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg'; export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg'; export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg'; export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg'; export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
+118 -3
View File
@@ -21,9 +21,10 @@
} }
.logoDot { .logoDot {
display: inline-block;
flex: none;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8); background: url('/favicon.svg') center / contain no-repeat;
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
} }
.button { .button {
@@ -40,7 +41,12 @@
transition: transition:
background 0.15s, background 0.15s,
box-shadow 0.15s, box-shadow 0.15s,
opacity 0.15s; opacity 0.15s,
transform 0.12s;
}
.button:active:not(:disabled) {
transform: scale(0.97);
} }
.button:disabled { .button:disabled {
@@ -52,6 +58,115 @@
cursor: progress; cursor: progress;
} }
.spinner {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: pipuSpin 0.6s linear infinite;
}
@keyframes pipuSpin {
to {
transform: rotate(360deg);
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
text-align: center;
color: var(--text3);
}
.emptyIcon {
color: var(--text3);
opacity: 0.7;
}
.emptyText {
margin: 0;
max-width: 28rem;
font-size: 15px;
line-height: 1.45;
}
.empty a,
.empty button {
color: var(--accent2);
font-weight: 600;
text-decoration: none;
}
.empty a:hover {
text-decoration: underline;
}
.skeleton {
display: block;
background: var(--surface3);
border-radius: 8px;
animation: pipuShimmer 1.2s ease-in-out infinite;
}
.skeletonRound {
border-radius: 50%;
}
@keyframes pipuShimmer {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.pageSkel {
display: flex;
flex-direction: column;
gap: 16px;
}
.skelHero {
display: flex;
align-items: flex-end;
gap: 24px;
margin-bottom: 8px;
}
.skelCover {
width: 160px;
height: 160px;
border-radius: 12px;
flex: none;
}
.skelCopy {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
padding-bottom: 8px;
}
.skelRow {
display: flex;
align-items: center;
gap: 14px;
}
.primary { .primary {
background: var(--accent-fill); background: var(--accent-fill);
color: #fff; color: #fff;
+57 -1
View File
@@ -4,6 +4,7 @@ import {
useRef, useRef,
useState, useState,
type ButtonHTMLAttributes, type ButtonHTMLAttributes,
type CSSProperties,
type InputHTMLAttributes, type InputHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
type ReactNode, type ReactNode,
@@ -52,7 +53,7 @@ export function Button({ variant = 'secondary', icon, busy, className, children,
aria-busy={busy || undefined} aria-busy={busy || undefined}
className={cx(s.button, s[variant], className)} className={cx(s.button, s[variant], className)}
> >
{icon && <Icon src={icon} size={18} />} {busy ? <span className={s.spinner} aria-hidden /> : icon ? <Icon src={icon} size={18} /> : null}
{children} {children}
</button> </button>
); );
@@ -283,6 +284,61 @@ export function Menu({
); );
} }
/** Пустой список: иконка, текст и действие (docs/design.md#5). */
export function EmptyState({
icon,
children,
action,
}: {
icon?: string;
children: ReactNode;
action?: ReactNode;
}) {
return (
<div className={s.empty} role="status">
{icon && <Icon src={icon} size={40} className={s.emptyIcon} />}
<p className={s.emptyText}>{children}</p>
{action}
</div>
);
}
export function Skeleton({
className,
style,
round,
}: {
className?: string;
style?: CSSProperties;
round?: boolean;
}) {
return <span className={cx(s.skeleton, round && s.skeletonRound, className)} style={style} aria-hidden />;
}
/** Каркас героя и строк списка, пока страница ещё грузится. */
export function PageSkeleton() {
const { t } = useTranslation();
return (
<div className={s.pageSkel} role="status" aria-busy="true" aria-label={t('loading')}>
<div className={s.skelHero}>
<Skeleton className={s.skelCover} />
<div className={s.skelCopy}>
<Skeleton style={{ width: 72, height: 12 }} />
<Skeleton style={{ width: '55%', height: 28 }} />
<Skeleton style={{ width: '38%', height: 14 }} />
</div>
</div>
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className={s.skelRow}>
<Skeleton style={{ width: 40, height: 40 }} />
<Skeleton style={{ flex: 1, height: 14 }} />
<Skeleton style={{ width: 36, height: 12 }} />
</div>
))}
</div>
);
}
export function Avatar({ name, size = 32 }: { name: string; size?: number }) { export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
const initials = name const initials = name
.split(/[\s._-]+/) .split(/[\s._-]+/)
+60 -5
View File
@@ -70,11 +70,13 @@ export function makeUser(username: string, role: Role, extra: Partial<FakeUser>
*/ */
export function fakeApi(initial: FakeUser[]) { export function fakeApi(initial: FakeUser[]) {
const users = new Map(initial.map((u) => [u.id, { ...u }])); const users = new Map(initial.map((u) => [u.id, { ...u }]));
const onboarding = new Map<string, components['schemas']['OnboardingChoice'] | null>();
const prefs = new Map<string, components['schemas']['UiPrefs']>(); const prefs = new Map<string, components['schemas']['UiPrefs']>();
let current: string | null = null; // id вошедшего (одна сессия на тест) let current: string | null = null; // id вошедшего (одна сессия на тест)
let token = 0; let token = 0;
const calls: { method: string; path: string; body: unknown }[] = []; const calls: { method: string; path: string; body: unknown }[] = [];
const reactions = new Map<string, 'like' | 'dislike'>(); const reactions = new Map<string, 'like' | 'dislike'>();
const playlistOwners = new Map<string, string>();
const playlists = new Map<string, components['schemas']['Playlist']>(); const playlists = new Map<string, components['schemas']['Playlist']>();
const me = (u: FakeUser): Me => ({ const me = (u: FakeUser): Me => ({
@@ -116,6 +118,34 @@ export function fakeApi(initial: FakeUser[]) {
min_android_version_code: 0, min_android_version_code: 0,
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials), bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
}); });
case 'GET /api/v1/app/clients':
return json({
version: '1.3.0',
version_code: 1_030_099,
notes_md: '',
clients: [
{
client: 'windows',
filename: 'pipu-music_1.3.0_x64-setup.exe',
size: 20_000_000,
download_url: '/api/v1/app/download/windows/1.3.0',
older: [{ version: '1.2.0', filename: 'pipu-music_1.2.0_x64-setup.exe', size: 19_000_000, download_url: '/api/v1/app/download/windows/1.2.0' }],
},
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.deb', size: 4_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
],
});
case 'GET /api/v1/app/android/latest':
return json({
version: '1.3.0',
version_code: 1_030_099,
notes_md: '',
size: 12_000_000,
sha256: 'ab'.repeat(32),
download_url: '/api/v1/app/android/download/1.3.0',
min_supported: 0,
});
case 'POST /api/v1/auth/login': { case 'POST /api/v1/auth/login': {
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password); const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
if (!u) return problem(401, 'auth.invalid_credentials'); if (!u) return problem(401, 'auth.invalid_credentials');
@@ -128,20 +158,37 @@ export function fakeApi(initial: FakeUser[]) {
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized'); 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 sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
if (sharedPath && req.method === 'GET') {
const item = [...playlists.values()].find(p => p.share_id === sharedPath[1]);
return item ? json({ ...item, share_id: null }) : problem(404, 'playlist.not_found');
}
const sharePath = /^\/api\/v1\/me\/playlists\/([^/]+)\/share$/.exec(path);
if (sharePath) {
const item = playlists.get(sharePath[1]!);
if (!item || playlistOwners.get(item.id) !== user.id) return problem(404, 'playlist.not_found');
if (req.method === 'POST') {
const share_id = item.share_id ?? `00000000-0000-4000-9001-${String(++seq).padStart(12, '0')}`;
playlists.set(item.id, { ...item, share_id }); return json({ share_id });
}
if (req.method === 'DELETE') { playlists.set(item.id, { ...item, share_id: null }); return new Response(null, { status: 204 }); }
}
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()].filter(p => !playlistOwners.has(p.id) || playlistOwners.get(p.id) === user.id) });
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path); const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) { if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`; const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
const old = playlists.get(id); const old = playlists.get(id);
if (playlistPath && !old) return problem(404, 'playlist.not_found'); if (playlistPath && (!old || (playlistOwners.has(id) && playlistOwners.get(id) !== user.id))) return problem(404, 'playlist.not_found');
if (req.method === 'GET') return json(old); if (req.method === 'GET') return json(old);
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); } if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict'); 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 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, const item = { ...old, 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), 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() }; revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
playlists.set(id, item); playlists.set(id, item);
if (!old) playlistOwners.set(id, user.id);
return json(item, old ? 200 : 201); return json(item, old ? 200 : 201);
} }
@@ -178,7 +225,10 @@ export function fakeApi(initial: FakeUser[]) {
case 'POST /api/v1/uploads/seen': case 'POST /api/v1/uploads/seen':
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
case 'GET /api/v1/onboarding': case 'GET /api/v1/onboarding':
return json({ required: false, artists: [], genres: [] }); return json({ required: onboarding.get(user!.id) === null, artists: [], genres: [], choice: onboarding.get(user!.id) ?? undefined });
case 'PUT /api/v1/me/onboarding':
onboarding.set(user!.id, body as components['schemas']['OnboardingChoice']);
return new Response(null, { status: 204 });
case 'GET /api/v1/me/likes': case 'GET /api/v1/me/likes':
case 'GET /api/v1/me/dislikes': case 'GET /api/v1/me/dislikes':
case 'GET /api/v1/me/history': case 'GET /api/v1/me/history':
@@ -257,8 +307,10 @@ export function fakeApi(initial: FakeUser[]) {
case `GET /api/v1/artists/${kino.id}/albums`: case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] }); return json({ items: [albumSummary] });
} }
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) {
onboarding.set(path.split('/')[5]!, null);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
}
const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path); const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path);
if (userPath && req.method === 'PATCH') { if (userPath && req.method === 'PATCH') {
if (!user || user.role === 'user') return problem(403, 'auth.forbidden'); if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
@@ -280,6 +332,9 @@ export function fakeApi(initial: FakeUser[]) {
users.set(target.id, target); users.set(target.id, target);
return json(admin(target)); return json(admin(target));
} }
if (req.method === 'GET' && /^\/api\/v1\/tracks\/[^/]+\/genius$/.test(path)) {
return json({ status: 'disabled', lyrics: '', source_url: '', song_image_url: '', artist_image_url: '', description: '', annotations: [] });
}
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path); const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path); const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
if (trackPath && req.method === 'GET') { if (trackPath && req.method === 'GET') {
+21
View File
@@ -227,12 +227,33 @@
color: var(--accent2); color: var(--accent2);
} }
.skip {
position: absolute;
left: 12px;
top: 8px;
z-index: 50;
padding: 8px 14px;
border-radius: 10px;
background: var(--elev);
color: var(--text);
font-weight: 600;
text-decoration: none;
transform: translateY(-120%);
}
.skip:focus {
transform: none;
}
.content { .content {
grid-area: content; grid-area: content;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 32px 36px; padding: 32px 36px;
}
.routeIn {
animation: pipuIn 0.2s ease-out; animation: pipuIn 0.2s ease-out;
} }
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more