Compare commits

..
15 Commits
Author SHA1 Message Date
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
163 changed files with 7901 additions and 2807 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. Полная сборка инсталлятора — локально
# (`pnpm --filter @pipu/desktop bundle`): на раннере нет Windows и WebKitGTK.
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
name: ci / desktop
on:
+144 -3
View File
@@ -1,6 +1,8 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs).
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
# и переписывают статус: собран / не удалась (scripts/release.mjs).
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
#
# Секреты (Settings → Actions → Secrets):
@@ -197,3 +199,142 @@ jobs:
- name: Статус в релизе — сборка отменена
if: 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/tauri.release.json
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/README.md](docs/README.md)
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
## Структура
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
```bash
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
pnpm check:boundaries
```
```bash
+132 -8
View File
@@ -22,6 +22,51 @@ tags:
security:
- bearerAuth: []
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:
get:
operationId: getHealthz
@@ -1382,9 +1427,10 @@ paths:
get:
operationId: getOnboarding
tags: [wave]
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
summary: Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
description: |
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
`required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации.
`choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере.
responses:
"200":
description: Варианты и состояние
@@ -1502,9 +1548,10 @@ paths:
operationId: getAppClients
tags: [app]
security: [] # публично: страница /download без входа
summary: Клиенты последнего релиза (Android и десктоп)
summary: Клиенты последних релизов (Android и десктоп)
description: |
Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
responses:
"200":
@@ -1773,6 +1820,8 @@ components:
properties:
required:
type: boolean
choice:
$ref: "#/components/schemas/OnboardingChoice"
artists:
type: array
items:
@@ -1808,6 +1857,19 @@ components:
type: object
required: [artist_ids, genres]
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:
type: array
maxItems: 50
@@ -3016,10 +3078,51 @@ components:
description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости
allOf:
- $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:
type: object
required: [id, name, album_count, track_count]
properties:
image_url: { type: string }
id:
type: string
format: uuid
@@ -3495,12 +3598,12 @@ components:
AppClient:
type: string
enum: [android, windows, linux, macos]
AppClientFile:
AppClientBuild:
type: object
required: [client, filename, size, download_url]
required: [version, filename, size, download_url]
properties:
client:
$ref: "#/components/schemas/AppClient"
version:
type: string
filename:
type: string
size:
@@ -3510,6 +3613,27 @@ components:
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]
+8 -6
View File
@@ -5,12 +5,14 @@ import { fileURLToPath } from 'node:url';
const root = dirname(fileURLToPath(import.meta.url));
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
let version = '0.0.0';
try {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
version = tag.replace(/^v/, '').replace(/-rc\.\d+$/, '');
} catch {
// local untagged build
let version = process.env.PIPU_VERSION ?? '0.0.0';
if (!process.env.PIPU_VERSION) {
try {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
version = tag.replace(/^v/, '');
} catch {
// local untagged build
}
}
conf.version = version;
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
+4 -1
View File
@@ -6,8 +6,11 @@
"scripts": {
"dev": "tauri dev",
"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",
"icons": "tauri icon ../web/public/favicon.svg"
"icons": "node ../../scripts/generate-icons.mjs"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.0"
+3 -1
View File
@@ -38,5 +38,7 @@ keyring = { version = "3.6.3", features = ["apple-native"] }
[profile.release]
strip = true
lto = "thin"
lto = true
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
@@ -24,7 +24,7 @@
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
"linux": {
"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 } }
}
+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',
slug: 'pipu-music',
scheme: 'pipu',
icon: './assets/icon.png',
version,
orientation: 'portrait',
userInterfaceStyle: 'automatic',
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
newArchEnabled: false,
backgroundColor: '#05070D',
android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
monochromeImage: './assets/monochrome-icon.png',
backgroundColor: '#05070D',
},
package: 'app.pipu.music',
versionCode,
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 { useColors } from '../../theme';
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
import { OnboardingGate } from '../../ui/Onboarding';
import { WaveOrb } from '../../ui/wave/WaveOrb';
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
@@ -22,6 +23,10 @@ function iconFor(name: string): IconName {
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
*/
export default function HomeScreen() {
return <OnboardingGate><WaveHome /></OnboardingGate>;
}
function WaveHome() {
const colors = useColors();
const { t } = useTranslation();
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 { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
import { useDownloads } from '../../ui/downloads';
import { RecommendationSettings } from '../../ui/Onboarding';
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
import { useUpdateState } from '../../ui/UpdateDialog';
@@ -74,6 +75,8 @@ export default function ProfileScreen() {
@{user.username} · {t(`roles.${user.role}`)}
</Muted>
<RecommendationSettings />
<Section>
<Row label={t('language.label')}>
<Segmented<Locale>
+6
View File
@@ -91,6 +91,12 @@ export default function PlayerScreen() {
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')}
</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 style={{ gap: 6 }}>
+6 -1
View File
@@ -272,7 +272,7 @@ class Downloads {
if (track.album.cover_url) {
const cover = this.cover(track.album.id);
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));
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();
+53 -51
View File
@@ -1,6 +1,11 @@
import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveInfo,
type WaveOutcome,
type WaveFeedback,
PlayAttempt,
normalizeGain,
unwrap,
@@ -17,19 +22,14 @@ import TrackPlayer, {
} from 'react-native-track-player';
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 { offlineWave } from './offlineWave';
import { eventId } from './eventId';
export { offlineWave };
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export type { WaveInfo } from '@pipu/core';
export interface PlayerState {
ready: boolean;
@@ -44,10 +44,6 @@ export interface PlayerState {
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 playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
@@ -84,8 +80,13 @@ class Controller {
private attempt: PlayAttempt | null = null;
private lastPositionMs = 0;
private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private readonly wavePolicy = new WaveCoordinator({
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 setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>();
@@ -224,15 +225,18 @@ class Controller {
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
return tracks.map((t) => ({
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
duration: t.duration_ms / 1000,
}));
return tracks.map((t) => {
const cover = absolute(this.server, t.album.cover_url);
return {
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
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) {
@@ -305,18 +309,22 @@ class Controller {
if (!this.session) return;
await this.setup();
this.leaveWave();
const version = this.wavePolicy.version;
let res;
try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
} catch (e) {
if (!this.wavePolicy.isCurrent(version)) return;
// Нет связи с сервером — офлайн-волна из скачанного.
const local = offlineWave(downloads.downloaded());
if (!local.length) throw e;
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
}
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter(playable);
if (!tracks.length) return;
this.finishAttempt(this.lastPositionMs / 1000);
this.wavePolicy.activate(res.session_id);
this.update({
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 },
@@ -329,13 +337,16 @@ class Controller {
async setWaveContext(activity: string | null, mood: string | null) {
const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood);
const version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
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 } });
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
if (res.tracks.some(playable)) {
@@ -348,8 +359,8 @@ class Controller {
private leaveWave() {
const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null });
void this.session?.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
@@ -357,46 +368,36 @@ class Controller {
}
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);
await TrackPlayer.removeUpcomingTracks();
const add = tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...keep, ...add] });
const native = await this.toRntp(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) {
const wave = this.state.wave;
if (!wave || !this.session || this.waveLoading) return;
if (!this.session) return;
const remaining = this.state.queue.length - this.state.index - 1;
if (!refresh && 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) await this.replaceUpcoming(res.tracks);
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
if (refresh) await this.replaceUpcoming(res.tracks, current);
else {
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({ wave: { ...wave, fallback: res.fallback } });
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback];
} finally {
this.waveLoading = false;
}
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
});
}
// --- состояние ---
@@ -440,6 +441,7 @@ class Controller {
}
async reset() {
this.wavePolicy.activate(null);
this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
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 { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { downloads, sizedCover } from '../offline/downloads';
import { absolute } from '../player/controller';
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;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
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;
return (
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
+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 должен освобождать обработчики.
+7 -2
View File
@@ -10,14 +10,19 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
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.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
await page.getByRole('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 expect(page.getByRole('dialog')).toHaveCount(0);
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
+9
View File
@@ -48,6 +48,15 @@ test('settings expose crossfade and loudness normalization', async ({ page }) =>
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
await slider.fill('4');
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 }) => {
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="color-scheme" content="dark light" />
<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>
</head>
<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

+48 -1
View File
@@ -291,7 +291,7 @@ describe('player', () => {
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
expect(router.state.location.pathname).toBe(path);
await user.click(screen.getByRole('listitem'));
await user.click(screen.getByRole('button', { name: 'Слушать' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
@@ -409,6 +409,26 @@ describe('safeRedirect', () => {
});
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 () => {
const alice = makeUser('alice', 'user');
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
@@ -459,3 +479,30 @@ describe('playlists', () => {
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 });
});
});
+57 -2
View File
@@ -8,12 +8,18 @@
color: rgba(255, 255, 255, 0.85);
}
.cover img {
.coverImg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease;
}
.coverImgOn {
opacity: 1;
}
.round {
@@ -59,10 +65,52 @@
width: 128px;
}
.cardCoverWrap {
position: relative;
margin-bottom: 4px;
overflow: hidden;
border-radius: 12px;
}
.cardCover {
aspect-ratio: 1;
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 {
@@ -104,6 +152,12 @@
min-height: 56px;
padding: 6px 12px;
border-radius: 10px;
animation: pipuIn 0.2s ease-out both;
animation-delay: var(--stagger, 0ms);
}
.trackStatic {
animation: none;
}
.track:has(.trackAlbum) {
@@ -272,6 +326,7 @@
}
@media (max-width: 767px) {
.cardPlay { opacity: 1; transform: none; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
+37 -4
View File
@@ -1,10 +1,12 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { Link } from '@tanstack/react-router';
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 { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
@@ -28,6 +30,7 @@ function fallbackColors(id: string): string[] {
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
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 size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`;
@@ -58,7 +61,7 @@ export function Cover({
aria-hidden
>
{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} />
)}
@@ -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 }) {
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 (
<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.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
@@ -154,7 +186,8 @@ export function TrackList({
role="listitem"
tabIndex={tr.available ? 0 : -1}
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')}
onClick={(e) => {
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
+10 -9
View File
@@ -1,12 +1,12 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { queueIcon } from '../shared/ui/icons';
import { playlistAddIcon } from '../shared/ui/icons';
import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist'];
@@ -61,7 +61,7 @@ export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
}
/** 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 session = useSession();
const cache = useQueryClient();
@@ -84,14 +84,15 @@ function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: ()
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div>
</Dialog>;
}
});
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
const close = useCallback(() => setSelectedIds(null), []);
return <>
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
{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={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
{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 { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { Cover, TrackList, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.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 } } })),
});
if (album.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (album.isError) return <NotFound error={album.error} />;
const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a?.title };
if (!a) return null;
const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a.title };
return (
<div className={s.page}>
<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 />}
<Cover album={a ?? { id }} size={200} />
<Cover album={a} size={200} className={c.heroCover} />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span>
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
</span>
)}
{a && a.tracks.some((tr) => tr.available) && (
<h1 className={c.heroTitle}>{a.title}</h1>
<span className={c.heroMeta}>
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
</span>
{a.tracks.some((tr) => tr.available) && (
<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)}>
{t('catalog.play')}
</Button>
@@ -67,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
)}
</div>
</header>
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
<TrackList tracks={a.tracks} numbered context={ctx} />
</div>
);
}
+35 -9
View File
@@ -12,7 +12,7 @@ import s from './auth.module.css';
/**
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
* Сборки — до 8 последних релизов Gitea через сервер; первым предлагается клиент текущей ОС.
*/
export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']);
@@ -40,10 +40,17 @@ export function AppDownloadPage() {
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
<h1>{t('app.title')}</h1>
<p className={s.lead}>{t('app.subtitle')}</p>
{latest.isPending ? null : data ? (
{latest.isPending ? (
<p role="status">{t('common:loading')}</p>
) : latest.isError ? (
<div className={s.hint}>{t('app.unavailable')}</div>
) : data ? (
<>
{primary ? (
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
<>
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: primary.version ?? data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
<OlderVersions items={primary.older ?? []} />
</>
) : detected ? (
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
) : null}
@@ -72,12 +79,15 @@ export function AppDownloadPage() {
{others.map((id) => {
const file = files[id]!;
return (
<a key={id} className={s.downloadAlt} href={file.download_url} download={file.filename}>
{t(`app.download.${id}`)}
<span>
{t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)}
</span>
</a>
<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>
@@ -91,6 +101,22 @@ export function AppDownloadPage() {
);
}
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 (
+35 -26
View File
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
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 { playIcon, waveIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.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,
});
if (artist.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data;
if (!a) return null;
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 appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
return (
<div className={s.page}>
<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}>
<span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span>
)}
{a && (
<div className={c.heroActions}>
<Button
variant="primary"
className={c.pill}
icon={waveIcon}
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
>
{t('catalog.artist_wave')}
<h1 className={c.heroTitle}>{a.name}</h1>
<span className={c.heroMeta}>
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span>
<div className={c.heroActions}>
<Button
variant="primary"
className={c.pill}
icon={waveIcon}
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
>
{t('catalog.artist_wave')}
</Button>
{tracks.length > 0 && (
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
{tracks.length > 0 && (
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
)}
</div>
)}
)}
</div>
</div>
</header>
{tracks.length > 0 && (
+6 -1
View File
@@ -2,16 +2,21 @@ import type { ReactNode } from 'react';
import { Logo } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './auth.module.css';
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
/** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
return (
<div className={s.screen}>
<div className={s.backdrop} aria-hidden>
<span className={s.blob1} />
<span className={s.blob2} />
<span className={s.blob3} />
<div className={s.waveDecor}>
<WaveButton interactive={false} size={620} label="" theme={theme} />
</div>
</div>
<header className={s.top}>
<Logo />
+18 -3
View File
@@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
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 s from './page.module.css';
@@ -109,7 +109,12 @@ function History() {
</div>
)}
<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) => (
<section key={g.key} className={c.section}>
<h2 className={c.day}>{g.title}</h2>
@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
</div>
)}
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <PageSkeleton />}
{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 && (
<TrackList
+16 -2
View File
@@ -21,7 +21,7 @@ import {
waveSunIcon,
waveWeekendIcon,
} 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 s from './home.module.css';
import { Onboarding } from './Onboarding';
@@ -52,8 +52,16 @@ export function HomePage() {
const onboarding = useQuery({
queryKey: ['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) {
return (
<Onboarding
@@ -136,6 +144,12 @@ function WaveHome() {
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
<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) && (
<div className={s.picker}>
{showActivities && (
+27 -10
View File
@@ -1,5 +1,5 @@
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 { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -20,17 +20,19 @@ const MIN_ARTISTS = 3;
* Онбординг вкуса (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 session = useSession();
const errorText = useErrorText();
const [artists, setArtists] = useState<Set<string>>(new Set());
const [genres, setGenres] = 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(options.choice?.genres));
const [gender, setGender] = useState(options.choice?.gender ?? null);
const [ageGroup, setAgeGroup] = useState(options.choice?.age_group ?? null);
const save = useMutation({
mutationFn: async (skip: boolean) =>
unwrap(
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,
@@ -43,7 +45,7 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
else next.add(key);
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 (
<div className={s.page}>
@@ -52,6 +54,21 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
<p>{t('onboarding.subtitle')}</p>
</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')}>
<h2 className={s.label}>{t('onboarding.artists')}</h2>
<div className={s.artists}>
@@ -101,11 +118,11 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<div className={s.actions}>
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
{t('onboarding.skip')}
<Button variant="ghost" disabled={save.isPending} busy={save.isPending && save.variables} onClick={() => editing ? onDone() : save.mutate(true)}>
{t(editing ? 'form.cancel' : 'onboarding.skip')}
</Button>
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough || save.isPending} onClick={() => save.mutate(false)}>
{enough ? t(editing ? 'settings.save' : 'onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
</Button>
</div>
</div>
+67 -16
View File
@@ -1,16 +1,17 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useState, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons';
import c from './catalog.module.css';
import s from './page.module.css';
import p from './playlists.module.css';
@@ -27,13 +28,16 @@ export function PlaylistsPage() {
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
<Cover album={item} size="100%" />
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
{item.description && <span className={p.description}>{item.description}</span>}
</Link>)}</div>
{list.isPending && <PageSkeleton />}
{list.isSuccess && !items.length && (
<EmptyState
icon={search ? searchIcon : playlistAddIcon}
action={!search ? <Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button> : undefined}
>
{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 } }); }} />}
</div>;
}
@@ -45,7 +49,7 @@ export function PlaylistPage({ id }: { id: string }) {
return <div className={s.page}>
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
{query.isPending && <PageSkeleton />}
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
</div>;
}
@@ -93,7 +97,11 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
</div>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
{!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)}
actions={manage ? (track, i) => <span className={p.actions}>
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
@@ -101,9 +109,52 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
</span> : undefined} />
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
{remove && (
<Dialog
title={t('playlists.delete')}
onClose={() => {
if (!deletion.isPending) setRemove(false);
}}
footer={
<>
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
</>
}
>
<p>{t('playlists.delete_confirm', { name: playlist.name })}</p>
<FormError>{deletion.error ? errorText(deletion.error) : null}</FormError>
</Dialog>
)}
</>;
}
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 { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
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 c from './catalog.module.css';
@@ -68,7 +69,11 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
</div>
<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 && (
<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 { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css';
import { Onboarding } from './Onboarding';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
@@ -24,6 +25,7 @@ export function SettingsPage() {
<h1>{t('settings.title')}</h1>
</div>
<ProfileSection />
<RecommendationSettings />
<AppearanceSection />
<PasswordSection />
<SessionsSection />
@@ -357,3 +359,17 @@ function SessionsSection() {
</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 { 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 { pauseIcon, playIcon } from '../shared/ui/icons';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
import { TrackGenius } from './TrackGenius';
export function TrackPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const st = usePlayerState();
const track = useQuery({
queryKey: ['track', 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} />;
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 (
<div className={s.page}>
<header className={c.hero}>
{tr && <Cover album={tr.album} size={200} />}
<div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
{tr && <>
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
</>}
<div className={c.listen}>
<header className={c.hero}>
{tr.album.cover_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
</span>
)}
<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>
</header>
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
</div>
</div>
);
}
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import s from '../page.module.css';
import { UploadSettingsSection } from './UploadQueuePage';
import { UploadSettingsSection } from '../../features/uploads/UploadSettingsSection';
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
export function AdminSettingsPage() {
+3 -3
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
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';
type Entry = components['schemas']['AuditEntry'];
@@ -67,8 +67,8 @@ export function AuditPage() {
))}
</tbody>
</table>
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
{list.isSuccess && entries.length === 0 && <EmptyState>{t('audit.empty')}</EmptyState>}
{list.isPending && <PageSkeleton />}
</div>
{list.hasNextPage && (
<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 { 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 { 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 l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus';
@@ -19,6 +20,7 @@ type TestResult = components['schemas']['SourceTestResult'];
const SCAN_POLL_MS = 2000;
export function SourcesPage() {
const user = useUser();
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
@@ -43,7 +45,12 @@ export function SourcesPage() {
</Button>
</div>
<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}>
{sources.data?.map((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 { 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 { UploadQueue } from '../../features/uploads/UploadQueue';
import { useSession } from '../../app/session';
import { needsFormatAttention } from '../../features/uploads/files';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
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,
};
}
export function UploadQueuePage() { return <UploadQueue />; }
+3 -3
View File
@@ -9,7 +9,7 @@ import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatRelative } from '../../shared/lib/format';
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, 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';
type AdminUser = components['schemas']['AdminUser'];
@@ -232,8 +232,8 @@ export function UsersPage() {
})}
</tbody>
</table>
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
{list.isSuccess && users.length === 0 && <EmptyState icon={searchIcon}>{t('users.empty')}</EmptyState>}
{list.isPending && <PageSkeleton />}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
+29
View File
@@ -20,6 +20,15 @@
filter: blur(30px);
}
.waveDecor {
position: absolute;
left: 50%;
top: 46%;
transform: translate(-50%, -50%);
opacity: 0.55;
filter: blur(0);
}
.blob1 {
width: 62vmax;
height: 62vmax;
@@ -244,6 +253,12 @@
gap: 8px;
}
.otherBlock {
display: flex;
flex-direction: column;
gap: 8px;
}
.otherTitle {
margin: 8px 0 0;
color: var(--text3);
@@ -272,6 +287,20 @@
font-weight: 500;
}
.older {
font-size: 13px;
color: var(--text3);
}
.older summary {
cursor: pointer;
font-weight: 600;
}
.older .downloadAlt {
margin-top: 8px;
}
.link {
align-self: center;
}
+41 -2
View File
@@ -60,12 +60,23 @@
.heroBg {
position: absolute;
inset: 0;
opacity: 0.35;
inset: -20%;
opacity: 0.4;
filter: blur(60px);
pointer-events: none;
}
.heroBg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.heroCover {
position: relative;
z-index: 1;
}
.tint {
position: absolute;
inset: 0;
@@ -81,12 +92,40 @@
.heroBody {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 8px;
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 {
font-size: 12px;
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);
}
.chip:active {
transform: scale(0.97);
}
.chipOn,
.chipOn:hover {
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; }
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
.card { 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; }
.coverWrap { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; }
.coverWrap > :first-child { width: 100%; height: 100%; }
.card strong { overflow-wrap: anywhere; }
.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; }
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.actions { display: flex; align-items: center; }
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
.actions button:disabled { opacity: .3; cursor: default; }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
.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 { formatBytes, formatDateTime } from '../../shared/lib/format';
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 { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
import u from './uploads.module.css';
@@ -86,7 +86,11 @@ export function MyUploadsPage() {
</Link>
</div>
<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 && (
<div className={s.tableWrap}>
<table className={s.table}>
+1 -84
View File
@@ -1,84 +1 @@
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 '../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;
};
}
export * from '../../features/uploads/common';
+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 { Player } from './player';
import { AudioAnalysis } from './analysis';
import { AudioEngine } from './engine';
afterEach(() => vi.restoreAllMocks());
function fixture() {
const player = new Player({} as AuthSession, document.createElement('audio'));
const sample = vi.fn((data: Uint8Array) => {
data.fill(0);
// 48 kHz / 1024: bins 1..4 cover the bass band.
data.fill(255, 1, 5);
});
Object.assign(player, {
state: { ...player.getState(), playing: true },
analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample },
});
return { player, sample };
const analyser = { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample } as unknown as AnalyserNode;
const analysis = new AudioAnalysis(() => analyser);
return { analysis, sample };
}
describe('audio reactivity', () => {
it('separates bass from the other frequency bands and shares the frame', () => {
vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture();
expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 });
player.readAudio();
const { analysis, sample } = fixture();
expect(analysis.read(true)).toEqual({ bass: 1, mid: 0, treble: 0 });
analysis.read(true);
expect(sample).toHaveBeenCalledTimes(1);
});
it('returns silence after pausing or muting', () => {
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture();
player.readAudio();
Object.assign(player, { state: { ...player.getState(), playing: false } });
const { analysis, sample } = fixture();
analysis.read(true);
clock.mockReturnValue(120);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } });
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
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);
});
it('works when Web Audio is unavailable', () => {
const player = new Player({} as AuthSession, document.createElement('audio'));
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
const analysis = new AudioAnalysis(() => null);
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 {
EventBuffer,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveOutcome,
type WaveFeedback,
normalizeGain,
PlayAttempt,
PlayQueue,
@@ -10,80 +14,29 @@ import {
type Track,
} 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 interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
/** Рекомендатель недоступен — играет запасная волна. */
fallback: boolean;
}
export type { WaveInfo } from '@pipu/core';
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 FLUSH_EVERY_MS = 30_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,
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
private readonly audio: HTMLAudioElement;
private readonly overlap: HTMLAudioElement;
private readonly engine: AudioEngine;
private get audio() { return this.engine.audio; }
private get overlap() { return this.engine.overlap; }
private fading = false;
private ignoreEnded = false;
private readonly queue = new PlayQueue();
@@ -92,13 +45,17 @@ export class Player {
private context: PlayContext | null = null;
private state: PlayerState;
private readonly listeners = new Set<() => void>();
private readonly device = deviceId();
private lastServerSave = 0;
private readonly persistence: PlaybackPersistence;
private preloaded: string | null = null;
private refreshedFor: string | null = null;
private timers: ReturnType<typeof setInterval>[] = [];
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private readonly wavePolicy = new WaveCoordinator({
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;
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null;
@@ -107,10 +64,7 @@ export class Player {
private readonly session: AuthSession,
audio?: HTMLAudioElement,
) {
this.audio = audio ?? new Audio();
this.audio.preload = 'auto';
this.overlap = new Audio();
this.overlap.preload = 'auto';
this.engine = new AudioEngine(audio);
this.events = new EventBuffer({
send: async (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,
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
};
this.persistence = new PlaybackPersistence(session, this.getState);
this.bindAudio();
this.bindMediaSession();
this.bindDesktopMedia();
this.media = new MediaControls(this, this.getState, () => this.audio);
}
/** Запуск фоновой отправки событий (парный вызов — dispose). */
@@ -140,13 +94,12 @@ export class Player {
getState = () => this.state;
private lastDesktop: { id?: string; playing?: boolean } = {};
private desktopUnlisten: (() => void) | null = null;
private readonly media: MediaControls;
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
this.syncDesktopMedia();
this.media.syncDesktopMedia();
}
private syncQueue() {
@@ -183,16 +136,19 @@ export class Player {
this.restoreVersion++;
this.finishAttempt('skip');
this.leaveWave();
const version = this.wavePolicy.version;
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions', {
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);
if (!tracks.length) {
this.update({ error: 'wave.empty' });
return;
}
this.wavePolicy.activate(res.session_id);
this.context = { type: 'wave', id: res.session_id, label: seed?.label };
this.queue.shuffle = false;
this.queue.repeat = 'off';
@@ -210,12 +166,14 @@ export class Player {
const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood);
this.update({ wave: { ...wave, activity, mood } });
const version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
if (!this.wavePolicy.isCurrent(version)) return;
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
this.queue.dropUpcoming();
this.queue.append(fresh);
@@ -236,8 +194,8 @@ export class Player {
private leaveWave() {
const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null });
void this.session.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
@@ -245,7 +203,7 @@ export class Player {
}
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) {
const wave = this.state.wave;
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; // волну уже выключили
await this.wavePolicy.refill(this.queue.remaining, refresh, (res) => {
if (refresh) this.queue.dropUpcoming();
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();
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
} finally {
this.waveLoading = false;
}
});
}
toggle() {
@@ -289,7 +230,7 @@ export class Player {
void this.load(true, this.state.positionMs);
return;
}
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
void this.engine.resume()?.catch(() => undefined);
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
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.
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
this.updateMediaMetadata(track);
this.media.updateMediaMetadata(track);
if (!autoplay) return;
this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs);
@@ -418,7 +359,8 @@ export class Player {
void this.refillWave();
if (autoplay) {
try {
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
const resumed = this.engine.resume();
if (resumed) await resumed;
await this.audio.play();
} catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
@@ -465,73 +407,11 @@ export class Player {
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
}
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
private gain: GainNode | null = null;
private wired = false;
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
private spectrum = new Uint8Array(512);
private audioFrame = { bass: 0, mid: 0, treble: 0 };
private sampledAt = -Infinity;
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
readAudio = () => {
const now = performance.now();
if (now - this.sampledAt < 12) return this.audioFrame;
this.sampledAt = now;
const active = this.state.playing && !this.state.loading && !this.state.muted;
const analyser = this.analyser;
if (!active || !analyser) {
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
return this.audioFrame;
}
analyser.getByteFrequencyData(this.spectrum);
const band = (low: number, high: number) => {
const hz = analyser.context.sampleRate / analyser.fftSize;
const start = Math.max(1, Math.ceil(low / hz));
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
let sum = 0;
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
return Math.sqrt(sum / Math.max(1, end - start));
};
this.audioFrame.bass = band(40, 220);
this.audioFrame.mid = band(220, 2500);
this.audioFrame.treble = band(2500, 10000);
return this.audioFrame;
};
readAudio = () => this.engine.analysis.read(this.state.playing && !this.state.loading && !this.state.muted);
private applyLoudness(mix = 1) {
const track = this.state.track;
const user = this.state.muted ? 0 : this.state.volume;
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
if (!this.wired && typeof AudioContext !== 'undefined') {
try {
const ctx = new AudioContext();
this.audioContext = ctx;
const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain();
src.connect(this.gain);
this.gain.connect(ctx.destination);
this.analyser = ctx.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.55;
this.gain.connect(this.analyser);
// The overlap is already audible through its own output; analyse it only.
try {
const overlapSource = ctx.createMediaElementSource(this.overlap);
overlapSource.connect(ctx.destination);
overlapSource.connect(this.analyser);
} catch { /* Crossfade analysis is optional. */ }
this.audio.volume = 1;
this.wired = true;
} catch {
this.wired = true;
}
}
if (this.gain) this.gain.gain.value = level;
else this.audio.volume = Math.min(1, level);
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
}
/** В конце трека накладывает следующий на crossfade_s секунд. */
@@ -590,7 +470,7 @@ export class Player {
this.applyLoudness();
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
this.updateMediaMetadata(next);
this.media.updateMediaMetadata(next);
void this.audio.play().finally(() => {
this.fading = false;
this.ignoreEnded = false;
@@ -599,13 +479,13 @@ export class Player {
private bindAudio() {
const a = this.audio;
a.addEventListener('playing', () => {
this.engine.on('playing', () => {
this.update({ playing: true, loading: false });
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 });
}
});
a.addEventListener('pause', () => {
this.engine.on('pause', () => {
if (a.ended) return;
this.update({ playing: false });
const t = this.queue.current;
@@ -614,22 +494,22 @@ export class Player {
this.saveServer();
}
});
a.addEventListener('waiting', () => {
this.engine.on('waiting', () => {
if (this.attempt) this.update({ loading: true });
});
a.addEventListener('timeupdate', () => {
this.engine.on('timeupdate', () => {
const ms = Math.round(a.currentTime * 1000);
this.attempt?.tick(ms);
this.update({ positionMs: ms });
this.updatePositionState();
this.media.updatePositionState();
if (this.state.playing) {
this.maybePreload(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();
});
a.addEventListener('ended', () => {
this.engine.on('ended', () => {
if (this.ignoreEnded || this.fading || !this.attempt) return;
this.finishAttempt('play_end');
if (this.queue.next(true)) {
@@ -640,7 +520,7 @@ export class Player {
this.saveServer();
}
});
a.addEventListener('error', () => void this.onError());
this.engine.on('error', () => void this.onError());
}
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
@@ -683,138 +563,11 @@ export class Player {
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 {
const ids = this.queue.tracks.map((t) => t.id);
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
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);
}
private saveLocal() { this.persistence.saveLocal(); }
private saveSoon() { this.persistence.saveSoon(); }
saveServer(keepalive = false) { this.persistence.saveServer(keepalive); }
/** Уход со страницы: дослать события и состояние. */
onHide() {
@@ -828,35 +581,7 @@ export class Player {
*/
async restore() {
const version = ++this.restoreVersion;
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 {
// офлайн — остаёмся с локальной копией
}
const saved = await this.persistence.restore();
if (version !== this.restoreVersion || !saved?.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
if (version !== this.restoreVersion) return;
@@ -872,25 +597,21 @@ export class Player {
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
this.syncQueue();
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.
await this.load(false, pos);
}
dispose() {
this.restoreVersion++;
this.wavePolicy.activate(null);
this.fading = false;
this.finishAttempt('skip');
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.overlap.pause();
this.overlap.removeAttribute('src');
this.overlap.load();
this.engine.dispose();
this.persistence.dispose();
for (const t of this.timers) clearInterval(t);
this.timers = [];
this.desktopUnlisten?.();
this.desktopUnlisten = null;
this.media.dispose();
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;
}
+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 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 playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
+118 -3
View File
@@ -21,9 +21,10 @@
}
.logoDot {
display: inline-block;
flex: none;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
background: url('/favicon.svg') center / contain no-repeat;
}
.button {
@@ -40,7 +41,12 @@
transition:
background 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 {
@@ -52,6 +58,115 @@
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 {
background: var(--accent-fill);
color: #fff;
+57 -1
View File
@@ -4,6 +4,7 @@ import {
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type InputHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
@@ -52,7 +53,7 @@ export function Button({ variant = 'secondary', icon, busy, className, children,
aria-busy={busy || undefined}
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}
</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 }) {
const initials = name
.split(/[\s._-]+/)
+19 -4
View File
@@ -70,6 +70,7 @@ export function makeUser(username: string, role: Role, extra: Partial<FakeUser>
*/
export function fakeApi(initial: FakeUser[]) {
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']>();
let current: string | null = null; // id вошедшего (одна сессия на тест)
let token = 0;
@@ -122,8 +123,14 @@ export function fakeApi(initial: FakeUser[]) {
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' },
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.AppImage', size: 18_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
{
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' },
],
@@ -200,7 +207,10 @@ export function fakeApi(initial: FakeUser[]) {
case 'POST /api/v1/uploads/seen':
return new Response(null, { status: 204 });
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/dislikes':
case 'GET /api/v1/me/history':
@@ -279,8 +289,10 @@ export function fakeApi(initial: FakeUser[]) {
case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] });
}
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path))
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) {
onboarding.set(path.split('/')[5]!, null);
return new Response(null, { status: 204 });
}
const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path);
if (userPath && req.method === 'PATCH') {
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
@@ -302,6 +314,9 @@ export function fakeApi(initial: FakeUser[]) {
users.set(target.id, 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 trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
if (trackPath && req.method === 'GET') {
+21
View File
@@ -227,12 +227,33 @@
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 {
grid-area: content;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 32px 36px;
}
.routeIn {
animation: pipuIn 0.2s ease-out;
}
+19 -7
View File
@@ -1,7 +1,7 @@
import { queueIcon } from '../shared/ui/icons';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { Suspense, useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
@@ -30,7 +30,7 @@ import {
chevronRightIcon,
storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
import { Avatar, cx, Dialog, Icon, Logo, Menu, PageSkeleton } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
import { Ambient } from './Ambient';
@@ -226,6 +226,9 @@ function Shell() {
return (
<div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<a href="#content" className={s.skip}>
{t('nav.skip')}
</a>
<header className={s.top}>
<div className={s.topLeft}>
<Link to="/" className={s.logoLink}>
@@ -284,17 +287,26 @@ function Shell() {
</Dialog>
)}
<main className={s.content}>
<Suspense fallback={null}>
<Outlet />
</Suspense>
</main>
<ShellMain />
<PlayerBar variant={island ? 'island' : 'bar'} />
</div>
);
}
function ShellMain() {
const key = useRouterState({ select: (st) => st.location.pathname });
return (
<main id="content" className={s.content} tabIndex={-1}>
<div key={key} className={s.routeIn}>
<Suspense fallback={<PageSkeleton />}>
<Outlet />
</Suspense>
</div>
</main>
);
}
/** Третье пятно фона — в цвете обложки текущего трека. */
function ShellAmbient() {
const player = usePlayer();
+120 -3
View File
@@ -149,6 +149,115 @@ a.source:hover {
transform: scale(1.06);
}
.play:active {
transform: scale(0.94);
}
.nowArt {
position: relative;
display: grid;
place-items: center;
flex: none;
padding: 0;
border-radius: 10px;
}
.nowEq {
position: absolute;
right: -2px;
bottom: -2px;
padding: 2px 4px;
border-radius: 6px;
background: var(--surface);
}
.coverLive {
animation: pipuCoverPulse 2.4s ease-in-out infinite;
}
@keyframes pipuCoverPulse {
50% {
filter: brightness(1.12);
}
}
.queueScrim,
.sheetScrim {
position: fixed;
inset: 0;
z-index: 15;
background: var(--overlay);
animation: pipuIn 0.18s ease-out;
}
.sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 16;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-height: min(92dvh, 760px);
padding: 12px 20px calc(24px + env(safe-area-inset-bottom, 0px));
border-radius: 24px 24px 0 0;
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
animation: pipuIn 0.22s ease-out;
}
.sheetHead {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-weight: 600;
}
.sheetCover {
width: min(240px, 56vw) !important;
height: min(240px, 56vw) !important;
border-radius: 16px;
}
.sheetMeta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
min-width: 0;
max-width: 100%;
}
.sheetTitle {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
color: inherit;
text-decoration: none;
}
.sheetControls,
.sheetExtra {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.sheet .progress {
max-width: none;
}
.sheet .volume {
flex: 1;
max-width: 160px;
}
.play[aria-busy='true'] {
opacity: 0.7;
}
@@ -218,6 +327,10 @@ a.source:hover {
}
@media (max-width: 767px) {
.now {
cursor: pointer;
}
.bar {
grid-template-columns: minmax(0, 1fr) auto;
padding: 0 12px;
@@ -228,7 +341,7 @@ a.source:hover {
.right,
.controls > :first-child,
.controls > :last-child,
.now > button {
.now > button:not(.nowArt) {
display: none;
}
}
@@ -237,7 +350,7 @@ a.source:hover {
position: fixed;
right: 16px;
bottom: 100px;
z-index: 6;
z-index: 16;
display: flex;
flex-direction: column;
width: min(380px, calc(100% - 32px));
@@ -311,13 +424,17 @@ a.source:hover {
.controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; }
.right { display: flex; gap: 0; }
.right > :not(.queueButton):not(.shareButton) { display: none; }
.right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; }
.error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px;
padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; }
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 359px) {
.now > :first-child { display: none; }
/* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */
.shareButton { display: none; }
}
.shareStatus {
+166 -5
View File
@@ -3,7 +3,8 @@ import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { shareTrack } from '../shared/lib/share';
@@ -36,6 +37,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
const [sharing, setSharing] = useState(false);
@@ -96,8 +99,24 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
return (
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<div className={s.now}>
<Cover album={track.album} size={52} />
<div
className={s.now}
onClick={
narrow
? (e) => {
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
}
: undefined
}
>
{narrow ? (
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
</button>
) : (
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
)}
<div className={s.meta}>
<span className={s.title}>{track.title}</span>
<span className={s.artists}>
@@ -178,6 +197,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
</div>
<div className={s.right}>
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
@@ -230,12 +250,150 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
{sheetOpen && narrow && (
<NowPlayingSheet
variant={variant}
onClose={() => setSheetOpen(false)}
share={share}
sharing={sharing}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
</footer>
);
}
function useNarrow() {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia?.('(max-width: 767px)');
if (!mq) return;
const sync = () => setNarrow(mq.matches);
sync();
mq.addEventListener?.('change', sync);
return () => mq.removeEventListener?.('change', sync);
}, []);
return narrow;
}
function NowPlayingSheet({
variant,
onClose,
share,
sharing,
}: {
variant: 'bar' | 'island';
onClose: () => void;
share: () => Promise<void>;
sharing: boolean;
}) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const track = st.track;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
if (!track) return null;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const repeatLabel = t(`player.repeat_${st.repeat}`);
return createPortal(
<div className={s.sheetScrim} onClick={onClose}>
<div
className={cx(s.sheet, variant === 'island' && s.sheetIsland)}
role="dialog"
aria-modal="true"
aria-label={t('player.now_playing')}
onClick={(e) => e.stopPropagation()}
>
<div className={s.sheetHead}>
<span>{t('player.now_playing')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} />
</button>
</div>
<Cover album={track.album} size={240} className={cx(s.sheetCover, st.playing && s.coverLive)} />
<div className={s.sheetMeta}>
<Link to="/track/$id" params={{ id: track.id }} className={s.sheetTitle} onClick={onClose}>
{track.title}
</Link>
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
</span>
</div>
<div className={s.progress}>
<span className={s.time}>{formatDuration(st.positionMs)}</span>
<input
type="range"
className={s.range}
min={0}
max={st.durationMs || track.duration_ms}
step={1000}
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
aria-label={t('player.progress')}
onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
</div>
<div className={s.sheetControls}>
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} />
</button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} />
</button>
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.sheetExtra}>
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
<Icon src={shuffleIcon} size={22} />
</button>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={22} />
</button>
<input
type="range"
className={cx(s.range, s.volume)}
min={0}
max={1}
step={0.01}
value={st.muted ? 0 : st.volume}
aria-label={t('player.volume')}
onChange={(e) => player.setVolume(Number(e.target.value))}
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
</div>
</div>,
document.body,
);
}
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation();
@@ -254,7 +412,9 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
}, [onClose]);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal(
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<>
<div className={s.queueScrim} onClick={onClose} />
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}>
<span>{t('player.queue')}</span>
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
@@ -280,7 +440,8 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
))}
</div>
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
</aside>,
</aside>
</>,
document.body,
);
}
+14 -7
View File
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { searchIcon } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
@@ -36,6 +37,7 @@ export function SearchBox({
}) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const navigate = useNavigate();
const listId = useId();
const inputRef = useRef<HTMLInputElement>(null);
@@ -118,8 +120,7 @@ export function SearchBox({
title: tr.title,
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
cover: tr.album,
// Воспроизведение — M3; пока трек открывает свой альбом.
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
go: () => player.playList([tr], 0, { type: 'search', id: debounced, label: debounced }),
});
}
@@ -149,7 +150,8 @@ export function SearchBox({
}
};
const showList = open && debounced !== '' && data !== undefined;
const showPopover = open && debounced !== '';
const showList = showPopover && data !== undefined;
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
const optionId = (i: number) => `${listId}-${i}`;
@@ -188,12 +190,17 @@ export function SearchBox({
<kbd className={s.kbd} aria-hidden>
/
</kbd>
{showList && (
{showPopover && (
<div className={s.popover}>
{data.corrected_query && (
{showPopover && !showList && (
<div className={s.nothing} role="status">
{t('search.searching')}
</div>
)}
{data?.corrected_query && (
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
)}
<div id={listId} role="listbox" aria-label={t('search.label')}>
{showList && <div id={listId} role="listbox" aria-label={t('search.label')}>
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
{groups.map((g) => {
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
@@ -221,7 +228,7 @@ export function SearchBox({
</div>
);
})}
</div>
</div>}
<button
type="button"
className={s.all}
+9
View File
@@ -129,6 +129,15 @@ export function WaveButton(props: WaveButtonProps) {
aria-pressed={interactive ? !!props.playing : undefined}
aria-hidden={interactive ? undefined : true}
onMouseMove={interactive ? onMove : undefined}
onFocus={interactive ? () => (s.hoverT = 1) : undefined}
onBlur={
interactive
? () => {
s.hoverT = 0;
s.pressT = 0;
}
: undefined
}
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
onMouseLeave={
interactive
+4
View File
@@ -44,6 +44,10 @@ LOG_LEVEL=info
SUPPORTED_LOCALES=ru,en
MIN_ANDROID_VERSION_CODE=0
# Genius: empty token disables enrichment. Queue and controls: Admin → Sources.
GENIUS_ACCESS_TOKEN=
GENIUS_REQUEST_INTERVAL=2s
# --- Самообновление Android (M5) ---
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
+1
View File
@@ -15,6 +15,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [genius.md](genius.md) | Genius: токен, поиск метаданных, парсинг текстов и проверочная команда |
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент |
+5 -2
View File
@@ -103,8 +103,11 @@
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
сборка под систему посетителя (APK, Windows, Linux, macOS), остальные платформы ниже, адрес сервера
для копирования и короткая инструкция. Та же страница — внизу экрана входа и в меню профиля.
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. После каждого релиза CI
оставляет файлы только у последних 8 релизов и последние 8 прогонов Actions (теги и заметки не удаляет).
Та же страница — внизу экрана входа
и в меню профиля.
## 7. Наблюдение
+2 -2
View File
@@ -106,8 +106,8 @@
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url}]}` — Android, Windows, Linux, macOS |
| GET | `/app/download/{client}/{version}` | файл клиента (прокси / кэш релиза Gitea) |
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url, older:[{version, filename, size, download_url}]}]}` — до 8 релизов на платформу |
| GET | `/app/download/{client}/{version}` | файл клиента этой версии (прокси / кэш релиза Gitea) |
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
+28 -10
View File
@@ -31,11 +31,11 @@
| Сервис | Язык | Ответственность |
|---|---|---|
| `nginx` | — | TLS, раздача SPA, reverse proxy на `api`, отдача аудиофайлов и обложек через `X-Accel-Redirect` (internal locations), gzip/brotli, кэш статики. |
| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи (сканер, транскодинг) через очередь River (на Postgres). |
| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи: сканер внутри процесса, upload-воркеры со статусами в PostgreSQL. |
| `storage-agent` | Go | Единственный привилегированный контейнер: монтирует / размонтирует NFS-шары в `/library/<source_id>`, проверяет доступность, **записывает одобренные загрузки** в принимающие хранилища (только создание новых файлов). Принимает команды только от `api` по внутренней сети. |
| `recommender` | Python | Онлайн-сервис рекомендаций: генерация кандидатов, ранжирование, пачки для «Моей волны», обработка обратной связи в реальном времени; ночное обучение моделей (ALS, LightGBM). |
| `analyzer` | Python | Воркер аудиоанализа: Essentia — BPM, энергия, танцевальность, настроение, эмбеддинги. Тот же образ, что `recommender`, другая команда запуска. |
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, очередь задач River. |
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, состояния upload/analysis jobs. |
| `redis` | — | Rate limiting, кэш, Redis Streams для событий в `recommender`, сессионные векторы волны. |
| `meilisearch` | — | Полнотекстовый поиск с опечатками и автокомплитом. |
@@ -60,7 +60,7 @@
### 3.3 Сканирование библиотеки
1. Админ добавляет хранилище → `api` → `storage-agent` монтирует NFS.
2. `api` ставит задачу `scan_source` в River → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
2. `api` запускает `Scanner.Start` в процессе → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
применение шаблонов путей, upsert в БД, индексирование в Meilisearch.
3. Для новых / изменённых треков создаётся задача анализа (таблица `analysis_jobs`) → `analyzer`.
@@ -94,11 +94,10 @@ pipu-music/
│ │ ├── cmd/api/ # main сервиса
│ │ ├── cmd/storage-agent/ # main агента хранилищ
│ │ ├── internal/
│ │ │ ├── auth/ users/ library/ catalog/ search/
│ │ │ ├── playback/ collection/ events/ wave/ admin/ uploads/
│ │ │ ├── auth/ library/ search/ genius/
│ │ │ ├── playback/ wave/ uploads/ appupdate/ system/
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
│ │ ├── migrations/ # goose SQL-миграции
│ │ └── queries/ # sqlc SQL
│ │ └── migrations/ # goose SQL-миграции; SQL-запросы — в адаптерах internal/
│ └── recommender/ # Python (uv)
│ ├── pipu_rec/
│ │ ├── api/ # FastAPI
@@ -120,16 +119,35 @@ pipu-music/
## 5. Архитектурный стиль
- **Модульный монолит** в `api`: каждый модуль в `internal/<module>` — `handler` (HTTP) → `service`
(бизнес-логика, интерфейсы) → `repository` (sqlc). Модули общаются через интерфейсы сервисов,
не лезут в чужие таблицы напрямую.
(операции приложения) → `store` (ручной SQL через pgx). Узкие порты отделяют изменяемые операции
от адаптеров; миграция существующих модулей постепенная. Read-проекции могут делать JOIN между областями.
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
Сгенерированный код совпадает с контрактом.
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
описан отдельными `internal-*.yaml` спеками.
реализован Go/Python DTO и обработчиками; отдельных `internal-*.yaml` сейчас нет.
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
что важно для офлайн-синхронизации мобильного.
- **Деградация**: если `recommender` недоступен, `api` отдаёт волну по упрощённому fallback-алгоритму
(похожие по жанрам + лайки + случайное), приложение продолжает работать.
- **Наблюдаемость**: структурированные JSON-логи (`slog`, `structlog`), `/healthz` и `/readyz` у каждого сервиса,
метрики Prometheus `/metrics` (опционально подключаются Grafana / Prometheus).
## 6. Реализация и планы
Точки изменения после выделения обязанностей:
- `apps/web/src/player/player.ts` — фасад воспроизведения; `engine.ts`, `analysis.ts`, `media.ts`, `persistence.ts` — браузерные адаптеры. Типы состояния — `types.ts`.
- `packages/core/src/wave.ts` — общая политика refill/feedback и поколения сессий. Web и Android сохраняют собственные очереди.
- `apps/web/src/features/uploads` — API, queries, экран очереди, диалоги и настройки. Страница очереди только собирает feature.
- `services/api/internal/library`: `sources.go`, `templates.go`, `scans.go`, `schedule.go` — отдельные сценарии; `Service` сохраняет фасад для HTTP и сборки приложения.
- `services/api/internal/uploads`: `placement.go` планирует размещение через узкие порты, `persistence.go` владеет SQL-операциями и транзакциями.
- `services/api/internal/playback/personalizer.go` персонализирует каталог независимо от HTTP; `events.go` принимает события в транзакции.
`pnpm check:boundaries` проверяет новые границы без сборки приложений. Его отдельный CI workflow
запускается при изменении соответствующих областей; read-проекции и composition root не запрещены.
River и sqlc не используются. Сканер и расписание живут в процессе API; upload workers читают
состояния PostgreSQL и пробуждаются каналами/таймерами. Анализатор использует `analysis_jobs`.
Добавление устойчивой очереди или генератора SQL — отдельное архитектурное решение при появлении
соответствующей потребности, не обязательная часть текущего рефакторинга.
+7 -1
View File
@@ -43,7 +43,13 @@
| `overlay` | `rgba(3,5,10,.66)` | `rgba(20,30,60,.32)` | Подложка диалогов |
| `glass` | `rgba(12,16,28,.78)` | `rgba(255,255,255,.84)` | Стеклянные карточки (вход, диалоги) + `backdrop-filter: blur(28px) saturate(1.3)` |
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — радиальный градиент `#9CC6FF → #2F80FF → #3A1FA8`.
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
и монохромный слои с отступами для масок лаунчера; фон — `#05070D`.
После изменения значков установленные приложения нужно пересобрать и обновить.
### Типографика
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more