Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8031b126 | ||
|
|
e4b841a8d8 | ||
|
|
597067689a | ||
|
|
efe78d7e07 | ||
|
|
fa79394869 | ||
|
|
3a097c15c8 | ||
|
|
20abe20686 | ||
|
|
c34272d508 | ||
|
|
b0cbd0f149 | ||
|
|
4c2286e70d | ||
|
|
9223516547 | ||
|
|
7b7ce78de8 | ||
|
|
4d2c6ce22f | ||
|
|
dca7173a88 | ||
|
|
1e3b350dfc | ||
|
|
74da457362 | ||
|
|
066ba24834 | ||
|
|
2b76060ea3 | ||
|
|
dfc72b1d69 | ||
|
|
978f03b8f8 | ||
|
|
b27b915b63 | ||
|
|
73e642c1ec | ||
|
|
66f5749fc1 | ||
|
|
823d1eb841 | ||
|
|
860072e542 | ||
|
|
d2f17d8d62 | ||
|
|
4763339335 | ||
|
|
fda6ed48ab | ||
|
|
2d49cb3f19 | ||
|
|
3da82e902a | ||
|
|
421b1a3eb9 | ||
|
|
b8243c3072 | ||
|
|
30f063456e | ||
|
|
de4e59d9f8 | ||
|
|
c0e50bf88e | ||
|
|
86c3e4e477 | ||
|
|
aacf55679a | ||
|
|
d91ff89c64 | ||
|
|
fbbe5d7528 | ||
|
|
e250837de9 | ||
|
|
58bd14ef38 | ||
|
|
fb07f962eb | ||
|
|
ff0aafdb1e | ||
|
|
f7722e28ad | ||
|
|
ed0302d90f | ||
|
|
85fceaa686 | ||
|
|
6ad49fe0e3 |
No files matched your search
+31
-2
@@ -4,14 +4,43 @@
|
||||
{
|
||||
"name": "design",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"],
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"http.server",
|
||||
"8765",
|
||||
"--bind",
|
||||
"127.0.0.1",
|
||||
"--directory",
|
||||
"docs"
|
||||
],
|
||||
"port": 8765
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@pipu/web", "dev"],
|
||||
"runtimeArgs": [
|
||||
"--filter",
|
||||
"@pipu/web",
|
||||
"dev"
|
||||
],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "web-fake",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"--filter",
|
||||
"@pipu/web",
|
||||
"exec",
|
||||
"vite",
|
||||
"--port",
|
||||
"4173",
|
||||
"--strictPort"
|
||||
],
|
||||
"env": {
|
||||
"VITE_E2E": "1"
|
||||
},
|
||||
"port": 4173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта.
|
||||
# Go: сервис api и storage-agent. Только при изменении их кода или контракта.
|
||||
name: ci / api
|
||||
|
||||
on:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md).
|
||||
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками.
|
||||
name: ci / contract
|
||||
|
||||
on:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
name: ci / db
|
||||
|
||||
on:
|
||||
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
@@ -51,6 +52,23 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
|
||||
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
|
||||
run: |
|
||||
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
|
||||
free=$(df -Pm / | awk 'NR==2 {print $4}')
|
||||
echo "свободно на раннере: ${free} МБ"
|
||||
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
|
||||
for svc in postgres:5432 redis:6379 meilisearch:7700; do
|
||||
host=${svc%:*}; port=${svc#*:}
|
||||
for i in $(seq 1 60); do
|
||||
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
|
||||
echo "$svc готов"; break
|
||||
fi
|
||||
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
done
|
||||
- name: Go — миграции и запросы store
|
||||
working-directory: services/api
|
||||
run: go test -tags integration -count=1 -v ./internal/dbtest/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
|
||||
# (Linux AppImage/deb и Windows NSIS через cargo-xwin на ubuntu-latest).
|
||||
name: ci / desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.gitea/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.gitea/workflows/ci-desktop.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: rust (desktop proxy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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 libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
|
||||
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
|
||||
@@ -1,7 +1,8 @@
|
||||
# Expo: expo-doctor и Metro-бандл Android (docs/ci-cd.md). Полная сборка APK — только в release.yml.
|
||||
# Expo: expo-doctor и Metro-бандл Android. Полная сборка APK — только в release.yml.
|
||||
name: ci / mobile
|
||||
|
||||
on:
|
||||
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Python: recommender и analyzer (docs/ci-cd.md).
|
||||
# Python: recommender и analyzer.
|
||||
name: ci / recommender
|
||||
|
||||
on:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md).
|
||||
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза.
|
||||
name: ci / ts
|
||||
|
||||
on:
|
||||
@@ -51,3 +51,15 @@ jobs:
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test
|
||||
- run: pnpm --filter @pipu/web build
|
||||
# Браузер качается долго (основной CDN Microsoft из раннера недоступен) — кэшируем по lock-файлу.
|
||||
# Системные библиотеки (libnspr4 и т.п.) в образе раннера нет, их ставит --with-deps на каждом прогоне.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
|
||||
- run: |
|
||||
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
|
||||
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
|
||||
pnpm --filter @pipu/web exec playwright install chromium
|
||||
- run: pnpm --filter @pipu/web test:e2e
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
||||
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md).
|
||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
|
||||
- name: Сборка образов
|
||||
env: { DOCKER_BUILDKIT: "1" }
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Ночные проверки, которым нужен поднятый стенд или эмулятор. На каждый push не запускаются.
|
||||
name: nightly / e2e
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
jobs:
|
||||
schemathesis:
|
||||
name: schemathesis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: pip install schemathesis
|
||||
- name: Фаззинг API
|
||||
env:
|
||||
PIPU_API_URL: ${{ vars.PIPU_API_URL }}
|
||||
PIPU_API_TOKEN: ${{ secrets.PIPU_API_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$PIPU_API_URL" ]; then echo "PIPU_API_URL is not set"; exit 0; fi
|
||||
sh tests/contract/schemathesis.sh
|
||||
|
||||
maestro:
|
||||
name: maestro
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Сценарии Android
|
||||
env:
|
||||
PIPU_E2E_SERVER: ${{ vars.PIPU_E2E_SERVER }}
|
||||
PIPU_E2E_USER: ${{ vars.PIPU_E2E_USER }}
|
||||
PIPU_E2E_PASSWORD: ${{ secrets.PIPU_E2E_PASSWORD }}
|
||||
run: |
|
||||
if [ -z "$PIPU_E2E_SERVER" ]; then echo "emulator flow is configured in apps/mobile/.maestro"; exit 0; fi
|
||||
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
~/.maestro/bin/maestro test apps/mobile/.maestro
|
||||
@@ -1,6 +1,7 @@
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): 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).
|
||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||
#
|
||||
# Секреты (Settings → Actions → Secrets):
|
||||
@@ -48,10 +49,12 @@ jobs:
|
||||
android:
|
||||
name: android (APK в релиз)
|
||||
needs: release
|
||||
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||||
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
||||
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
|
||||
# (container.valid_volumes: ['/opt/ci-cache/**']); иначе раннер их пропускает и всё качается заново.
|
||||
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
|
||||
defaults:
|
||||
run:
|
||||
@@ -60,7 +63,7 @@ jobs:
|
||||
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
|
||||
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
|
||||
options: --cpus=3 --memory=6g --memory-swap=6g
|
||||
options: --cpus=3 --memory=7g --memory-swap=7g
|
||||
volumes:
|
||||
- /opt/ci-cache/gradle:/root/.gradle
|
||||
- /opt/ci-cache/android:/root/.android
|
||||
@@ -106,6 +109,8 @@ jobs:
|
||||
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: "2"
|
||||
NINJAFLAGS: "-j2"
|
||||
# JS-бандл (Metro) собирается внутри Gradle отдельным node — не даём ему разрастись.
|
||||
NODE_OPTIONS: "--max-old-space-size=1536"
|
||||
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
||||
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
||||
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
||||
@@ -120,7 +125,7 @@ jobs:
|
||||
cd android
|
||||
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
|
||||
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
|
||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2048m -XX:MaxMetaspaceSize=512m")
|
||||
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
|
||||
if grep -q '^newArchEnabled=true' gradle.properties; then
|
||||
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
||||
@@ -128,9 +133,10 @@ jobs:
|
||||
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||||
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||||
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
||||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
|
||||
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
|
||||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
|
||||
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
|
||||
./gradlew --stop || true
|
||||
find ~/.gradle -name "*.lock" -delete || true
|
||||
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||
else
|
||||
@@ -139,6 +145,11 @@ jobs:
|
||||
fi
|
||||
./gradlew --stop
|
||||
|
||||
# Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске
|
||||
# на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86).
|
||||
- name: Проверка APK (ABI, 16 КБ страницы)
|
||||
run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Диагностика сбоя (диск, память, OOM)
|
||||
if: failure()
|
||||
run: |
|
||||
@@ -187,3 +198,133 @@ 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 }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
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 libfuse2 \
|
||||
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: Сборка AppImage и 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"/appimage/*.AppImage; do
|
||||
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.AppImage"
|
||||
done
|
||||
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.*
|
||||
rm -rf src-tauri/target/release
|
||||
- name: Linux в релиз
|
||||
id: linux-upload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
files=()
|
||||
for f in pipu-music-${PIPU_VERSION}-linux.AppImage pipu-music-${PIPU_VERSION}-linux.deb; do
|
||||
[ -f "$f" ] && files+=("$f")
|
||||
done
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "::error::нет AppImage и deb после сборки"
|
||||
exit 1
|
||||
fi
|
||||
node scripts/release.mjs desktop linux success "${files[@]}"
|
||||
- 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
|
||||
@@ -27,6 +27,8 @@ __pycache__/
|
||||
|
||||
# generated test audio
|
||||
tests/fixtures/music/out/
|
||||
apps/web/test-results/
|
||||
apps/web/playwright-report/
|
||||
|
||||
# os / editors
|
||||
.DS_Store
|
||||
@@ -35,3 +37,7 @@ Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.cache/
|
||||
apps/desktop/src-tauri/target/
|
||||
apps/desktop/src-tauri/gen/
|
||||
apps/desktop/src-tauri/tauri.release.json
|
||||
apps/desktop/release/
|
||||
@@ -1,11 +1,10 @@
|
||||
# pipu-music
|
||||
|
||||
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
|
||||
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
|
||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||
|
||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||
- Документация для разработки: [docs/README.md](docs/README.md)
|
||||
- План: [docs/roadmap.md](docs/roadmap.md)
|
||||
- Документация: [docs/README.md](docs/README.md)
|
||||
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
||||
|
||||
## Структура
|
||||
@@ -16,6 +15,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
|
||||
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
||||
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
||||
| `apps/web` | Веб-клиент (React + Vite) |
|
||||
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
|
||||
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
||||
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
||||
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
||||
@@ -67,4 +67,4 @@ pnpm generate && (cd services/api && go generate ./...)
|
||||
|
||||
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
|
||||
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
|
||||
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
|
||||
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты перечислены в шапке `.gitea/workflows/release.yml`.
|
||||
+505
-9
@@ -359,6 +359,22 @@ paths:
|
||||
$ref: "#/components/schemas/TemporaryPassword"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/users/{id}/reset-recommendations:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserId"
|
||||
post:
|
||||
operationId: resetUserRecommendations
|
||||
tags: [admin]
|
||||
summary: Сбросить рекомендации
|
||||
description: |
|
||||
Рекомендации начинаются с нуля: стираются профиль вкуса (онбординг, CF-вектор, бандит),
|
||||
статистика прослушиваний и показы волны; онбординг покажется снова.
|
||||
Лайки, дизлайки и история остаются.
|
||||
responses:
|
||||
"204":
|
||||
description: Сброшено
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/quota:
|
||||
get:
|
||||
operationId: getUploadQuota
|
||||
@@ -435,6 +451,35 @@ paths:
|
||||
$ref: "#/components/schemas/UploadFilePage"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/unseen:
|
||||
get:
|
||||
operationId: unseenUploadDecisions
|
||||
tags: [uploads]
|
||||
summary: Сколько решений по своим предложениям с прошлого визита (moderator, admin)
|
||||
responses:
|
||||
"200":
|
||||
description: Число
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [count]
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/seen:
|
||||
post:
|
||||
operationId: markUploadsSeen
|
||||
tags: [uploads]
|
||||
summary: Отметить свои предложения просмотренными
|
||||
responses:
|
||||
"204":
|
||||
description: Отмечено
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/files/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EntityId"
|
||||
@@ -519,6 +564,65 @@ paths:
|
||||
$ref: "#/components/schemas/UploadFile"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/uploads/files/{id}/stream:
|
||||
get:
|
||||
operationId: streamUpload
|
||||
tags: [uploads]
|
||||
security: []
|
||||
summary: Прослушать файл из staging (подпись, как у стрима трека)
|
||||
description: Ссылку даёт `stream_url` файла очереди. Range поддерживается.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EntityId"
|
||||
- name: exp
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- name: sig
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Аудио из staging
|
||||
content:
|
||||
audio/*:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/uploads/batches/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EntityId"
|
||||
patch:
|
||||
operationId: updateUploadBatch
|
||||
tags: [uploads]
|
||||
summary: Правка метаданных и обложки на всю пачку (admin)
|
||||
description: Исполнитель, альбом и год применяются к файлам в pending_review и import_failed. Обложка встраивается при импорте.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UploadBatchPatch"
|
||||
responses:
|
||||
"200":
|
||||
description: Обновлённые файлы пачки
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UploadFile"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/uploads/review:
|
||||
post:
|
||||
operationId: reviewUploads
|
||||
@@ -1006,6 +1110,24 @@ paths:
|
||||
$ref: "#/components/schemas/SearchResults"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/library/analysis/retry:
|
||||
post:
|
||||
operationId: retryAnalysis
|
||||
tags: [library]
|
||||
summary: Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||
responses:
|
||||
"200":
|
||||
description: Сколько задач снова в очереди
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [requeued]
|
||||
properties:
|
||||
requeued:
|
||||
type: integer
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/search/reindex:
|
||||
post:
|
||||
operationId: reindexSearch
|
||||
@@ -1256,6 +1378,38 @@ paths:
|
||||
description: Снято
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/onboarding:
|
||||
get:
|
||||
operationId: getOnboarding
|
||||
tags: [wave]
|
||||
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
|
||||
description: |
|
||||
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
|
||||
responses:
|
||||
"200":
|
||||
description: Варианты и состояние
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Onboarding"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/me/onboarding:
|
||||
put:
|
||||
operationId: saveOnboarding
|
||||
tags: [wave]
|
||||
summary: Сохранить выбор онбординга (или пропуск — пустые списки)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OnboardingChoice"
|
||||
responses:
|
||||
"204":
|
||||
description: Сохранено
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/wave/options:
|
||||
get:
|
||||
operationId: getWaveOptions
|
||||
@@ -1343,10 +1497,57 @@ paths:
|
||||
$ref: "#/components/schemas/WaveBatch"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/app/clients:
|
||||
get:
|
||||
operationId: getAppClients
|
||||
tags: [app]
|
||||
security: [] # публично: страница /download без входа
|
||||
summary: Клиенты последнего релиза (Android и десктоп)
|
||||
description: |
|
||||
Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
|
||||
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||
responses:
|
||||
"200":
|
||||
description: Доступные сборки
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppClients"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/app/download/{client}/{version}:
|
||||
get:
|
||||
operationId: downloadAppClient
|
||||
tags: [app]
|
||||
security: []
|
||||
summary: Файл клиента (кэшируется на сервере, поддерживает Range)
|
||||
parameters:
|
||||
- name: client
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppClient"
|
||||
- name: version
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$'
|
||||
responses:
|
||||
"200":
|
||||
description: Установщик или APK
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/app/android/latest:
|
||||
get:
|
||||
operationId: getAndroidLatest
|
||||
tags: [app]
|
||||
security: [] # публично: страница /download раздаёт приложение без входа
|
||||
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||
description: |
|
||||
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
||||
@@ -1364,6 +1565,7 @@ paths:
|
||||
get:
|
||||
operationId: downloadAndroidApk
|
||||
tags: [app]
|
||||
security: []
|
||||
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
|
||||
parameters:
|
||||
- name: version
|
||||
@@ -1382,6 +1584,82 @@ paths:
|
||||
format: binary
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/me/playlists:
|
||||
get:
|
||||
tags: [collection]
|
||||
operationId: listPlaylists
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PlaylistList'
|
||||
post:
|
||||
tags: [collection]
|
||||
operationId: createPlaylist
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PlaylistInput'
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'201':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Playlist'
|
||||
/api/v1/me/playlists/{id}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
get:
|
||||
tags: [collection]
|
||||
operationId: getPlaylist
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Playlist'
|
||||
put:
|
||||
tags: [collection]
|
||||
operationId: updatePlaylist
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PlaylistUpdate'
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Playlist'
|
||||
delete:
|
||||
tags: [collection]
|
||||
operationId: deletePlaylist
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
components:
|
||||
parameters:
|
||||
SourceId:
|
||||
@@ -1442,6 +1720,106 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
schemas:
|
||||
PlaylistInput:
|
||||
type: object
|
||||
required: [name, description, track_ids]
|
||||
properties:
|
||||
name: { type: string, minLength: 1, maxLength: 120 }
|
||||
description: { type: string, maxLength: 2000 }
|
||||
track_ids:
|
||||
type: array
|
||||
maxItems: 2000
|
||||
uniqueItems: true
|
||||
items: { type: string, format: uuid }
|
||||
PlaylistUpdate:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/PlaylistInput'
|
||||
- type: object
|
||||
required: [revision]
|
||||
properties:
|
||||
revision: { type: integer, minimum: 1 }
|
||||
PlaylistSummary:
|
||||
type: object
|
||||
required: [id, name, description, revision, track_count, duration_ms, created_at, updated_at]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
revision: { type: integer }
|
||||
track_count: { type: integer }
|
||||
duration_ms: { type: integer, format: int64 }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
Playlist:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/PlaylistSummary'
|
||||
- type: object
|
||||
required: [tracks]
|
||||
properties:
|
||||
tracks:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Track' }
|
||||
PlaylistList:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/PlaylistSummary' }
|
||||
|
||||
Onboarding:
|
||||
type: object
|
||||
required: [required, artists, genres]
|
||||
properties:
|
||||
required:
|
||||
type: boolean
|
||||
artists:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [id, name, track_count, cover_url, cover_colors]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
track_count:
|
||||
type: integer
|
||||
cover_url:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Обложка самого большого альбома исполнителя
|
||||
cover_colors:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
genres:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [name, track_count]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
track_count:
|
||||
type: integer
|
||||
OnboardingChoice:
|
||||
type: object
|
||||
required: [artist_ids, genres]
|
||||
properties:
|
||||
artist_ids:
|
||||
type: array
|
||||
maxItems: 50
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
genres:
|
||||
type: array
|
||||
maxItems: 30
|
||||
items:
|
||||
type: string
|
||||
maxLength: 100
|
||||
UserRef:
|
||||
type: object
|
||||
required: [id, username]
|
||||
@@ -1499,6 +1877,9 @@ components:
|
||||
type: string
|
||||
duration_ms:
|
||||
type: integer
|
||||
album:
|
||||
type: string
|
||||
maxLength: 500
|
||||
UploadPrecheckResult:
|
||||
type: object
|
||||
required: [files]
|
||||
@@ -1612,7 +1993,7 @@ components:
|
||||
nullable: true
|
||||
UploadFile:
|
||||
type: object
|
||||
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url]
|
||||
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, quality_upgrade, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url, stream_url]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -1686,6 +2067,9 @@ components:
|
||||
enum: [sha256, metadata, fingerprint, null]
|
||||
possible_duplicate:
|
||||
type: boolean
|
||||
quality_upgrade:
|
||||
type: boolean
|
||||
description: Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade
|
||||
target_source_id:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -1711,6 +2095,10 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Только автору и пока файл в статусе uploading
|
||||
stream_url:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Подписанная ссылка прослушивания из staging; null, если файла уже нет
|
||||
UploadFilePage:
|
||||
type: object
|
||||
required: [items, next_cursor]
|
||||
@@ -1730,6 +2118,23 @@ components:
|
||||
target_source_id:
|
||||
type: string
|
||||
format: uuid
|
||||
UploadBatchPatch:
|
||||
type: object
|
||||
description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки.
|
||||
properties:
|
||||
artist:
|
||||
type: string
|
||||
maxLength: 500
|
||||
album:
|
||||
type: string
|
||||
maxLength: 500
|
||||
year:
|
||||
type: integer
|
||||
nullable: true
|
||||
cover_base64:
|
||||
type: string
|
||||
nullable: true
|
||||
description: JPEG, PNG или WebP, не больше 8 МБ после декодирования
|
||||
UploadReviewRequest:
|
||||
type: object
|
||||
required: [file_ids, decision]
|
||||
@@ -1769,7 +2174,7 @@ components:
|
||||
- $ref: "#/components/schemas/Problem"
|
||||
UploadSettings:
|
||||
type: object
|
||||
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id]
|
||||
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id, allow_quality_upgrade]
|
||||
properties:
|
||||
queue_limit_bytes:
|
||||
type: integer
|
||||
@@ -1785,6 +2190,9 @@ components:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
allow_quality_upgrade:
|
||||
type: boolean
|
||||
description: Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли
|
||||
UploadSettingsPatch:
|
||||
type: object
|
||||
description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано).
|
||||
@@ -1808,6 +2216,8 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: null — без хранилища по умолчанию
|
||||
allow_quality_upgrade:
|
||||
type: boolean
|
||||
HealthStatus:
|
||||
type: object
|
||||
required: [status]
|
||||
@@ -1859,7 +2269,7 @@ components:
|
||||
enum: [admin, moderator, user]
|
||||
ClientType:
|
||||
type: string
|
||||
enum: [web, android]
|
||||
enum: [web, android, desktop]
|
||||
LoginRequest:
|
||||
type: object
|
||||
required: [username, password, client_type]
|
||||
@@ -1879,7 +2289,7 @@ components:
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
description: Только Android; веб передаёт токен в cookie
|
||||
description: Android и desktop; веб передаёт токен в cookie
|
||||
AuthTokens:
|
||||
type: object
|
||||
required: [access_token, token_type, expires_in, user]
|
||||
@@ -1894,7 +2304,7 @@ components:
|
||||
description: Срок жизни access-токена, секунды
|
||||
refresh_token:
|
||||
type: string
|
||||
description: "Только для `client_type: android`"
|
||||
description: "Для `client_type: android` и `client_type: desktop`"
|
||||
user:
|
||||
$ref: "#/components/schemas/Me"
|
||||
UiPrefs:
|
||||
@@ -1909,6 +2319,19 @@ components:
|
||||
web_player:
|
||||
type: string
|
||||
enum: [bar, island]
|
||||
search_position:
|
||||
type: string
|
||||
enum: [center, left, right]
|
||||
description: Где в топбаре веба поиск (по умолчанию center)
|
||||
search_collapse:
|
||||
type: boolean
|
||||
description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false)
|
||||
home_activities:
|
||||
type: boolean
|
||||
description: Показывать на главной веба выбор занятия (по умолчанию true)
|
||||
home_moods:
|
||||
type: boolean
|
||||
description: Показывать на главной веба выбор настроения (по умолчанию true)
|
||||
mobile_player:
|
||||
type: string
|
||||
enum: [cover, wave]
|
||||
@@ -1922,7 +2345,7 @@ components:
|
||||
type: boolean
|
||||
Me:
|
||||
type: object
|
||||
required: [id, username, display_name, role, locale, ui_prefs, must_change_password, must_change_credentials, created_at]
|
||||
required: [id, username, display_name, role, reviews_uploads, locale, ui_prefs, must_change_password, must_change_credentials, created_at]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -1934,6 +2357,9 @@ components:
|
||||
nullable: true
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
|
||||
locale:
|
||||
type: string
|
||||
ui_prefs:
|
||||
@@ -1974,7 +2400,7 @@ components:
|
||||
type: string
|
||||
AdminUser:
|
||||
type: object
|
||||
required: [id, username, display_name, role, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
|
||||
required: [id, username, display_name, role, reviews_uploads, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -1986,6 +2412,9 @@ components:
|
||||
nullable: true
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
|
||||
locale:
|
||||
type: string
|
||||
is_blocked:
|
||||
@@ -2023,6 +2452,9 @@ components:
|
||||
maxLength: 64
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Сразу выдать аудит загрузок. Может задать только админ
|
||||
CreatedUser:
|
||||
type: object
|
||||
required: [user, temporary_password]
|
||||
@@ -2035,6 +2467,9 @@ components:
|
||||
UpdateUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: Новый логин. Может менять только администратор, в том числе у себя
|
||||
display_name:
|
||||
type: string
|
||||
maxLength: 64
|
||||
@@ -2043,6 +2478,9 @@ components:
|
||||
$ref: "#/components/schemas/Role"
|
||||
is_blocked:
|
||||
type: boolean
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
|
||||
TemporaryPassword:
|
||||
type: object
|
||||
required: [temporary_password]
|
||||
@@ -2996,7 +3434,7 @@ components:
|
||||
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
|
||||
AnalysisStatus:
|
||||
type: object
|
||||
required: [queued, running, done, failed, analyzer_ver]
|
||||
required: [queued, running, done, failed, analyzer_ver, top_error]
|
||||
properties:
|
||||
queued:
|
||||
type: integer
|
||||
@@ -3009,9 +3447,13 @@ components:
|
||||
analyzer_ver:
|
||||
type: string
|
||||
nullable: true
|
||||
top_error:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
|
||||
WaveMetrics:
|
||||
type: object
|
||||
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions]
|
||||
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
|
||||
description: Метрики волны за 7 дней
|
||||
properties:
|
||||
served:
|
||||
@@ -3028,6 +3470,60 @@ components:
|
||||
description: Лайков на 100 треков
|
||||
sessions:
|
||||
type: integer
|
||||
by_model:
|
||||
type: array
|
||||
description: Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
|
||||
items:
|
||||
$ref: '#/components/schemas/WaveModelMetrics'
|
||||
WaveModelMetrics:
|
||||
type: object
|
||||
required: [model_ver, served, listen_through_rate, early_skip_rate, like_rate]
|
||||
properties:
|
||||
model_ver:
|
||||
type: string
|
||||
served:
|
||||
type: integer
|
||||
listen_through_rate:
|
||||
type: number
|
||||
nullable: true
|
||||
early_skip_rate:
|
||||
type: number
|
||||
nullable: true
|
||||
like_rate:
|
||||
type: number
|
||||
nullable: true
|
||||
AppClient:
|
||||
type: string
|
||||
enum: [android, windows, linux, macos]
|
||||
AppClientFile:
|
||||
type: object
|
||||
required: [client, filename, size, download_url]
|
||||
properties:
|
||||
client:
|
||||
$ref: "#/components/schemas/AppClient"
|
||||
filename:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
format: int64
|
||||
sha256:
|
||||
type: string
|
||||
download_url:
|
||||
type: string
|
||||
AppClients:
|
||||
type: object
|
||||
required: [version, version_code, notes_md, clients]
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
version_code:
|
||||
type: integer
|
||||
notes_md:
|
||||
type: string
|
||||
clients:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppClientFile"
|
||||
AndroidRelease:
|
||||
type: object
|
||||
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
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 = 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));
|
||||
console.log(`desktop version ${version}`);
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@pipu/desktop",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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 appimage,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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "pipu-desktop"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "pipu_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "pipu-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||
axum = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
url = "2"
|
||||
souvlaki = "0.8.3"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "Only the bundled main window may invoke desktop integration.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "core:event:default"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,157 @@
|
||||
mod media;
|
||||
mod proxy;
|
||||
mod store;
|
||||
|
||||
use media::Media;
|
||||
use proxy::Proxy;
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
include_image,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager, State,
|
||||
};
|
||||
|
||||
struct AppState {
|
||||
proxy: Proxy,
|
||||
media: Media,
|
||||
server: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Bootstrap {
|
||||
proxy_base: String,
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
fn show_main(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bootstrap(app: AppHandle, state: State<AppState>) -> Result<Bootstrap, String> {
|
||||
Ok(Bootstrap {
|
||||
proxy_base: state.proxy.base.clone(),
|
||||
server: store::load_server(&app)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_server(app: AppHandle, state: State<'_, AppState>, url: String) -> Result<String, String> {
|
||||
let server = proxy::validate_server(&url)?;
|
||||
state.proxy.check(&server).await?;
|
||||
store::save_server(&app, Some(&server))?;
|
||||
*state.proxy.server.write().map_err(|e| e.to_string())? = Some(server.clone());
|
||||
*state.server.lock().map_err(|e| e.to_string())? = Some(server.clone());
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_server(app: AppHandle, state: State<AppState>) -> Result<(), String> {
|
||||
store::save_server(&app, None)?;
|
||||
store::save_user(&app, None)?;
|
||||
store::save_refresh(None)?;
|
||||
*state.proxy.server.write().map_err(|e| e.to_string())? = None;
|
||||
*state.server.lock().map_err(|e| e.to_string())? = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_get() -> Result<Option<String>, String> {
|
||||
store::load_refresh()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_set(token: String) -> Result<(), String> {
|
||||
store::save_refresh(Some(&token))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_clear() -> Result<(), String> {
|
||||
store::save_refresh(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_get(app: AppHandle) -> Result<Option<String>, String> {
|
||||
store::load_user(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_set(app: AppHandle, json: String) -> Result<(), String> {
|
||||
store::save_user(&app, Some(&json))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_clear(app: AppHandle) -> Result<(), String> {
|
||||
store::save_user(&app, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn media_set(
|
||||
state: State<AppState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: String,
|
||||
cover_url: Option<String>,
|
||||
playing: bool,
|
||||
) -> Result<(), String> {
|
||||
state.media.set(&title, &artist, &album, cover_url.as_deref(), playing)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.setup(|app| {
|
||||
let saved = store::load_server(app.handle())?;
|
||||
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||
let media = Media::new();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = media.attach(app.handle().clone(), &window);
|
||||
}
|
||||
app.manage(AppState {
|
||||
proxy,
|
||||
media,
|
||||
server: Mutex::new(saved),
|
||||
});
|
||||
|
||||
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open, &quit])?;
|
||||
let icon: Image = include_image!("icons/32x32.png");
|
||||
TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => show_main(app),
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("desktop app failed to start");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
pipu_desktop_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, WebviewWindow};
|
||||
|
||||
pub struct Media {
|
||||
controls: Mutex<Option<MediaControls>>,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
pub fn new() -> Self {
|
||||
Self { controls: Mutex::new(None) }
|
||||
}
|
||||
|
||||
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let hwnd = {
|
||||
let _ = window;
|
||||
None
|
||||
};
|
||||
let mut controls = MediaControls::new(PlatformConfig {
|
||||
dbus_name: "app.pipu.music.desktop",
|
||||
display_name: "Pipu Music",
|
||||
hwnd,
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let handle = app.clone();
|
||||
controls
|
||||
.attach(move |event| {
|
||||
let name = match event {
|
||||
MediaControlEvent::Play => "play",
|
||||
MediaControlEvent::Pause => "pause",
|
||||
MediaControlEvent::Toggle => "toggle",
|
||||
MediaControlEvent::Next => "next",
|
||||
MediaControlEvent::Previous => "previous",
|
||||
MediaControlEvent::Stop => "stop",
|
||||
_ => return,
|
||||
};
|
||||
let _ = handle.emit("media", name);
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
|
||||
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
|
||||
let Some(controls) = slot.as_mut() else { return Ok(()); };
|
||||
controls
|
||||
.set_metadata(MediaMetadata {
|
||||
title: Some(title),
|
||||
artist: Some(artist),
|
||||
album: Some(album),
|
||||
cover_url,
|
||||
duration: None,
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
|
||||
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Loopback-only streaming transport. The random per-process path is a capability;
|
||||
//! it never leaves the app except for OS media artwork requests. No open proxy.
|
||||
use axum::{body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, Router};
|
||||
use serde_json::Value;
|
||||
use std::{sync::{Arc, RwLock}, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Proxy {
|
||||
pub server: Arc<RwLock<Option<String>>>,
|
||||
pub base: String,
|
||||
secret: String,
|
||||
authority: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub fn validate_server(raw: &str) -> Result<String, String> {
|
||||
let url = Url::parse(raw.trim()).map_err(|_| "Invalid server URL")?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|
||||
|| !url.username().is_empty() || url.password().is_some()
|
||||
|| url.query().is_some() || url.fragment().is_some()
|
||||
{ return Err("Use an HTTP(S) server address without credentials, query or fragment".into()); }
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
pub async fn start(server: Option<String>) -> Result<Self, String> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
|
||||
let authority = listener.local_addr().map_err(|e| e.to_string())?.to_string();
|
||||
let secret = uuid::Uuid::new_v4().simple().to_string();
|
||||
let proxy = Self {
|
||||
server: Arc::new(RwLock::new(server)),
|
||||
base: format!("http://{authority}/{secret}"), secret, authority,
|
||||
client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.read_timeout(Duration::from_secs(60))
|
||||
.build().map_err(|e| e.to_string())?,
|
||||
};
|
||||
let router = Router::new().fallback(forward).with_state(proxy.clone());
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, router).await { eprintln!("Desktop transport stopped: {e}"); }
|
||||
});
|
||||
Ok(proxy)
|
||||
}
|
||||
|
||||
pub async fn check(&self, server: &str) -> Result<(), String> {
|
||||
let response = self.client.get(format!("{server}/api/v1/server-info"))
|
||||
.timeout(Duration::from_secs(10)).send().await.map_err(|_| "Server unavailable")?;
|
||||
if !response.status().is_success() { return Err("Server unavailable".into()); }
|
||||
let info: Value = response.json().await.map_err(|_| "Not a Pipu server")?;
|
||||
if info.get("api_version").is_none() || info.get("name").is_none() { return Err("Not a Pipu server".into()); }
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_origin(origin: &str) -> bool {
|
||||
matches!(origin, "tauri://localhost" | "http://tauri.localhost")
|
||||
|| (cfg!(debug_assertions) && origin == "http://127.0.0.1:5173")
|
||||
}
|
||||
|
||||
// Signed media and tus URLs are relative in the API. Rewrite only API URLs from
|
||||
// the configured server; never route arbitrary URLs supplied in server content.
|
||||
fn local_url(value: &str, server: &str, base: &str) -> Option<String> {
|
||||
if value.starts_with("/api/") { return Some(format!("{base}{value}")); }
|
||||
value.strip_prefix(server).filter(|p| p.starts_with("/api/"))
|
||||
.map(|p| format!("{base}{p}"))
|
||||
}
|
||||
|
||||
fn rewrite_json(value: &mut Value, server: &str, base: &str) {
|
||||
match value {
|
||||
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_json(v, server, base)),
|
||||
Value::Object(fields) => for (key, v) in fields {
|
||||
if matches!(key.as_str(), "stream_url" | "cover_url" | "tus_url" | "download_url") {
|
||||
if let Some(url) = v.as_str().and_then(|s| local_url(s, server, base)) { *v = Value::String(url); }
|
||||
} else { rewrite_json(v, server, base); }
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
||||
if let Some(origin) = origin {
|
||||
headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
|
||||
headers.insert("vary", HeaderValue::from_static("Origin"));
|
||||
}
|
||||
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
||||
}
|
||||
|
||||
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
let origin = request.headers().get("origin").and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&proxy.authority)
|
||||
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
|
||||
{ return StatusCode::FORBIDDEN.into_response(); }
|
||||
let prefix = format!("/{}/api/", proxy.secret);
|
||||
let path = request.uri().path().to_owned();
|
||||
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
|
||||
let tail = tail.to_owned();
|
||||
// Prevent URL normalization escaping the configured API path, including encoded segments.
|
||||
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|
||||
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
|
||||
{ return StatusCode::BAD_REQUEST.into_response(); }
|
||||
if origin.is_none() && !matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let mut response = if request.method() == Method::OPTIONS {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
match transfer(&proxy, request, tail).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||
}
|
||||
};
|
||||
cors(response.headers_mut(), origin.as_deref());
|
||||
response
|
||||
}
|
||||
|
||||
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
|
||||
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
|
||||
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||
let url = format!("{server}/api/{tail}{query}");
|
||||
let (parts, body) = request.into_parts();
|
||||
let mut upstream = proxy.client.request(parts.method.clone(), url);
|
||||
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
|
||||
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
|
||||
}
|
||||
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
|
||||
let status = upstream.status();
|
||||
let mut headers = HeaderMap::new();
|
||||
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
|
||||
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
|
||||
}
|
||||
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
|
||||
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
|
||||
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
|
||||
}
|
||||
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
|
||||
let body = if json && parts.method != Method::HEAD {
|
||||
// Bound only JSON, never buffer a song or an upload in memory.
|
||||
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
|
||||
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
rewrite_json(&mut value, &server, &proxy.base);
|
||||
headers.remove("content-length");
|
||||
headers.remove("etag");
|
||||
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
|
||||
} else { Body::from_stream(upstream.bytes_stream()) };
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_server_and_scopes_media_urls() {
|
||||
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
|
||||
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
|
||||
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
|
||||
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
|
||||
rewrite_json(&mut value, "https://music.example", "http://local/key");
|
||||
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
|
||||
assert_eq!(value["tracks"][0]["title"], "/api/private");
|
||||
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const SERVICE: &str = "app.pipu.music.desktop";
|
||||
|
||||
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
|
||||
let path = config_dir(app)?.join("server.txt");
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => {
|
||||
let s = s.trim().to_string();
|
||||
Ok((!s.is_empty()).then_some(s))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||
let path = config_dir(app)?.join("server.txt");
|
||||
match server {
|
||||
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_entry() -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_refresh() -> Result<Option<String>, String> {
|
||||
match refresh_entry()?.get_password() {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
|
||||
let entry = refresh_entry()?;
|
||||
match token {
|
||||
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
|
||||
None => match entry.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
|
||||
let path = config_dir(app)?.join("user.json");
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
|
||||
let path = config_dir(app)?.join("user.json");
|
||||
match json {
|
||||
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Pipu Music",
|
||||
"version": "0.0.0",
|
||||
"identifier": "app.pipu.music.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm --filter @pipu/web dev",
|
||||
"devUrl": "http://127.0.0.1:5173",
|
||||
"beforeBuildCommand": "pnpm --filter @pipu/web build",
|
||||
"frontendDist": "../../web/dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis", "appimage", "deb", "rpm"],
|
||||
"category": "Music",
|
||||
"shortDescription": "Self-hosted music player",
|
||||
"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 }
|
||||
},
|
||||
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- runFlow: login.yaml
|
||||
- tapOn: "Коллекция"
|
||||
- assertVisible: "Лайки"
|
||||
@@ -0,0 +1,15 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- assertVisible: "Адрес сервера"
|
||||
- tapOn: "Адрес сервера"
|
||||
- inputText: ${PIPU_E2E_SERVER}
|
||||
- tapOn: "Подключиться"
|
||||
- assertVisible: "Вход"
|
||||
- tapOn: "Логин"
|
||||
- inputText: ${PIPU_E2E_USER}
|
||||
- tapOn: "Пароль"
|
||||
- inputText: ${PIPU_E2E_PASSWORD}
|
||||
- tapOn: "Войти"
|
||||
- assertVisible: "Бегаю"
|
||||
@@ -0,0 +1,10 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- assertVisible: "Адрес сервера|Server address"
|
||||
- stopApp
|
||||
- launchApp
|
||||
- assertVisible: "Адрес сервера|Server address"
|
||||
- openLink: "pipu://album/1"
|
||||
- assertVisible: "Адрес сервера|Server address"
|
||||
@@ -0,0 +1,6 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- runFlow: login.yaml
|
||||
- tapOn: "Профиль"
|
||||
- assertVisible: "Проверить обновления"
|
||||
- tapOn: "Проверить обновления"
|
||||
@@ -30,7 +30,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
'expo-secure-store',
|
||||
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
||||
// Android по умолчанию запрещает незашифрованный HTTP.
|
||||
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
||||
// Распаковываем .so при установке: SoLoader на x86_64 с ARM-трансляцией ищет
|
||||
// внутри APK lib/x86_64 вместо arm64-v8a и не находит libreactnative.so.
|
||||
['expo-build-properties', { android: { usesCleartextTraffic: true, useLegacyPackaging: true } }],
|
||||
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
|
||||
'./plugins/withArm64Only',
|
||||
],
|
||||
extra: { versionCode },
|
||||
experiments: { typedRoutes: false },
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"start": "expo start --dev-client",
|
||||
"android": "expo run:android",
|
||||
"prebuild": "expo prebuild --platform android --clean",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
@@ -42,7 +43,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"@types/react": "~19.0.10"
|
||||
"@types/react": "~19.0.10",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"expo": {
|
||||
"doctor": {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI
|
||||
// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят
|
||||
// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог
|
||||
// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so».
|
||||
const { withAppBuildGradle } = require('expo/config-plugins');
|
||||
|
||||
const MARKER = '// pipu: arm64-v8a only';
|
||||
|
||||
module.exports = function withArm64Only(config) {
|
||||
return withAppBuildGradle(config, (cfg) => {
|
||||
if (cfg.modResults.contents.includes(MARKER)) return cfg;
|
||||
cfg.modResults.contents = cfg.modResults.contents.replace(
|
||||
/defaultConfig\s*\{/,
|
||||
(m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`,
|
||||
);
|
||||
return cfg;
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { unwrap, type Track } from '@pipu/core';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
|
||||
const session = useSession();
|
||||
const current = usePlayerState().track;
|
||||
const [tab, setTab] = useState<Tab>('likes');
|
||||
const router = useRouter();
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['m-collection', tab],
|
||||
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
|
||||
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
||||
|
||||
const tabs = (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
|
||||
<Segmented<Tab>
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
|
||||
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ function Chips({
|
||||
height: 38,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 999,
|
||||
backgroundColor: on ? colors.accent : colors.surface2,
|
||||
backgroundColor: on ? colors['accent-fill'] : colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: on ? colors.accent : colors.line,
|
||||
}}
|
||||
|
||||
@@ -116,6 +116,16 @@ export default function ProfileScreen() {
|
||||
onChange={(v) => void setPrefs({ mobile_player: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t('settings.normalize')}>
|
||||
<Switch value={user.ui_prefs.normalize !== false} onValueChange={(v) => void setPrefs({ normalize: v })} />
|
||||
</Row>
|
||||
<Row label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}>
|
||||
<Segmented
|
||||
value={String([0, 2, 4, 6].includes(user.ui_prefs.crossfade_s ?? 0) ? (user.ui_prefs.crossfade_s ?? 0) : 0)}
|
||||
options={['0', '2', '4', '6'].map((v) => ({ value: v, label: v }))}
|
||||
onChange={(v) => void setPrefs({ crossfade_s: Number(v) })}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import type { i18n as I18n } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -54,8 +54,6 @@ const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly stri
|
||||
function Gate() {
|
||||
const { server, session, ready } = useSessionCtx();
|
||||
const auth = useAuthState();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
const { i18n } = useTranslation();
|
||||
const user = auth.status === 'signed_in' ? auth.user : null;
|
||||
const system = useColorScheme();
|
||||
@@ -79,17 +77,6 @@ function Gate() {
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const first = segments[0] as string | undefined;
|
||||
let target: string | null = null;
|
||||
if (!server) target = first === 'connect' ? null : '/connect';
|
||||
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
|
||||
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
|
||||
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
|
||||
if (target) router.replace(target as never);
|
||||
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
|
||||
|
||||
if (!ready) return <Splash />;
|
||||
return (
|
||||
<ThemeContext.Provider value={theme}>
|
||||
@@ -101,9 +88,30 @@ function Gate() {
|
||||
|
||||
function Screens() {
|
||||
const colors = useColors();
|
||||
const { server, session } = useSessionCtx();
|
||||
const auth = useAuthState();
|
||||
const connected = !!server && !!session;
|
||||
const user = connected && auth.status === 'signed_in' ? auth.user : null;
|
||||
const needsSetup = !!user && (user.must_change_credentials || user.must_change_password);
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
|
||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||
{/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
|
||||
<Stack.Protected guard={!connected}>
|
||||
<Stack.Screen name="connect" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={connected && !user}>
|
||||
<Stack.Screen name="login" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={needsSetup}>
|
||||
<Stack.Screen name="setup" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={!!user && !needsSetup}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="album/[id]" />
|
||||
<Stack.Screen name="artist/[id]" />
|
||||
<Stack.Screen name="playlists" />
|
||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
|
||||
import { useUser } from '../lib/session';
|
||||
import { useSessionCtx, useUser } from '../lib/session';
|
||||
import { player } from '../player/controller';
|
||||
import { usePlayerState } from '../player/hooks';
|
||||
import { useColors } from '../theme';
|
||||
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
|
||||
const router = useRouter();
|
||||
const st = usePlayerState();
|
||||
const prefs = useUser().ui_prefs;
|
||||
const { server } = useSessionCtx();
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [barWidth, setBarWidth] = useState(1);
|
||||
const track = st.track;
|
||||
|
||||
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
|
||||
const duration = st.durationMs || track.duration_ms;
|
||||
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const share = async () => {
|
||||
if (!server || sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
|
||||
} catch {
|
||||
Alert.alert(t('player.share'), t('player.share_error'));
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
|
||||
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
||||
<DownloadButton track={track} />
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
|
||||
<Icon name="share" size={24} color={colors.text} />
|
||||
</Pressable>
|
||||
<DownloadButton track={track} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||
import { useSession, useUser } from '../lib/session';
|
||||
import { player } from '../player/controller';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||
|
||||
type Playlist = components['schemas']['Playlist'];
|
||||
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
|
||||
|
||||
export default function PlaylistsScreen() {
|
||||
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
|
||||
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
|
||||
}
|
||||
|
||||
function List({ track }: { track?: string }) {
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [create, setCreate] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||
const add = useMutation({ mutationFn: async (id: string) => {
|
||||
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
|
||||
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||
return <Screen><FlatList
|
||||
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
|
||||
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
|
||||
contentContainerStyle={{ padding: 16, gap: 12 }}
|
||||
ListHeaderComponent={<View style={{ gap: 12 }}>
|
||||
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
|
||||
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
|
||||
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
|
||||
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
|
||||
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
|
||||
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||
</View>}
|
||||
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
|
||||
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
|
||||
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
|
||||
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
|
||||
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
|
||||
}} />}</Screen>;
|
||||
}
|
||||
|
||||
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
|
||||
const session = useSession();
|
||||
const { t } = useTranslation();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [original] = useState(playlist);
|
||||
const [name, setName] = useState(playlist?.name ?? '');
|
||||
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||
const save = useMutation({ mutationFn: async () => {
|
||||
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
|
||||
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
|
||||
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
|
||||
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
|
||||
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
|
||||
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
|
||||
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
|
||||
</ScrollView></Screen></Modal>;
|
||||
}
|
||||
|
||||
function Detail({ id }: { id: string }) {
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [playError, setPlayError] = useState<unknown>();
|
||||
const key = ['m-playlists', user.id, id];
|
||||
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
const p = query.data;
|
||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
||||
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
||||
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
|
||||
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||
const play = (shuffle: boolean) => {
|
||||
const tracks = p?.tracks.filter(t => t.available) ?? [];
|
||||
if (shuffle) for (let i=tracks.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [tracks[i],tracks[j]]=[tracks[j]!,tracks[i]!]; }
|
||||
if (tracks.length) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
|
||||
};
|
||||
const move = (index: number, delta: number) => {
|
||||
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
||||
};
|
||||
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
|
||||
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
||||
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
||||
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
||||
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
||||
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
||||
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
||||
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
|
||||
])} />
|
||||
</>}
|
||||
</View>}
|
||||
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
|
||||
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
|
||||
{manage && <View style={{padding:12,gap:6}}>
|
||||
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
|
||||
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
|
||||
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
|
||||
</View>}
|
||||
</View>}
|
||||
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type ReleaseNeed = 'current' | 'optional' | 'required';
|
||||
|
||||
/** Нужно ли обновление: обязательное, если установленная версия ниже min_supported. */
|
||||
export function releaseNeed(installed: number, versionCode: number, minSupported: number): ReleaseNeed {
|
||||
if (versionCode <= installed) return 'current';
|
||||
return installed < minSupported ? 'required' : 'optional';
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { sha256 } from 'js-sha256';
|
||||
|
||||
import { releaseNeed } from './releaseNeed';
|
||||
|
||||
export type AndroidRelease = components['schemas']['AndroidRelease'];
|
||||
|
||||
/** Состояние самообновления (docs/mobile.md#самообновление). */
|
||||
@@ -96,11 +98,12 @@ class Updater {
|
||||
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
|
||||
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
|
||||
const installed = installedVersionCode();
|
||||
if (release.version_code > installed) {
|
||||
const need = releaseNeed(installed, release.version_code, release.min_supported);
|
||||
if (need !== 'current') {
|
||||
this.set({
|
||||
status: 'available',
|
||||
release,
|
||||
required: installed < release.min_supported,
|
||||
required: need === 'required',
|
||||
dismissed: false,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import NetInfo from '@react-native-community/netinfo';
|
||||
import {
|
||||
EventBuffer,
|
||||
PlayAttempt,
|
||||
normalizeGain,
|
||||
unwrap,
|
||||
type AuthSession,
|
||||
type PlayContext,
|
||||
@@ -18,6 +19,10 @@ import TrackPlayer, {
|
||||
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||
import { downloads } from '../offline/downloads';
|
||||
import { PersistentEvents } from '../offline/events';
|
||||
import { offlineWave } from './offlineWave';
|
||||
import { eventId } from './eventId';
|
||||
|
||||
export { offlineWave };
|
||||
|
||||
export interface WaveInfo {
|
||||
sessionId: string;
|
||||
@@ -45,15 +50,6 @@ const WAVE_REFILL_AT = 2;
|
||||
const EARLY_SKIP_MS = 30_000;
|
||||
const SAVE_EVERY_MS = 10_000;
|
||||
|
||||
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
||||
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
|
||||
return tracks
|
||||
.filter((t) => t.reaction !== 'dislike')
|
||||
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
|
||||
.sort((a, b) => a.key - b.key)
|
||||
.map((x) => x.t);
|
||||
}
|
||||
|
||||
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
||||
|
||||
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
|
||||
@@ -90,6 +86,7 @@ class Controller {
|
||||
private lastSave = 0;
|
||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||
private waveLoading = false;
|
||||
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
||||
private setupDone: Promise<void> | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private state: PlayerState = {
|
||||
@@ -117,7 +114,7 @@ class Controller {
|
||||
this.server = server;
|
||||
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
||||
const sink = this.persistent;
|
||||
this.events = sink ? new EventBuffer({ send: sink.send }) : null;
|
||||
this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
|
||||
void sink?.drain();
|
||||
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||
if (st.isConnected) void this.persistent?.drain();
|
||||
@@ -158,6 +155,10 @@ class Controller {
|
||||
this.lastPositionMs = ms;
|
||||
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
|
||||
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
|
||||
const cf = Math.min(6, Math.max(0, this.userPrefs()?.crossfade_s ?? 0));
|
||||
const left = duration - position;
|
||||
const fade = cf > 0 && left < cf ? Math.max(0, left / cf) : 1;
|
||||
void this.applyVolume(this.state.track, fade);
|
||||
});
|
||||
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
|
||||
this.finishAttempt(lastPosition);
|
||||
@@ -166,6 +167,7 @@ class Controller {
|
||||
if (!track) return;
|
||||
this.attempt = new PlayAttempt(track, 0);
|
||||
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
|
||||
void this.applyVolume(track, 1);
|
||||
this.events?.push({
|
||||
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
|
||||
context_id: this.state.context?.id,
|
||||
@@ -189,6 +191,14 @@ class Controller {
|
||||
if (!a) return;
|
||||
this.attempt = null;
|
||||
const pos = Math.round(lastPositionS * 1000);
|
||||
if (this.neutralSwitch) {
|
||||
this.neutralSwitch = false;
|
||||
if (a.playedMs >= EARLY_SKIP_MS) {
|
||||
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||
void this.events?.flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const kind = ended ? 'play_end' : a.outcome(pos);
|
||||
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||
void this.events?.flush();
|
||||
@@ -200,6 +210,17 @@ class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
private userPrefs() {
|
||||
const st = this.session?.state;
|
||||
return st?.status === 'signed_in' ? st.user.ui_prefs : undefined;
|
||||
}
|
||||
|
||||
/** Громкость 0..1: нормализация вниз к −14 LUFS и затухание кроссфейда в конце трека. */
|
||||
private async applyVolume(track: Track | null, fade: number) {
|
||||
const gain = Math.min(1, normalizeGain(track?.features?.loudness_lufs, this.userPrefs()?.normalize !== false) * fade);
|
||||
await TrackPlayer.setVolume(gain).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
||||
const q = await quality();
|
||||
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||
@@ -316,6 +337,13 @@ class Controller {
|
||||
);
|
||||
await this.replaceUpcoming(res.tracks);
|
||||
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||
if (res.tracks.some(playable)) {
|
||||
this.neutralSwitch = true;
|
||||
await TrackPlayer.skipToNext().catch(() => {
|
||||
this.neutralSwitch = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private leaveWave() {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { uuidv7 } from '@pipu/core';
|
||||
|
||||
/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */
|
||||
export function eventId(now: number): string {
|
||||
return uuidv7(now, () => {
|
||||
const hex = globalThis.expo.uuidv4().replace(/-/g, '');
|
||||
return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Track } from '@pipu/core';
|
||||
|
||||
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
||||
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
|
||||
return tracks
|
||||
.filter((t) => t.reaction !== 'dislike')
|
||||
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
|
||||
.sort((a, b) => a.key - b.key)
|
||||
.map((x) => x.t);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ApiError, type Track } from '@pipu/core';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
@@ -107,7 +108,7 @@ export function Button({
|
||||
}) {
|
||||
const s = useStyles();
|
||||
const colors = useColors();
|
||||
const bg = { primary: colors.accent, secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
|
||||
const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
|
||||
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
|
||||
return (
|
||||
<Pressable
|
||||
@@ -199,6 +200,7 @@ export function TrackRow({
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const liked = track.reaction === 'like';
|
||||
const router = useRouter();
|
||||
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||
return (
|
||||
<Pressable
|
||||
@@ -233,6 +235,10 @@ export function TrackRow({
|
||||
</Pressable>
|
||||
)}
|
||||
{trailing}
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
|
||||
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
|
||||
<Icon name="playlist-add" size={22} />
|
||||
</Pressable>
|
||||
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
||||
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
||||
</Pressable>
|
||||
@@ -282,7 +288,7 @@ export function Segmented<T extends string>({
|
||||
onPress={() => onChange(o.value)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: value === o.value }}
|
||||
style={[s.segment, value === o.value && { backgroundColor: colors.accent }]}
|
||||
style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
|
||||
>
|
||||
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { EventBuffer } from '@pipu/core';
|
||||
import { eventId } from '../src/player/eventId';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('queues restored-track events on Hermes without Web Crypto', () => {
|
||||
vi.stubGlobal('crypto', undefined);
|
||||
const nativeUuid = vi.fn()
|
||||
.mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff')
|
||||
.mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe');
|
||||
vi.stubGlobal('expo', { uuidv4: nativeUuid });
|
||||
const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId);
|
||||
buffer.push({ type: 'play_start', track_id: 'restored-track' });
|
||||
buffer.push({ type: 'skip', track_id: 'restored-track' });
|
||||
expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff');
|
||||
expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id);
|
||||
expect(nativeUuid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { releaseNeed } from '../src/lib/releaseNeed';
|
||||
import { offlineWave } from '../src/player/offlineWave';
|
||||
import type { Track } from '@pipu/core';
|
||||
|
||||
function track(id: string, reaction: Track['reaction']): Track {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
duration_ms: 1000,
|
||||
artists: [],
|
||||
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [],
|
||||
disc_no: 1,
|
||||
track_no: 1,
|
||||
available: true,
|
||||
stream_url: null,
|
||||
reaction,
|
||||
features: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('offline wave', () => {
|
||||
it('drops dislikes and keeps likes ahead of the rest', () => {
|
||||
const tracks = [track('d', 'dislike'), track('n', null), track('l', 'like')];
|
||||
const order = offlineWave(tracks, () => 0.5).map((t) => t.id);
|
||||
expect(order).toEqual(['l', 'n']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update dialog decision', () => {
|
||||
it('marks an older install below the minimum as required', () => {
|
||||
expect(releaseNeed(10, 12, 11)).toBe('required');
|
||||
expect(releaseNeed(11, 12, 11)).toBe('optional');
|
||||
expect(releaseNeed(12, 12, 11)).toBe('current');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,8 @@ WORKDIR /repo
|
||||
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
||||
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает)
|
||||
COPY patches/ patches/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/mobile/package.json apps/mobile/
|
||||
COPY packages/api-client/package.json packages/api-client/
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
async function login(page: Page, username: string) {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill(username);
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Ещё', exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function fits(page: Page) {
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const main = document.querySelector('main')!;
|
||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||
})).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430, 'alice']] as const) {
|
||||
test(`mobile ${width}: ${username} navigation, forms and search`, async ({ page }, info) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await login(page, username);
|
||||
await fits(page);
|
||||
const bottom = page.getByRole('navigation', { name: 'Навигация' }).filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
|
||||
await expect(bottom.getByRole('link')).toHaveCount(3);
|
||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Навигация' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('link', { name: 'Дизлайки', exact: true })).toBeVisible();
|
||||
if (username !== 'alice') {
|
||||
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole('link', { name: 'Хранилища', exact: true })).toHaveCount(username === 'mobile-admin' ? 1 : 0);
|
||||
await dialog.getByRole('link', { name: 'Пользователи', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
|
||||
await fits(page);
|
||||
await page.screenshot({ path: info.outputPath('users.png') });
|
||||
await page.getByRole('button', { name: /Действия.*alice/ }).click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
const rect = await menu.boundingBox();
|
||||
expect(rect!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(rect!.x + rect!.width).toBeLessThanOrEqual(width);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Создать пользователя', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('create-user.png') });
|
||||
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
} else {
|
||||
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible();
|
||||
await fits(page);
|
||||
await page.screenshot({ path: info.outputPath('settings.png') });
|
||||
for (const name of ['Слева', 'По центру', 'Справа']) {
|
||||
await page.getByRole('button', { name, exact: true }).click();
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||
await expect(page.getByRole('option', { name: /Кино/ }).first()).toBeVisible();
|
||||
await fits(page);
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Escape');
|
||||
}
|
||||
|
||||
if (username !== 'alice') {
|
||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
|
||||
await expect(page.locator('audio')).toBeVisible();
|
||||
await fits(page);
|
||||
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
|
||||
await expect(page.getByRole('region', { name: 'Действия с выбранными' })).toBeVisible();
|
||||
await fits(page);
|
||||
await page.screenshot({ path: info.outputPath('upload-queue.png') });
|
||||
await page.getByRole('region', { name: 'Действия с выбранными' }).getByRole('button', { name: 'Отклонить' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
}
|
||||
if (username === 'mobile-admin') {
|
||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
|
||||
await expect(page.getByRole('article', { name: 'Музыкальное хранилище' })).toBeVisible();
|
||||
await fits(page);
|
||||
await page.getByRole('button', { name: 'Шаблоны путей', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Добавить шаблон', exact: true }).click();
|
||||
await fits(page);
|
||||
await page.screenshot({ path: info.outputPath('patterns.png') });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 320, height: 740 });
|
||||
await login(page, 'alice');
|
||||
const box = page.getByRole('combobox', { name: 'Поиск', exact: true });
|
||||
await box.fill('кино');
|
||||
await box.press('Enter');
|
||||
await page.getByRole('link', { name: /Группа крови/ }).first().click();
|
||||
await page.getByRole('listitem').first().click();
|
||||
const player = page.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
|
||||
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
|
||||
await fits(page);
|
||||
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Остров', exact: true }).click();
|
||||
const nav = page.getByRole('navigation').filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
|
||||
await expect.poll(async () => {
|
||||
const p = await player.boundingBox();
|
||||
const n = await nav.boundingBox();
|
||||
return p!.y + p!.height <= n!.y + 1;
|
||||
}).toBe(true);
|
||||
await player.getByRole('button', { name: 'Очередь', exact: true }).click();
|
||||
await expect(page.getByRole('complementary', { name: 'Очередь', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('player-queue.png') });
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const width of [320, 1280]) {
|
||||
test(`playlists at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
|
||||
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
|
||||
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
|
||||
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
||||
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
||||
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const main = document.querySelector('main')!;
|
||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||
})).toBeLessThanOrEqual(1);
|
||||
await page.screenshot({ path: info.outputPath('playlist.png') });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
});
|
||||
|
||||
test('1. bootstrap admin changes credentials and lands on home', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByText(/Первый запуск/)).toBeVisible();
|
||||
await page.getByLabel('Логин').fill('admin');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('admin-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Смените логин и пароль' })).toBeVisible();
|
||||
await page.getByLabel('Новый логин').fill('owner');
|
||||
await page.getByRole('textbox', { name: 'Новый пароль' }).fill('a-strong-owner-pass');
|
||||
await page.getByRole('textbox', { name: 'Повторите пароль' }).fill('a-strong-owner-pass');
|
||||
await page.getByRole('button', { name: 'Сохранить и продолжить' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Бегаю' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('4. search suggest opens the artist and Enter opens search', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин').fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
const box = page.getByRole('combobox', { name: 'Поиск' });
|
||||
await box.fill('кино');
|
||||
await page.getByRole('option', { name: /Кино/ }).first().click();
|
||||
await expect(page.getByRole('heading', { name: 'Кино' })).toBeVisible();
|
||||
|
||||
await box.fill('кино');
|
||||
// A suggestion under the pointer must not become a keyboard selection.
|
||||
const suggestion = page.getByRole('option', { name: /Кино/ }).first();
|
||||
await suggestion.hover();
|
||||
await expect(suggestion).toHaveAttribute('aria-selected', 'false');
|
||||
await box.press('Enter');
|
||||
await expect(page).toHaveURL(/\/search\?q=/);
|
||||
});
|
||||
|
||||
test('settings expose crossfade and loudness normalization', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин').fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки' }).click();
|
||||
await expect(page.getByText('Выравнивание громкости')).toBeVisible();
|
||||
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
|
||||
await slider.fill('4');
|
||||
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
|
||||
});
|
||||
test('9. login page switches between Russian and English', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByRole('button', { name: 'EN' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'RU' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Вход' })).toBeVisible();
|
||||
});
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>pipu</title>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||
@@ -21,13 +22,16 @@
|
||||
"@pipu/i18n": "workspace:*",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"@tanstack/react-router": "^1.120.20",
|
||||
"@tanstack/react-virtual": "^3.14.21",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"music-metadata": "^12.2.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.3",
|
||||
"tus-js-client": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -35,6 +39,7 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"axe-core": "^4.14.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] },
|
||||
webServer: {
|
||||
command: 'pnpm exec vite --port 4173 --strictPort',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: { VITE_E2E: '1' },
|
||||
},
|
||||
});
|
||||
+139
-3
@@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query';
|
||||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import axe from 'axe-core';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -117,7 +118,8 @@ describe('navigation by role', () => {
|
||||
|
||||
it('shows users but not the audit log to moderators', async () => {
|
||||
const mod = makeUser('mod', 'moderator');
|
||||
await renderApp([mod], '/', { signedInAs: mod });
|
||||
const { user } = await renderApp([mod], '/', { signedInAs: mod });
|
||||
await user.click(await screen.findByRole('button', { name: 'Админка' }));
|
||||
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -152,6 +154,39 @@ describe('users admin', () => {
|
||||
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['root', 'anna'])('lets admins change the username of %s', async (target) => {
|
||||
const root = makeUser('root', 'admin');
|
||||
const anna = makeUser('anna', 'user');
|
||||
const { user, api, session } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||
await user.click(await screen.findByRole('button', { name: `Действия с ${target}` }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||
const input = within(dialog).getByLabelText('Логин');
|
||||
await user.clear(input);
|
||||
await user.type(input, `new.${target}`);
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(await screen.findByText(`@new.${target}`)).toBeInTheDocument();
|
||||
expect(api.users.get(target === 'root' ? root.id : anna.id)?.username).toBe(`new.${target}`);
|
||||
if (target === 'root') expect(session.state).toMatchObject({ status: 'signed_in', user: { username: 'new.root' } });
|
||||
});
|
||||
|
||||
it('keeps the username dialog open when the new username is taken', async () => {
|
||||
const root = makeUser('root', 'admin');
|
||||
const anna = makeUser('anna', 'user');
|
||||
const { user, api } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||
await user.click(await screen.findByRole('button', { name: 'Действия с anna' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||
const input = within(dialog).getByLabelText('Логин');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ROOT');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
|
||||
expect(api.users.get(anna.id)?.username).toBe('anna');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers moderators only what they may do', async () => {
|
||||
const mod = makeUser('mod', 'moderator');
|
||||
const anna = makeUser('anna', 'user');
|
||||
@@ -164,7 +199,7 @@ describe('users admin', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
|
||||
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
|
||||
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
|
||||
expect(items).toEqual(['Сбросить пароль', 'Сбросить рекомендации', 'Заблокировать']);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
|
||||
@@ -246,11 +281,35 @@ describe('search box', () => {
|
||||
});
|
||||
|
||||
describe('player', () => {
|
||||
it('opens a shared track after login and copies its page URL from the player', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const path = '/track/00000000-0000-4000-8000-000000000101';
|
||||
const { user, router } = await renderApp([alice], path);
|
||||
await screen.findByRole('heading', { name: 'Вход' });
|
||||
await user.type(screen.getByLabelText('Логин'), alice.username);
|
||||
await user.type(screen.getByLabelText('Пароль'), alice.password);
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
await user.click(screen.getByRole('listitem'));
|
||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
||||
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
|
||||
});
|
||||
|
||||
it('shows a missing shared track without starting playback', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
await renderApp([alice], '/track/missing', { signedInAs: alice });
|
||||
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||
|
||||
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
|
||||
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
|
||||
await user.click(row);
|
||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
|
||||
@@ -313,6 +372,31 @@ describe('my wave', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggest tracks', () => {
|
||||
it('warns that lossless formats are the ones to send', async () => {
|
||||
const admin = makeUser('admin', 'admin');
|
||||
await renderApp([admin], '/uploads/new', { signedInAs: admin });
|
||||
expect(await screen.findByText(/Нужны FLAC, WAV или AIFF/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/MP3 — только в крайнем случае/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('home and login have no serious axe violations', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
await renderApp([alice], '/', { signedInAs: alice });
|
||||
await screen.findByRole('button', { name: 'Бегаю' });
|
||||
const home = await axe.run(document.body, { resultTypes: ['violations'] });
|
||||
expect(home.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
|
||||
|
||||
cleanup();
|
||||
await renderApp([], '/login');
|
||||
await screen.findByRole('heading', { name: 'Вход' });
|
||||
const login = await axe.run(document.body, { resultTypes: ['violations'] });
|
||||
expect(login.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeRedirect', () => {
|
||||
it.each([
|
||||
['/settings', '/settings'],
|
||||
@@ -323,3 +407,55 @@ describe('safeRedirect', () => {
|
||||
expect(safeRedirect(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playlists', () => {
|
||||
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||
await user.type(screen.getByLabelText('Название'), 'В дорогу');
|
||||
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await screen.findByRole('heading', { name: 'В дорогу' });
|
||||
const id = [...api.playlists.keys()][0]!;
|
||||
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
|
||||
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
|
||||
await screen.findByRole('heading', { name: 'Группа крови' });
|
||||
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
|
||||
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
|
||||
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
|
||||
await router.navigate({ to: '/playlist/$id', params: { id } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
|
||||
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
|
||||
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
|
||||
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
|
||||
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||
await user.clear(screen.getByLabelText('Название'));
|
||||
await user.type(screen.getByLabelText('Название'), 'Вечер');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await screen.findByRole('heading', { name: 'Вечер' });
|
||||
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
|
||||
await screen.findByRole('heading', { name: 'Плейлисты' });
|
||||
expect(api.playlists.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||
await user.type(screen.getByLabelText('Название'), 'Первый');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await screen.findByRole('heading', { name: 'Первый' });
|
||||
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||
const item = [...api.playlists.values()][0]!;
|
||||
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
|
||||
await user.type(screen.getByLabelText('Название'), ' изменён');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
|
||||
});
|
||||
});
|
||||
+64
-17
@@ -7,20 +7,26 @@ import {
|
||||
redirect,
|
||||
type ParsedLocation,
|
||||
} from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { AuditPage } from '../pages/admin/AuditPage';
|
||||
import { LibraryPage } from '../pages/admin/LibraryPage';
|
||||
import { PatternsPage } from '../pages/admin/PatternsPage';
|
||||
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
|
||||
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
|
||||
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
|
||||
import { SuggestPage } from '../pages/uploads/SuggestPage';
|
||||
import { SourcesPage } from '../pages/admin/SourcesPage';
|
||||
import { UsersPage } from '../pages/admin/UsersPage';
|
||||
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
||||
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
||||
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
||||
const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
|
||||
const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
|
||||
const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
|
||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||
import type { DesktopHandle } from '../desktop';
|
||||
import { AlbumPage } from '../pages/AlbumPage';
|
||||
import { ConnectPage } from '../pages/ConnectPage';
|
||||
import { TrackPage } from '../pages/TrackPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
||||
import { SettingsPage } from '../pages/SettingsPage';
|
||||
@@ -30,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
|
||||
|
||||
export interface RouterContext {
|
||||
session: AuthSession;
|
||||
desktop: DesktopHandle | null;
|
||||
}
|
||||
|
||||
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||
@@ -48,9 +55,12 @@ export function safeRedirect(target: unknown): string {
|
||||
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
||||
}
|
||||
|
||||
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
|
||||
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
|
||||
const user = signedInUser(session);
|
||||
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||
if (!user) {
|
||||
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
|
||||
throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -59,12 +69,34 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
errorComponent: RouteError,
|
||||
});
|
||||
|
||||
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
|
||||
const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage });
|
||||
const androidAppRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/android',
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/download' });
|
||||
},
|
||||
component: AppDownloadPage,
|
||||
});
|
||||
|
||||
const connectRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/connect',
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.desktop) throw redirect({ to: '/login' });
|
||||
if (context.desktop.server) throw redirect({ to: '/login' });
|
||||
},
|
||||
component: ConnectPage,
|
||||
});
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/login',
|
||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||
beforeLoad: ({ context, search }) => {
|
||||
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
|
||||
const user = signedInUser(context.session);
|
||||
if (!user) return;
|
||||
const home = homeFor(user);
|
||||
@@ -78,7 +110,7 @@ const setupAdminRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup/admin',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||
},
|
||||
component: () => <SetupPage variant="admin" />,
|
||||
@@ -88,7 +120,7 @@ const setupPasswordRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup/password',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||
},
|
||||
component: () => <SetupPage variant="password" />,
|
||||
@@ -99,7 +131,7 @@ const appRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'app',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
const home = homeFor(user);
|
||||
if (home !== '/') throw redirect({ to: home });
|
||||
return { user };
|
||||
@@ -112,6 +144,9 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
};
|
||||
|
||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||
|
||||
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||
|
||||
@@ -221,7 +256,9 @@ const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/upl
|
||||
const uploadQueueRoute = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: '/admin/uploads',
|
||||
beforeLoad: adminOnly,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.user.reviews_uploads) throw redirect({ to: '/' });
|
||||
},
|
||||
component: UploadQueuePage,
|
||||
});
|
||||
|
||||
@@ -233,11 +270,17 @@ const adminSettingsRoute = createRoute({
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
downloadAppRoute,
|
||||
androidAppRoute,
|
||||
connectRoute,
|
||||
loginRoute,
|
||||
setupAdminRoute,
|
||||
setupPasswordRoute,
|
||||
appRoute.addChildren([
|
||||
homeRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
playlistRoute,
|
||||
settingsRoute,
|
||||
searchRoute,
|
||||
historyRoute,
|
||||
@@ -257,8 +300,12 @@ const routeTree = rootRoute.addChildren([
|
||||
]),
|
||||
]);
|
||||
|
||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
||||
export function createAppRouter(
|
||||
session: AuthSession,
|
||||
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
|
||||
) {
|
||||
const { desktop = null, ...rest } = opts;
|
||||
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
|
||||
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||
session.subscribe(() => void router.invalidate());
|
||||
return router;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDesktop, isDesktop } from './desktop';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('desktop bridge', () => {
|
||||
it('is inactive in the browser', () => {
|
||||
expect(isDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
it('bootstraps through the Tauri invoke surface', async () => {
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
|
||||
if (cmd === 'set_server') return 'https://music.example/pipu';
|
||||
return null;
|
||||
});
|
||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
|
||||
const desktop = await createDesktop();
|
||||
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
|
||||
expect(desktop?.server).toBe('https://music.example');
|
||||
await desktop?.setServer('https://music.example/pipu');
|
||||
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
|
||||
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
||||
|
||||
type Tauri = {
|
||||
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
|
||||
};
|
||||
|
||||
function api(): Tauri | undefined {
|
||||
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
|
||||
}
|
||||
|
||||
export function isDesktop(): boolean {
|
||||
return api() !== undefined;
|
||||
}
|
||||
|
||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const tauri = api();
|
||||
if (!tauri) throw new Error('Tauri API unavailable');
|
||||
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||
}
|
||||
|
||||
export type DesktopHandle = {
|
||||
proxyBase: string;
|
||||
server: string | null;
|
||||
setServer: (url: string) => Promise<string>;
|
||||
clearServer: () => Promise<void>;
|
||||
refreshStore: RefreshStore;
|
||||
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
|
||||
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
|
||||
};
|
||||
|
||||
export const DesktopContext = createContext<DesktopHandle | null>(null);
|
||||
|
||||
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
|
||||
return createElement(DesktopContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useDesktop(): DesktopHandle | null {
|
||||
return useContext(DesktopContext);
|
||||
}
|
||||
|
||||
let active: DesktopHandle | null = null;
|
||||
|
||||
export function desktopHandle(): DesktopHandle | null {
|
||||
return active;
|
||||
}
|
||||
|
||||
export async function createDesktop(): Promise<DesktopHandle | null> {
|
||||
if (!isDesktop()) return null;
|
||||
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
|
||||
const handle: DesktopHandle = {
|
||||
proxyBase: boot.proxyBase,
|
||||
server: boot.server,
|
||||
setServer: async (url) => {
|
||||
const server = await invoke<string>('set_server', { url });
|
||||
handle.server = server;
|
||||
return server;
|
||||
},
|
||||
clearServer: async () => {
|
||||
await invoke('clear_server');
|
||||
handle.server = null;
|
||||
},
|
||||
refreshStore: {
|
||||
get: () => invoke<string | null>('token_get'),
|
||||
set: (token) => invoke('token_set', { token }),
|
||||
clear: async () => {
|
||||
await invoke('token_clear');
|
||||
await invoke('user_clear');
|
||||
},
|
||||
getUser: async () => {
|
||||
const json = await invoke<string | null>('user_get');
|
||||
return json ? (JSON.parse(json) as AuthUser) : null;
|
||||
},
|
||||
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
|
||||
},
|
||||
listenMedia: async (handler) => {
|
||||
const tauri = api();
|
||||
if (!tauri) return () => undefined;
|
||||
return tauri.event.listen('media', (e) => handler(e.payload));
|
||||
},
|
||||
setMedia: (info) => {
|
||||
void invoke('media_set', {
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
album: info.album,
|
||||
coverUrl: info.coverUrl ?? null,
|
||||
playing: info.playing,
|
||||
});
|
||||
},
|
||||
};
|
||||
active = handle;
|
||||
return handle;
|
||||
}
|
||||
@@ -270,3 +270,12 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
|
||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||
.end { gap: 4px; }
|
||||
.like { opacity: 1; width: 44px; height: 44px; }
|
||||
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Fragment } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Fragment, useRef, type ReactNode } from 'react';
|
||||
import { AddToPlaylist } from './playlists';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
@@ -114,6 +116,7 @@ export function TrackList({
|
||||
rowMeta,
|
||||
lead,
|
||||
disliked,
|
||||
actions,
|
||||
}: {
|
||||
tracks: Track[];
|
||||
showAlbum?: boolean;
|
||||
@@ -125,86 +128,121 @@ export function TrackList({
|
||||
lead?: (t: Track, i: number) => string;
|
||||
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
||||
disliked?: boolean;
|
||||
actions?: (track: Track, index: number) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const { track: current, playing } = usePlayerState();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||
const multiDisc = Boolean(numbered && discs.size > 1);
|
||||
const virtual = tracks.length > 30 && !multiDisc;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: virtual ? tracks.length : 0,
|
||||
getScrollElement: () => listRef.current?.closest('main') ?? null,
|
||||
estimateSize: () => 68,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const row = (tr: Track, i: number) => {
|
||||
const isCurrent = current?.id === tr.id;
|
||||
const play = () => tr.available && player.playList(tracks, i, context);
|
||||
const liked = tr.reaction === 'like';
|
||||
const meta = rowMeta?.(tr, i);
|
||||
return (
|
||||
<div
|
||||
role="listitem"
|
||||
tabIndex={tr.available ? 0 : -1}
|
||||
aria-current={isCurrent || undefined}
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||
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();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||
}}
|
||||
>
|
||||
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
||||
<span className={cx(s.num, disliked && s.dimmed)}>
|
||||
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
||||
{tr.available && (
|
||||
<span className={s.playHint} aria-hidden>
|
||||
<Icon src={playIcon} size={20} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
||||
<span className={s.trackTitle}>{tr.title}</span>
|
||||
<span className={s.trackArtists}>
|
||||
<ArtistLinks artists={tr.artists} />
|
||||
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||
</span>
|
||||
</span>
|
||||
{showAlbum && (
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
||||
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
||||
{disliked ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, s.liked)}
|
||||
aria-label={t('player.undislike')}
|
||||
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
||||
>
|
||||
<Icon src={undoIcon} size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, liked && s.liked)}
|
||||
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||
aria-pressed={liked}
|
||||
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
||||
>
|
||||
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
||||
</button>
|
||||
)}
|
||||
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
let lastDisc: number | null = null;
|
||||
return (
|
||||
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
|
||||
{tracks.map((tr, i) => {
|
||||
const disc = tr.disc_no ?? 1;
|
||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||
lastDisc = disc;
|
||||
const isCurrent = current?.id === tr.id;
|
||||
const play = () => tr.available && player.playList(tracks, i, context);
|
||||
const liked = tr.reaction === 'like';
|
||||
const meta = rowMeta?.(tr, i);
|
||||
return (
|
||||
<Fragment key={`${tr.id}-${i}`}>
|
||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||
<div
|
||||
ref={listRef}
|
||||
className={s.tracks}
|
||||
role="list"
|
||||
aria-label={t('search.tracks')}
|
||||
style={virtual ? { height: virtualizer.getTotalSize(), position: 'relative' } : undefined}
|
||||
>
|
||||
{virtual
|
||||
? virtualizer.getVirtualItems().map((vi) => (
|
||||
<div
|
||||
role="row"
|
||||
tabIndex={tr.available ? 0 : -1}
|
||||
aria-current={isCurrent || undefined}
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) play();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||
}}
|
||||
key={vi.key}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||
>
|
||||
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
||||
<span className={cx(s.num, disliked && s.dimmed)}>
|
||||
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
||||
{tr.available && (
|
||||
<span className={s.playHint} aria-hidden>
|
||||
<Icon src={playIcon} size={20} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
||||
<span className={s.trackTitle}>{tr.title}</span>
|
||||
<span className={s.trackArtists}>
|
||||
<ArtistLinks artists={tr.artists} />
|
||||
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||
</span>
|
||||
</span>
|
||||
{showAlbum && (
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end}>
|
||||
{disliked ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, s.liked)}
|
||||
aria-label={t('player.undislike')}
|
||||
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
||||
>
|
||||
<Icon src={undoIcon} size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, liked && s.liked)}
|
||||
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||
aria-pressed={liked}
|
||||
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
||||
>
|
||||
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
||||
</button>
|
||||
)}
|
||||
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||
</span>
|
||||
{row(tracks[vi.index]!, vi.index)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
))
|
||||
: tracks.map((tr, i) => {
|
||||
const disc = tr.disc_no ?? 1;
|
||||
const header = multiDisc && disc !== lastDisc;
|
||||
lastDisc = disc;
|
||||
return (
|
||||
<Fragment key={`${tr.id}-${i}`}>
|
||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||
{row(tr, i)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||
import { queueIcon } from '../shared/ui/icons';
|
||||
import s from './catalog.module.css';
|
||||
|
||||
export type Playlist = components['schemas']['Playlist'];
|
||||
export type PlaylistInput = components['schemas']['PlaylistInput'];
|
||||
|
||||
export function usePlaylists(enabled = true) {
|
||||
const session = useSession();
|
||||
return useQuery({ queryKey: ['playlists'], enabled,
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||
}
|
||||
|
||||
export function usePlaylist(id: string) {
|
||||
const session = useSession();
|
||||
return useQuery({ queryKey: ['playlists', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
}
|
||||
|
||||
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
|
||||
}
|
||||
|
||||
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
|
||||
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [original] = useState(playlist);
|
||||
const [name, setName] = useState(playlist?.name ?? '');
|
||||
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||
const save = useMutation({ mutationFn: async () => {
|
||||
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
|
||||
return original
|
||||
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||
}, onSuccess: p => {
|
||||
cache.setQueryData(['playlists', p.id], p);
|
||||
void cache.invalidateQueries({ queryKey: ['playlists'] });
|
||||
onSaved(p);
|
||||
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
|
||||
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
|
||||
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
|
||||
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
|
||||
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
|
||||
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
|
||||
</form>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
||||
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const list = usePlaylists();
|
||||
const [create, setCreate] = useState(false);
|
||||
const add = useMutation({ mutationFn: async (id: string) => {
|
||||
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||
const body = playlistInput(p);
|
||||
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
|
||||
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
|
||||
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
|
||||
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
|
||||
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
|
||||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
|
||||
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return <>
|
||||
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
|
||||
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
|
||||
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { needsFormatAttention } from './files';
|
||||
|
||||
describe('needsFormatAttention', () => {
|
||||
it('marks everything except lossless formats', () => {
|
||||
expect(needsFormatAttention('flac', 'a.mp3')).toBe(false);
|
||||
expect(needsFormatAttention('WAV', 'track.wav')).toBe(false);
|
||||
expect(needsFormatAttention(null, 'Кино/01.wav')).toBe(false);
|
||||
expect(needsFormatAttention('aiff', 'a.aif')).toBe(false);
|
||||
expect(needsFormatAttention(null, 'track.AIFF')).toBe(false);
|
||||
expect(needsFormatAttention(null, 'track.wv')).toBe(false);
|
||||
expect(needsFormatAttention('mp3', 'a.flac')).toBe(true);
|
||||
expect(needsFormatAttention(null, 'folder/song.MP3')).toBe(true);
|
||||
expect(needsFormatAttention(null, 'song.m4a')).toBe(true);
|
||||
expect(needsFormatAttention(null, 'noext')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,23 @@ export interface PickedFile {
|
||||
/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */
|
||||
export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv'];
|
||||
|
||||
/** То, что просим присылать. MP3 и прочие — крайний случай, их помечает очередь. */
|
||||
// Lossless — как в quality.go на сервере.
|
||||
const PREFERRED_AUDIO = new Set(['flac', 'wav', 'aiff', 'aif', 'wv']);
|
||||
|
||||
export function audioExt(name: string): string {
|
||||
const base = name.split(/[/\\]/).pop() ?? name;
|
||||
const dot = base.lastIndexOf('.');
|
||||
if (dot < 0 || dot === base.length - 1) return '';
|
||||
return base.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/** Не lossless (FLAC, WAV, AIFF, WavPack): в очереди на добавление такой файл нужно выделить. */
|
||||
export function needsFormatAttention(format: string | null | undefined, name: string): boolean {
|
||||
const ext = (format || audioExt(name)).toLowerCase();
|
||||
return ext !== '' && !PREFERRED_AUDIO.has(ext);
|
||||
}
|
||||
|
||||
export function isAudio(name: string): boolean {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
return AUDIO_EXTENSIONS.includes(ext);
|
||||
|
||||
@@ -17,15 +17,18 @@ export type Stage =
|
||||
| 'uploading'
|
||||
| 'done'
|
||||
| 'rejected'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'resume';
|
||||
|
||||
export interface UploadItem {
|
||||
id: string;
|
||||
file: File;
|
||||
file: File | null;
|
||||
size: number;
|
||||
relPath: string;
|
||||
stage: Stage;
|
||||
progress: number; // 0..1 текущего этапа (хэш или загрузка)
|
||||
sha256?: string;
|
||||
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
|
||||
trackId?: string | null;
|
||||
possibleDuplicate?: boolean;
|
||||
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
|
||||
@@ -44,6 +47,17 @@ export interface UploaderState {
|
||||
|
||||
const PARALLEL = 4;
|
||||
const CHUNK = 8 << 20;
|
||||
const PENDING_KEY = 'pipu.uploads.pending';
|
||||
|
||||
interface PendingUpload {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
relPath: string;
|
||||
sha256: string;
|
||||
tusUrl: string;
|
||||
uploadFileId: string;
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
|
||||
@@ -56,7 +70,9 @@ export class Uploader {
|
||||
private uploads = new Map<string, tus.Upload>();
|
||||
private refreshing: Promise<unknown> | null = null;
|
||||
|
||||
constructor(private readonly session: AuthSession) {}
|
||||
constructor(private readonly session: AuthSession) {
|
||||
this.restore();
|
||||
}
|
||||
|
||||
subscribe = (fn: () => void) => {
|
||||
this.listeners.add(fn);
|
||||
@@ -89,8 +105,8 @@ export class Uploader {
|
||||
continue;
|
||||
}
|
||||
// Тот же путь и размер уже в списке — не добавляем второй раз.
|
||||
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue;
|
||||
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 });
|
||||
if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
|
||||
fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
|
||||
}
|
||||
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
|
||||
this.hashQueue.push(...fresh.map((it) => it.id));
|
||||
@@ -101,6 +117,7 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
this.hashQueue = this.hashQueue.filter((x) => x !== id);
|
||||
this.set({ items: this.state.items.filter((it) => it.id !== id) });
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Очистить список (после завершения или чтобы начать заново). */
|
||||
@@ -108,6 +125,7 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
this.hashQueue = [];
|
||||
this.set({ items: [], skipped: 0, batchId: null, error: null });
|
||||
this.persist();
|
||||
}
|
||||
|
||||
private hashWorker(): Worker {
|
||||
@@ -125,6 +143,7 @@ export class Uploader {
|
||||
if (!it) continue;
|
||||
try {
|
||||
const sha256 = await this.hash(it);
|
||||
await this.readTags(it);
|
||||
this.patch(id, { sha256, stage: 'checking', progress: 1 });
|
||||
} catch (err) {
|
||||
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
@@ -137,13 +156,15 @@ export class Uploader {
|
||||
}
|
||||
|
||||
private hash(it: UploadItem): Promise<string> {
|
||||
const file = it.file;
|
||||
if (!file) return Promise.reject(new Error('no file'));
|
||||
const w = this.hashWorker();
|
||||
return new Promise((resolve, reject) => {
|
||||
const onMessage = (e: MessageEvent<HashResponse>) => {
|
||||
const m = e.data;
|
||||
if (m.id !== it.id) return;
|
||||
if (m.type === 'progress') {
|
||||
this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) });
|
||||
this.patch(it.id, { progress: m.done / Math.max(it.size, 1) });
|
||||
return;
|
||||
}
|
||||
w.removeEventListener('message', onMessage);
|
||||
@@ -151,7 +172,7 @@ export class Uploader {
|
||||
else reject(new Error(m.message));
|
||||
};
|
||||
w.addEventListener('message', onMessage);
|
||||
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest);
|
||||
w.postMessage({ id: it.id, file } satisfies HashRequest);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,7 +183,17 @@ export class Uploader {
|
||||
try {
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/uploads/precheck', {
|
||||
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) },
|
||||
body: {
|
||||
files: pending.map((it) => ({
|
||||
client_id: it.id,
|
||||
size: it.size,
|
||||
sha256: it.sha256!,
|
||||
artist: it.tags?.artist,
|
||||
title: it.tags?.title,
|
||||
album: it.tags?.album,
|
||||
duration_ms: it.tags?.duration_ms,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const r of res.files) {
|
||||
@@ -192,9 +223,9 @@ export class Uploader {
|
||||
comment: comment.trim() || undefined,
|
||||
files: ready.map((it) => ({
|
||||
client_id: it.id,
|
||||
name: it.file.name,
|
||||
name: it.file?.name ?? it.relPath,
|
||||
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
|
||||
size: it.file.size,
|
||||
size: it.size,
|
||||
sha256: it.sha256!,
|
||||
})),
|
||||
},
|
||||
@@ -209,6 +240,7 @@ export class Uploader {
|
||||
queue.push({ id: f.client_id, url: f.tus_url });
|
||||
}
|
||||
}
|
||||
this.persist();
|
||||
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
||||
} catch (err) {
|
||||
this.set({ error: err });
|
||||
@@ -239,9 +271,10 @@ export class Uploader {
|
||||
|
||||
private upload(id: string, url: string): Promise<void> {
|
||||
const it = this.item(id);
|
||||
if (!it) return Promise.resolve();
|
||||
if (!it?.file) return Promise.resolve();
|
||||
const file = it.file;
|
||||
return new Promise((resolve) => {
|
||||
const up = new tus.Upload(it.file, {
|
||||
const up = new tus.Upload(file, {
|
||||
uploadUrl: url,
|
||||
chunkSize: CHUNK,
|
||||
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
|
||||
@@ -264,6 +297,7 @@ export class Uploader {
|
||||
onSuccess: () => {
|
||||
this.uploads.delete(id);
|
||||
this.patch(id, { stage: 'done', progress: 1 });
|
||||
this.persist();
|
||||
resolve();
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -276,6 +310,100 @@ export class Uploader {
|
||||
up.start();
|
||||
});
|
||||
}
|
||||
|
||||
private async readTags(it: UploadItem) {
|
||||
if (!it.file) return;
|
||||
try {
|
||||
const { parseBlob } = await import('music-metadata');
|
||||
const meta = await parseBlob(it.file);
|
||||
const duration = meta.format.duration;
|
||||
this.patch(it.id, {
|
||||
tags: {
|
||||
artist: meta.common.artist,
|
||||
title: meta.common.title,
|
||||
album: meta.common.album,
|
||||
duration_ms: duration ? Math.round(duration * 1000) : undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Теги — подсказка для предпроверки; без них сервер прочитает их сам.
|
||||
}
|
||||
}
|
||||
|
||||
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
|
||||
private persist() {
|
||||
const pending: PendingUpload[] = this.state.items
|
||||
.filter((it): it is UploadItem & { sha256: string; tusUrl: string; uploadFileId: string } =>
|
||||
Boolean(it.tusUrl && it.sha256 && it.uploadFileId && it.stage !== 'done'),
|
||||
)
|
||||
.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.file?.name ?? it.relPath,
|
||||
size: it.size,
|
||||
relPath: it.relPath,
|
||||
sha256: it.sha256,
|
||||
tusUrl: it.tusUrl,
|
||||
uploadFileId: it.uploadFileId,
|
||||
}));
|
||||
try {
|
||||
if (pending.length) localStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
||||
else localStorage.removeItem(PENDING_KEY);
|
||||
} catch {
|
||||
// Приватный режим — докачка после перезагрузки недоступна.
|
||||
}
|
||||
}
|
||||
|
||||
private restore() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PENDING_KEY);
|
||||
if (!raw) return;
|
||||
const rows = JSON.parse(raw) as PendingUpload[];
|
||||
if (!Array.isArray(rows) || !rows.length) return;
|
||||
this.state = {
|
||||
...this.state,
|
||||
items: rows.map((r) => ({
|
||||
id: r.id,
|
||||
file: null,
|
||||
size: r.size,
|
||||
relPath: r.relPath,
|
||||
stage: 'resume' as const,
|
||||
progress: 0,
|
||||
sha256: r.sha256,
|
||||
tusUrl: r.tusUrl,
|
||||
uploadFileId: r.uploadFileId,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
localStorage.removeItem(PENDING_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Приложить заново те же файлы и продолжить tus с принятого смещения. */
|
||||
async continueWith(files: PickedFile[]) {
|
||||
const waiting = this.state.items.filter((it) => it.stage === 'resume' && it.tusUrl);
|
||||
if (!waiting.length) return;
|
||||
const queue: { id: string; url: string }[] = [];
|
||||
for (const picked of files) {
|
||||
const it = waiting.find((w) => w.size === picked.file.size && (w.relPath === picked.relPath || w.relPath.endsWith(picked.file.name)));
|
||||
if (!it) continue;
|
||||
this.patch(it.id, { file: picked.file, stage: 'hashing', progress: 0 });
|
||||
const sha = await this.hash(this.item(it.id)!);
|
||||
if (sha !== it.sha256) {
|
||||
this.patch(it.id, { stage: 'error', message: 'sha256' });
|
||||
continue;
|
||||
}
|
||||
this.patch(it.id, { stage: 'uploading', progress: 0 });
|
||||
queue.push({ id: it.id, url: it.tusUrl! });
|
||||
}
|
||||
if (!queue.length) return;
|
||||
this.set({ busy: true });
|
||||
try {
|
||||
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
||||
} finally {
|
||||
this.set({ busy: false });
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let instance: { session: AuthSession; uploader: Uploader } | null = null;
|
||||
|
||||
+16
-3
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
||||
import { App } from './App';
|
||||
import { createAppRouter } from './app/router';
|
||||
import { applyTheme } from './app/session';
|
||||
import { createDesktop, DesktopProvider } from './desktop';
|
||||
|
||||
const LOCALE_KEY = 'pipu.locale';
|
||||
|
||||
@@ -48,6 +49,10 @@ function deviceName(): string {
|
||||
}
|
||||
|
||||
applyTheme(undefined);
|
||||
if (import.meta.env.VITE_E2E === '1') {
|
||||
const { installE2EFetch } = await import('./test/e2eBridge');
|
||||
installE2EFetch();
|
||||
}
|
||||
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
|
||||
document.documentElement.lang = i18n.language;
|
||||
i18n.on('languageChanged', (l) => {
|
||||
@@ -59,15 +64,23 @@ i18n.on('languageChanged', (l) => {
|
||||
}
|
||||
});
|
||||
|
||||
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
|
||||
const desktop = await createDesktop();
|
||||
const session = new AuthSession({
|
||||
clientType: desktop ? 'desktop' : 'web',
|
||||
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||
baseUrl: desktop?.proxyBase,
|
||||
refreshStore: desktop?.refreshStore,
|
||||
});
|
||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||
await session.restore();
|
||||
const router = createAppRouter(session);
|
||||
const router = createAppRouter(session, { desktop });
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App session={session} router={router} />
|
||||
<DesktopProvider value={desktop}>
|
||||
<App session={session} router={router} />
|
||||
</DesktopProvider>
|
||||
</I18nextProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
@@ -45,6 +46,7 @@ export function AlbumPage({ id }: { id: string }) {
|
||||
)}
|
||||
{a && a.tracks.some((tr) => tr.available) && (
|
||||
<div className={c.heroActions}>
|
||||
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
|
||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { formatBytes } from '../shared/lib/format';
|
||||
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
|
||||
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||
import { Icon } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/**
|
||||
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
||||
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||
*/
|
||||
export function AppDownloadPage() {
|
||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const detected = useMemo(() => detectAppClient(), []);
|
||||
const latest = useQuery({
|
||||
queryKey: ['app-clients'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const server = location.origin;
|
||||
const copy = () =>
|
||||
void navigator.clipboard?.writeText(server).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
const data = latest.data;
|
||||
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
|
||||
const primary = detected ? files[detected] : undefined;
|
||||
const others = otherAppClients(detected).filter((id) => files[id]);
|
||||
|
||||
return (
|
||||
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||
<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 ? (
|
||||
<>
|
||||
{primary ? (
|
||||
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||
) : detected ? (
|
||||
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
||||
) : null}
|
||||
{(primary || others.length > 0) && (
|
||||
<ol className={s.steps}>
|
||||
{detected && <li>{t(`app.step_install.${detected}`)}</li>}
|
||||
<li>
|
||||
{t('app.step_server')}
|
||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||
<code>{server}</code>
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
|
||||
</ol>
|
||||
)}
|
||||
{primary?.sha256 && (
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{primary.sha256}</code>
|
||||
</details>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div className={s.otherClients}>
|
||||
<p className={s.otherTitle}>{t('app.other')}</p>
|
||||
{others.map((id) => {
|
||||
const file = files[id]!;
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
|
||||
</>
|
||||
) : (
|
||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
||||
const { t } = useTranslation('auth');
|
||||
return (
|
||||
<a className={s.download} href={href} download>
|
||||
<Icon src={downloadIcon} size={22} />
|
||||
<span>
|
||||
{t(`app.download.${client}`)}
|
||||
<span className={s.downloadMeta}>{meta}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
|
||||
import { useDesktop } from '../desktop';
|
||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
export function ConnectPage() {
|
||||
const { t } = useTranslation();
|
||||
const desktop = useDesktop();
|
||||
const router = useRouter();
|
||||
const [address, setAddress] = useState(desktop?.server ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!desktop) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await desktop.setServer(address);
|
||||
await router.invalidate();
|
||||
await router.navigate({ to: '/login' });
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : '';
|
||||
setError(/Invalid|query|fragment|credentials/i.test(text) ? t('server.invalid_address') : t('server.offline'));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1>{t('server.title')}</h1>
|
||||
<p className={s.lead}>{t('app.tagline')}</p>
|
||||
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||
<Field
|
||||
label={t('server.address')}
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
autoComplete="url"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
<Button type="submit" variant="primary" className={s.submit} busy={busy} disabled={!address.trim()}>
|
||||
{t('mobile.connect')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { cx, FormError, Icon } from '../shared/ui/ui';
|
||||
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||
import s from './home.module.css';
|
||||
import { Onboarding } from './Onboarding';
|
||||
|
||||
/** Иконки Material Symbols по именам из /wave/options. */
|
||||
const ICONS: Record<string, string> = {
|
||||
@@ -44,8 +45,28 @@ const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
type Tr = (key: string) => string;
|
||||
|
||||
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
|
||||
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
||||
export function HomePage() {
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const onboarding = useQuery({
|
||||
queryKey: ['onboarding'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
if (onboarding.data?.required) {
|
||||
return (
|
||||
<Onboarding
|
||||
options={onboarding.data}
|
||||
onDone={() => queryClient.setQueryData(['onboarding'], { ...onboarding.data, required: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <WaveHome />;
|
||||
}
|
||||
|
||||
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
|
||||
function WaveHome() {
|
||||
const { t } = useTranslation();
|
||||
const tr = t as unknown as Tr;
|
||||
const session = useSession();
|
||||
@@ -63,6 +84,8 @@ export function HomePage() {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const showActivities = user.ui_prefs.home_activities !== false;
|
||||
const showMoods = user.ui_prefs.home_moods !== false;
|
||||
const waveOn = st.wave !== null;
|
||||
const playing = waveOn && st.playing;
|
||||
const track = waveOn ? st.track : null;
|
||||
@@ -95,6 +118,7 @@ export function HomePage() {
|
||||
<WaveButton
|
||||
variant={user.ui_prefs.wave_variant ?? 'orb'}
|
||||
playing={playing}
|
||||
readAudio={player.readAudio}
|
||||
bpm={track?.features?.bpm ?? undefined}
|
||||
energy={track?.features?.energy ?? undefined}
|
||||
colors={colors}
|
||||
@@ -112,22 +136,26 @@ export function HomePage() {
|
||||
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
|
||||
<FormError>{error ? errorText(error) : null}</FormError>
|
||||
|
||||
{options.data && (
|
||||
{options.data && (showActivities || showMoods) && (
|
||||
<div className={s.picker}>
|
||||
<Chips
|
||||
title={t('wave.activity_label')}
|
||||
options={options.data.activities}
|
||||
value={activity}
|
||||
label={(k) => tr(`wave.activities.${k}`)}
|
||||
onPick={(k) => choose('activity', k)}
|
||||
/>
|
||||
<Chips
|
||||
title={t('wave.mood_label')}
|
||||
options={options.data.moods}
|
||||
value={mood}
|
||||
label={(k) => tr(`wave.moods.${k}`)}
|
||||
onPick={(k) => choose('mood', k)}
|
||||
/>
|
||||
{showActivities && (
|
||||
<Chips
|
||||
title={t('wave.activity_label')}
|
||||
options={options.data.activities}
|
||||
value={activity}
|
||||
label={(k) => tr(`wave.activities.${k}`)}
|
||||
onPick={(k) => choose('activity', k)}
|
||||
/>
|
||||
)}
|
||||
{showMoods && (
|
||||
<Chips
|
||||
title={t('wave.mood_label')}
|
||||
options={options.data.moods}
|
||||
value={mood}
|
||||
label={(k) => tr(`wave.moods.${k}`)}
|
||||
onPick={(k) => choose('mood', k)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { useDesktop } from '../desktop';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
@@ -11,6 +12,7 @@ import s from './auth.module.css';
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const errorText = useErrorText();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -37,7 +39,21 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
|
||||
<AuthLayout
|
||||
footer={
|
||||
info.data ? (
|
||||
<>
|
||||
pipu {info.data.version}
|
||||
{desktop ? null : (
|
||||
<>
|
||||
{' · '}
|
||||
<a href="/download">{t('app.link')}</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<h1>{t('login.title')}</h1>
|
||||
<p className={s.lead}>{t('login.subtitle')}</p>
|
||||
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover } from '../entities/catalog';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { checkIcon } from '../shared/ui/icons';
|
||||
import { Button, cx, FormError, Icon } from '../shared/ui/ui';
|
||||
import s from './onboarding.module.css';
|
||||
|
||||
type Options = components['schemas']['Onboarding'];
|
||||
|
||||
/** Минимум исполнителей, чтобы волне было от чего оттолкнуться. */
|
||||
const MIN_ARTISTS = 3;
|
||||
|
||||
/**
|
||||
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
|
||||
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
|
||||
*/
|
||||
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
|
||||
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 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] },
|
||||
}),
|
||||
),
|
||||
onSuccess: onDone,
|
||||
});
|
||||
// Функциональное обновление: быстрые нажатия подряд не теряют выбор.
|
||||
const toggle = (update: (fn: (prev: Set<string>) => Set<string>) => void, key: string) =>
|
||||
update((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={s.head}>
|
||||
<h1>{t('onboarding.title')}</h1>
|
||||
<p>{t('onboarding.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
<section aria-label={t('onboarding.artists')}>
|
||||
<h2 className={s.label}>{t('onboarding.artists')}</h2>
|
||||
<div className={s.artists}>
|
||||
{options.artists.map((a) => {
|
||||
const on = artists.has(a.id);
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className={cx(s.artist, on && s.on)}
|
||||
aria-pressed={on}
|
||||
onClick={() => toggle(setArtists, a.id)}
|
||||
>
|
||||
<span className={s.avatar}>
|
||||
<Cover album={{ id: a.id, cover_url: a.cover_url, cover_colors: a.cover_colors }} size={96} round />
|
||||
{on && (
|
||||
<span className={s.check}>
|
||||
<Icon src={checkIcon} size={22} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={s.name}>{a.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{options.genres.length > 0 && (
|
||||
<section aria-label={t('onboarding.genres')}>
|
||||
<h2 className={s.label}>{t('onboarding.genres')}</h2>
|
||||
<div className={s.genres}>
|
||||
{options.genres.map((g) => (
|
||||
<button
|
||||
key={g.name}
|
||||
type="button"
|
||||
className={s.genre}
|
||||
aria-pressed={genres.has(g.name)}
|
||||
onClick={() => toggle(setGenres, g.name)}
|
||||
>
|
||||
{g.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
|
||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import c from './catalog.module.css';
|
||||
import s from './page.module.css';
|
||||
import p from './playlists.module.css';
|
||||
|
||||
export function PlaylistsPage() {
|
||||
const { t } = useTranslation();
|
||||
const list = usePlaylists();
|
||||
const errorText = useErrorText();
|
||||
const navigate = useNavigate();
|
||||
const [create, setCreate] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? [];
|
||||
return <div className={s.page}>
|
||||
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
||||
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
|
||||
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||
<Cover album={item} size="100%" />
|
||||
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||
{item.description && <span className={p.description}>{item.description}</span>}
|
||||
</Link>)}</div>
|
||||
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PlaylistPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const query = usePlaylist(id);
|
||||
const errorText = useErrorText();
|
||||
return <div className={s.page}>
|
||||
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
const player = usePlayer();
|
||||
const navigate = useNavigate();
|
||||
const errorText = useErrorText();
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
||||
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
|
||||
})), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); },
|
||||
onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })),
|
||||
onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } });
|
||||
const play = (shuffle: boolean) => {
|
||||
const tracks = playlist.tracks.filter(x => x.available);
|
||||
if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; }
|
||||
if (tracks.length) player.playList(tracks, 0, context);
|
||||
};
|
||||
const move = (index: number, delta: number) => {
|
||||
const ids = playlist.tracks.map(x => x.id);
|
||||
[ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!];
|
||||
save.mutate(ids);
|
||||
};
|
||||
return <>
|
||||
<header className={c.colHero}>
|
||||
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
|
||||
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
||||
<p className={p.description}>{playlist.description}</p>
|
||||
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className={s.toolbar}>
|
||||
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||||
</div>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
|
||||
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||||
actions={manage ? (track, i) => <span className={p.actions}>
|
||||
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||||
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
|
||||
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||||
</span> : undefined} />
|
||||
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
|
||||
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../app/session';
|
||||
import { useDesktop } from '../desktop';
|
||||
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||
import { formatDateTime } from '../shared/lib/format';
|
||||
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||
@@ -126,6 +128,55 @@ function AppearanceSection() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.search_position')}</span>
|
||||
<Segmented<'left' | 'center' | 'right'>
|
||||
label={t('settings.search_position')}
|
||||
value={user.ui_prefs.search_position ?? 'center'}
|
||||
onChange={(v) => void setPrefs({ search_position: v })}
|
||||
options={[
|
||||
{ value: 'left', label: t('settings.search_left') },
|
||||
{ value: 'center', label: t('settings.search_center') },
|
||||
{ value: 'right', label: t('settings.search_right') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.search_collapse')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.search_collapse')}
|
||||
value={user.ui_prefs.search_collapse === true ? 'on' : 'off'}
|
||||
onChange={(v) => void setPrefs({ search_collapse: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.home_activities')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.home_activities')}
|
||||
value={user.ui_prefs.home_activities === false ? 'off' : 'on'}
|
||||
onChange={(v) => void setPrefs({ home_activities: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.home_moods')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.home_moods')}
|
||||
value={user.ui_prefs.home_moods === false ? 'off' : 'on'}
|
||||
onChange={(v) => void setPrefs({ home_moods: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.ambient')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
@@ -138,6 +189,30 @@ function AppearanceSection() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.normalize')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.normalize')}
|
||||
value={user.ui_prefs.normalize === false ? 'off' : 'on'}
|
||||
onChange={(v) => void setPrefs({ normalize: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={6}
|
||||
step={1}
|
||||
aria-label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}
|
||||
value={user.ui_prefs.crossfade_s ?? 0}
|
||||
onChange={(e) => void setPrefs({ crossfade_s: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('language.label')}</span>
|
||||
<LanguageSwitch />
|
||||
@@ -224,6 +299,8 @@ function PasswordSection() {
|
||||
function SessionsSection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const sessions = useQuery({
|
||||
@@ -260,6 +337,19 @@ function SessionsSection() {
|
||||
</div>
|
||||
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||
<div>
|
||||
{desktop && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await desktop.clearServer();
|
||||
await session.logout();
|
||||
await navigate({ to: '/connect' });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('mobile.change_server')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||
{t('settings.logout_all')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
|
||||
export function TrackPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const track = useQuery({
|
||||
queryKey: ['track', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
|
||||
});
|
||||
if (track.isError) return <NotFound error={track.error} />;
|
||||
const tr = track.data;
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
{tr && <Cover album={tr.album} size={200} />}
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('player.track_page')}</span>
|
||||
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
|
||||
{tr && <>
|
||||
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
|
||||
</>}
|
||||
</div>
|
||||
</header>
|
||||
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type Entry = components['schemas']['AuditEntry'];
|
||||
function details(e: Entry, roleName: (r: string) => string): string {
|
||||
const p = e.payload ?? {};
|
||||
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
|
||||
if (e.action === 'user.username_change') return `${String(p.from)} → ${String(p.to)}`;
|
||||
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
|
||||
(v): v is string => typeof v === 'string' && v !== '',
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
@@ -31,6 +32,11 @@ export function LibraryPage() {
|
||||
const reindex = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const retry = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
|
||||
});
|
||||
const d = status.data;
|
||||
const tiles = d
|
||||
? ([
|
||||
@@ -76,7 +82,19 @@ export function LibraryPage() {
|
||||
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
|
||||
})}
|
||||
/>
|
||||
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
|
||||
{d.analysis.failed > 0 && (
|
||||
<div className={l.retryRow}>
|
||||
<span className={l.scanError}>
|
||||
{t('library.analysis_failed', { count: d.analysis.failed })}
|
||||
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
||||
</span>
|
||||
{admin && (
|
||||
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
||||
{t('library.analysis_retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
||||
</section>
|
||||
)}
|
||||
@@ -99,6 +117,33 @@ export function LibraryPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{d.wave.by_model.length > 1 && (
|
||||
<div className={s.tableWrap}>
|
||||
<table className={s.table}>
|
||||
<caption className={s.muted}>{t('library.wave_by_model')}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('library.wave_model')}</th>
|
||||
<th scope="col">{t('library.wave_served')}</th>
|
||||
<th scope="col">{t('library.wave_listen')}</th>
|
||||
<th scope="col">{t('library.wave_early_skip')}</th>
|
||||
<th scope="col">{t('library.wave_likes')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.wave.by_model.map((m) => (
|
||||
<tr key={m.model_ver}>
|
||||
<td>{m.model_ver || '—'}</td>
|
||||
<td>{m.served.toLocaleString()}</td>
|
||||
<td>{pct(m.listen_through_rate)}</td>
|
||||
<td>{pct(m.early_skip_rate)}</td>
|
||||
<td>{m.like_rate === null ? '—' : m.like_rate.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{d && d.sources.length > 0 && (
|
||||
@@ -133,10 +178,13 @@ const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%
|
||||
|
||||
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
|
||||
const value = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
const id = useId();
|
||||
return (
|
||||
<div className={l.scan}>
|
||||
<div className={l.scanText}>{label}</div>
|
||||
<div className={l.bar} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className={l.scanText} id={id}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
|
||||
<span style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatDateTime } from '../../shared/lib/format';
|
||||
@@ -16,14 +17,15 @@ export function StatusBadge({ status }: { status: SourceStatus }) {
|
||||
/** Прогресс идущего скана или итог последнего. */
|
||||
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
const id = useId();
|
||||
if (scan?.running) {
|
||||
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
||||
return (
|
||||
<div className={s.scan}>
|
||||
<div className={s.scanText}>
|
||||
<div className={s.scanText} id={id}>
|
||||
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
|
||||
</div>
|
||||
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { needsFormatAttention } from '../../features/uploads/files';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
@@ -35,6 +36,7 @@ export function UploadQueuePage() {
|
||||
const [onlyPossible, setOnlyPossible] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [editing, setEditing] = useState<UploadFile | null>(null);
|
||||
const [batchEdit, setBatchEdit] = useState<string | null>(null);
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
@@ -131,6 +133,7 @@ export function UploadQueuePage() {
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
onEdit={setEditing}
|
||||
onEditBatch={setBatchEdit}
|
||||
onRetry={(id) => retry.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
@@ -158,6 +161,17 @@ export function UploadQueuePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchEdit && (
|
||||
<BatchEditDialog
|
||||
batchId={batchEdit}
|
||||
onClose={() => setBatchEdit(null)}
|
||||
onSaved={() => {
|
||||
setBatchEdit(null);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditDialog
|
||||
file={editing}
|
||||
@@ -194,6 +208,7 @@ function BatchCard({
|
||||
selected,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onEditBatch,
|
||||
onRetry,
|
||||
}: {
|
||||
files: UploadFile[];
|
||||
@@ -201,6 +216,7 @@ function BatchCard({
|
||||
selected: Set<string>;
|
||||
onToggle: (ids: string[], on: boolean) => void;
|
||||
onEdit: (f: UploadFile) => void;
|
||||
onEditBatch: (batchId: string) => void;
|
||||
onRetry: (id: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
@@ -218,6 +234,12 @@ function BatchCard({
|
||||
<span>{formatDateTime(first.created_at, i18n.language)}</span>
|
||||
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
|
||||
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{selectable && (
|
||||
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
||||
{t('uploads.edit_batch')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{files.map((f) => (
|
||||
<QueueRow
|
||||
@@ -288,7 +310,13 @@ function QueueRow({
|
||||
<div className={u.meta}>
|
||||
<div className={u.flags}>
|
||||
<UploadStatusBadge status={f.status} />
|
||||
{needsFormatAttention(f.format, f.original_name) && (
|
||||
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
|
||||
{t('uploads.flag_attention')}
|
||||
</span>
|
||||
)}
|
||||
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
|
||||
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
|
||||
{f.matched_album_id ? (
|
||||
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
|
||||
) : (
|
||||
@@ -302,6 +330,9 @@ function QueueRow({
|
||||
)}
|
||||
</div>
|
||||
<div className={u.rowActions}>
|
||||
{f.stream_url && (
|
||||
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
|
||||
)}
|
||||
{editable && (
|
||||
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
||||
<Icon src={editIcon} size={18} />
|
||||
@@ -319,6 +350,75 @@ function QueueRow({
|
||||
|
||||
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
|
||||
|
||||
function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const [artist, setArtist] = useState('');
|
||||
const [album, setAlbum] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [cover, setCover] = useState<string | null | undefined>(undefined);
|
||||
const save = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', {
|
||||
params: { path: { id: batchId } },
|
||||
body: {
|
||||
...(artist.trim() ? { artist: artist.trim() } : {}),
|
||||
...(album.trim() ? { album: album.trim() } : {}),
|
||||
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
|
||||
...(cover !== undefined ? { cover_base64: cover } : {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
const pickCover = (file: File | undefined) => {
|
||||
if (!file) {
|
||||
setCover(undefined);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const data = String(reader.result ?? '');
|
||||
const comma = data.indexOf(',');
|
||||
setCover(comma >= 0 ? data.slice(comma + 1) : data);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
title={t('uploads.batch_edit_title')}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('uploads.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={s.sessions}>
|
||||
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
|
||||
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
|
||||
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
|
||||
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.batch_cover')}</span>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
|
||||
</label>
|
||||
<Button variant="ghost" onClick={() => setCover(null)}>
|
||||
{t('uploads.batch_cover_clear')}
|
||||
</Button>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
@@ -452,7 +552,7 @@ export function UploadSettingsSection() {
|
||||
queryKey: ['sources'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
||||
});
|
||||
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null);
|
||||
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
if (settings.data) setForm(toForm(settings.data));
|
||||
}, [settings.data]);
|
||||
@@ -468,6 +568,7 @@ export function UploadSettingsSection() {
|
||||
max_file_bytes: gb(f.maxFile),
|
||||
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
|
||||
default_source_id: f.source || null,
|
||||
allow_quality_upgrade: f.upgrade,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -484,7 +585,7 @@ export function UploadSettingsSection() {
|
||||
return (
|
||||
<section className={s.section}>
|
||||
<h2>{t('uploads.settings_title')}</h2>
|
||||
<div className={u.grid}>
|
||||
<div className={u.settingsGrid}>
|
||||
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
|
||||
<Field
|
||||
label={t('uploads.set_per_user')}
|
||||
@@ -494,8 +595,8 @@ export function UploadSettingsSection() {
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.set_source')}</span>
|
||||
<label>
|
||||
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
|
||||
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
|
||||
<option value="">{t('uploads.set_source_auto')}</option>
|
||||
{sources.data
|
||||
@@ -507,6 +608,12 @@ export function UploadSettingsSection() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
<label className={u.settingsCheck}>
|
||||
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
|
||||
<span>{t('uploads.set_upgrade')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className={s.note}>{t('uploads.set_note')}</p>
|
||||
<div className={s.toolbar}>
|
||||
@@ -528,5 +635,6 @@ function toForm(st: Settings) {
|
||||
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
|
||||
maxFile: gb(st.max_file_bytes),
|
||||
source: st.default_source_id ?? '',
|
||||
upgrade: st.allow_quality_upgrade,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useSession, useUser } from '../../app/session';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatRelative } from '../../shared/lib/format';
|
||||
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons';
|
||||
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
|
||||
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
|
||||
@@ -16,7 +16,9 @@ type AdminUser = components['schemas']['AdminUser'];
|
||||
|
||||
const ROLES: Role[] = ['admin', 'moderator', 'user'];
|
||||
|
||||
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser };
|
||||
const TITLES = { reset: 'reset_password', recs: 'reset_recommendations', block: 'block', delete: 'delete' } as const;
|
||||
|
||||
type Confirm = { kind: 'reset' | 'recs' | 'block' | 'delete'; user: AdminUser };
|
||||
|
||||
function useDebounced<T>(value: T, ms: number): T {
|
||||
const [v, setV] = useState(value);
|
||||
@@ -44,9 +46,11 @@ export function UsersPage() {
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
const query = useDebounced(q.trim(), 200);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState<AdminUser | null>(null);
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
const [recsDone, setRecsDone] = useState<string | null>(null);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['admin-users', query, role],
|
||||
@@ -79,6 +83,14 @@ export function UsersPage() {
|
||||
},
|
||||
onError: setActionError,
|
||||
});
|
||||
const resetRecs = useMutation({
|
||||
mutationFn: async (u: AdminUser) =>
|
||||
unwrap(
|
||||
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: u.id } } }),
|
||||
),
|
||||
onSuccess: (_, u) => setRecsDone(u.username),
|
||||
onError: setActionError,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: async (u: AdminUser) =>
|
||||
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
|
||||
@@ -91,7 +103,9 @@ export function UsersPage() {
|
||||
const { kind, user } = confirm;
|
||||
setConfirm(null);
|
||||
setActionError(null);
|
||||
setRecsDone(null);
|
||||
if (kind === 'reset') reset.mutate(user);
|
||||
if (kind === 'recs') resetRecs.mutate(user);
|
||||
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
|
||||
if (kind === 'delete') remove.mutate(user);
|
||||
};
|
||||
@@ -99,8 +113,16 @@ export function UsersPage() {
|
||||
const actionsFor = (u: AdminUser): MenuItem[] => {
|
||||
const items: MenuItem[] = [];
|
||||
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
||||
if (me.role === 'admin')
|
||||
items.push({ label: t('users.change_username'), onSelect: () => setRenaming(u) });
|
||||
if (can('reset_password'))
|
||||
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
|
||||
if (can('reset_recommendations'))
|
||||
items.push({
|
||||
label: t('users.reset_recommendations'),
|
||||
icon: refreshIcon,
|
||||
onSelect: () => setConfirm({ kind: 'recs', user: u }),
|
||||
});
|
||||
if (can('block'))
|
||||
items.push(
|
||||
u.is_blocked
|
||||
@@ -113,6 +135,11 @@ export function UsersPage() {
|
||||
label: t('users.make_role', { role: t(`common:roles.${r}`) }),
|
||||
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
|
||||
});
|
||||
if (me.role === 'admin')
|
||||
items.push({
|
||||
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
|
||||
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
|
||||
});
|
||||
if (can('delete'))
|
||||
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
|
||||
return items;
|
||||
@@ -154,9 +181,10 @@ export function UsersPage() {
|
||||
</div>
|
||||
|
||||
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
|
||||
{recsDone && <p className={s.ok}>{t('users.recs_done', { username: recsDone })}</p>}
|
||||
|
||||
<div className={s.tableWrap}>
|
||||
<table className={s.table}>
|
||||
<table className={cx(s.table, s.mobileCards)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('users.col_user')}</th>
|
||||
@@ -172,7 +200,7 @@ export function UsersPage() {
|
||||
const st = status(u);
|
||||
return (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<td data-label={t('users.col_user')}>
|
||||
<div className={s.userCell}>
|
||||
<Avatar name={u.display_name ?? u.username} />
|
||||
<div>
|
||||
@@ -184,16 +212,17 @@ export function UsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label={t('users.col_role')}>
|
||||
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
|
||||
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label={t('users.col_status')}>
|
||||
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
|
||||
</td>
|
||||
<td className={s.muted}>
|
||||
<td className={s.muted} data-label={t('users.col_last_login')}>
|
||||
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
|
||||
</td>
|
||||
<td>
|
||||
<td className={s.cardAction}>
|
||||
{items.length > 0 && (
|
||||
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
|
||||
)}
|
||||
@@ -224,16 +253,28 @@ export function UsersPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{renaming && (
|
||||
<ChangeUsernameDialog
|
||||
user={renaming}
|
||||
onClose={() => setRenaming(null)}
|
||||
onSaved={async (updated) => {
|
||||
if (updated.id === me.id) session.setUser({ ...me, username: updated.username });
|
||||
setRenaming(null);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirm && (
|
||||
<Dialog
|
||||
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : confirm.kind}`)}
|
||||
title={t(`users.${TITLES[confirm.kind]}`)}
|
||||
onClose={() => setConfirm(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirm(null)}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant={confirm.kind === 'reset' ? 'primary' : 'danger'} onClick={runConfirm}>
|
||||
<Button variant={confirm.kind === 'block' || confirm.kind === 'delete' ? 'danger' : 'primary'} onClick={runConfirm}>
|
||||
{t('users.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
@@ -265,12 +306,18 @@ function CreateUserDialog({
|
||||
const [username, setUsername] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [role, setRole] = useState<Role>('user');
|
||||
const [reviews, setReviews] = useState(false);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.POST('/api/v1/admin/users', {
|
||||
body: { username: username.trim(), display_name: name.trim() || undefined, role },
|
||||
body: {
|
||||
username: username.trim(),
|
||||
display_name: name.trim() || undefined,
|
||||
role,
|
||||
reviews_uploads: actorRole === 'admin' && reviews ? true : undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
|
||||
@@ -322,6 +369,13 @@ function CreateUserDialog({
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{actorRole === 'admin' && (
|
||||
<label className={s.sessions} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={reviews} onChange={(e) => setReviews(e.target.checked)} />
|
||||
<span>{t('create.review_uploads')}</span>
|
||||
</label>
|
||||
)}
|
||||
{actorRole === 'admin' && <p className={s.note}>{t('create.review_uploads_hint')}</p>}
|
||||
<p className={s.note}>{t('create.password_note')}</p>
|
||||
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
|
||||
</form>
|
||||
@@ -329,6 +383,40 @@ function CreateUserDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeUsernameDialog({ user, onClose, onSaved }: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
onSaved: (user: AdminUser) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const fieldError = useFieldError();
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', {
|
||||
params: { path: { id: user.id } }, body: { username: username.trim() },
|
||||
})),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
const error = fieldError(save.error, 'username');
|
||||
return (
|
||||
<Dialog title={t('users.change_username')} onClose={onClose} footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common:form.cancel')}</Button>
|
||||
<Button type="submit" form="change-username" variant="primary" busy={save.isPending}
|
||||
disabled={!username.trim() || username.trim() === user.username}>{t('common:settings.save')}</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="change-username" className={s.sessions} noValidate onSubmit={(e) => { e.preventDefault(); save.mutate(); }}>
|
||||
<Field label={t('create.username')} value={username} onChange={setUsername} autoCapitalize="none"
|
||||
spellCheck={false} hint={t('create.username_hint')} error={error} mono />
|
||||
<FormError>{save.error && !error ? errorText(save.error) : null}</FormError>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -118,6 +118,20 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.retryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.errorDetail {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text3);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
@@ -306,3 +320,14 @@
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card { padding: 16px; }
|
||||
.cardActions > button { flex: 1 1 auto; }
|
||||
.retryRow { flex-wrap: wrap; gap: 10px; }
|
||||
.patternRow { grid-template-columns: 20px minmax(0, 1fr); }
|
||||
.patternTools { grid-column: 2; margin-top: 0 !important; height: auto; flex-wrap: wrap; }
|
||||
.patternTools .check { flex-basis: 100%; min-height: 44px; }
|
||||
.patternTools > button { width: 44px; min-width: 44px; padding: 0; }
|
||||
.dir { min-height: 44px; height: auto; padding: 8px; overflow-wrap: anywhere; }
|
||||
}
|
||||
@@ -161,6 +161,117 @@
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
/* Страница приложений (/download). */
|
||||
.download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.download:hover {
|
||||
background: var(--accent-fill2);
|
||||
}
|
||||
|
||||
.download:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.downloadMeta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.steps {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.server {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details code {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
word-break: break-all;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.otherClients {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.otherTitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.downloadAlt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.downloadAlt span {
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -218,3 +218,10 @@
|
||||
height: 44px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.heroText { min-width: 0; }
|
||||
.colTitle { font-size: clamp(24px, 7vw, 34px); overflow-wrap: anywhere; }
|
||||
.underlineTabs { overflow-x: auto; }
|
||||
.underlineTab { flex-shrink: 0; min-height: 44px; }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
.home {
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: clip;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -7,6 +9,18 @@
|
||||
gap: 28px;
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
user-select: none; /* главная — только элементы управления волной */
|
||||
}
|
||||
|
||||
/* Плеер на всю ширину окна, а главная — в колонке справа от сайдбара.
|
||||
Сдвиг на половину сайдбара ставит волну на ту же ось, что и кнопка play.
|
||||
Если чипам не хватает места, сдвиг меньше, чтобы они не уехали под меню. */
|
||||
@media (min-width: 768px) {
|
||||
.home {
|
||||
transform: translateX(
|
||||
calc(-1 * min(var(--side-w, 240px) / 2, max(0px, (100% - 760px) / 2)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.orbLabel {
|
||||
@@ -86,7 +100,7 @@
|
||||
|
||||
.chipOn,
|
||||
.chipOn:hover {
|
||||
background: var(--accent);
|
||||
background: var(--accent-fill);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow);
|
||||
@@ -95,3 +109,9 @@
|
||||
.chipOn > span {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.home { justify-content: flex-start; gap: 20px; padding: 8px 0 20px; }
|
||||
.chip { min-height: 44px; height: auto; padding: 10px 14px; }
|
||||
.orbLabel { font-size: clamp(18px, 5vw, 22px); }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: 0;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.artists {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 18px 12px;
|
||||
}
|
||||
|
||||
.artist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 6px;
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.artist:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
box-shadow 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.on .avatar {
|
||||
box-shadow:
|
||||
0 0 0 3px var(--bg),
|
||||
0 0 0 5px var(--accent);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.check {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.genres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.genre {
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface2);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.genre[aria-pressed='true'] {
|
||||
background: var(--accent-fill);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow);
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: sticky;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(28px) saturate(1.3);
|
||||
}
|
||||
@@ -341,3 +341,29 @@
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page, .section, .head > *, .toolbar > *, .settingRow > * { min-width: 0; max-width: 100%; }
|
||||
.page { gap: 20px; overflow-wrap: anywhere; }
|
||||
.head h1 { font-size: 24px; }
|
||||
.section { padding: 16px; }
|
||||
.row > :first-child, .search { min-width: 0; flex-basis: 100%; max-width: none; }
|
||||
.settingRow { align-items: flex-start; gap: 10px; }
|
||||
.settingRow > :first-child { flex-basis: 100%; }
|
||||
.actions { flex-wrap: wrap; justify-content: flex-start; }
|
||||
.actions > * { flex: 1 1 auto; }
|
||||
.chip { min-height: 44px; height: auto; padding: 8px 14px; }
|
||||
.select { max-width: 100%; min-width: 0; font-size: 16px; }
|
||||
.session { flex-wrap: wrap; overflow-wrap: anywhere; }
|
||||
.tableWrap { min-width: 0; max-width: 100%; }
|
||||
.mobileCards, .mobileCards tbody { display: block; }
|
||||
.mobileCards thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
.mobileCards tr { display: flex; flex-direction: column; padding: 14px; border-bottom: 1px solid var(--line); gap: 12px; }
|
||||
.mobileCards tr:last-child { border-bottom: 0; }
|
||||
.mobileCards td { display: flex; flex-wrap: wrap; gap: 6px 10px; height: auto; padding: 0; border: 0; white-space: normal; }
|
||||
.mobileCards td::before { content: attr(data-label); flex-basis: 100%; font-size: 12px; color: var(--text3); }
|
||||
.mobileCards .userCell { min-width: 0; }
|
||||
.mobileCards .userCell > div { min-width: 0; overflow-wrap: anywhere; }
|
||||
.mobileCards .cardAction { align-self: flex-end; }
|
||||
.cardAction::before { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
|
||||
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
|
||||
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
|
||||
.card strong { overflow-wrap: anywhere; }
|
||||
.card span { font-size: 13px; }
|
||||
.card:hover { background: #ffffff10; }
|
||||
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.actions { display: flex; align-items: center; }
|
||||
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
|
||||
.actions button:disabled { opacity: .3; cursor: default; }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
|
||||
@@ -1,7 +1,7 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
@@ -31,6 +31,12 @@ export function MyUploadsPage() {
|
||||
const errorText = useErrorText();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
useEffect(() => {
|
||||
void session.client.POST('/api/v1/uploads/seen').then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['uploads-unseen'] });
|
||||
});
|
||||
}, [session, queryClient]);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['my-uploads', filter],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore, type DragEvent } fro
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { AUDIO_EXTENSIONS, fromDrop, fromInput } from '../../features/uploads/files';
|
||||
import { AUDIO_EXTENSIONS, fromDrop, fromInput, needsFormatAttention } from '../../features/uploads/files';
|
||||
import { uploaderFor, type UploadItem } from '../../features/uploads/uploader';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { formatBytes } from '../../shared/lib/format';
|
||||
@@ -39,9 +39,10 @@ export function SuggestPage() {
|
||||
}, []);
|
||||
|
||||
const ready = st.items.filter((it) => it.stage === 'ready');
|
||||
const readyBytes = ready.reduce((n, it) => n + it.file.size, 0);
|
||||
const readyBytes = ready.reduce((n, it) => n + it.size, 0);
|
||||
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
|
||||
const finished = !st.busy && st.batchId !== null;
|
||||
const resume = st.items.filter((it) => it.stage === 'resume').length;
|
||||
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
|
||||
const uploaded = st.items.filter((it) => it.stage === 'done').length;
|
||||
const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null;
|
||||
@@ -80,7 +81,8 @@ export function SuggestPage() {
|
||||
>
|
||||
<Icon src={musicIcon} size={36} />
|
||||
<div className={u.dropTitle}>{t('uploads.drop_title')}</div>
|
||||
<div className={s.note}>{t('uploads.drop_hint', { formats: AUDIO_EXTENSIONS.join(', ') })}</div>
|
||||
<p className={u.formatWarn}>{t('uploads.drop_hint')}</p>
|
||||
<div className={s.note}>{t('uploads.drop_folders')}</div>
|
||||
<div className={u.dropActions}>
|
||||
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
|
||||
{t('uploads.pick_files')}
|
||||
@@ -96,7 +98,9 @@ export function SuggestPage() {
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
onChange={(e) => {
|
||||
uploader.add(fromInput(e.target.files));
|
||||
const picked = fromInput(e.target.files);
|
||||
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
|
||||
else uploader.add(picked);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
@@ -106,12 +110,20 @@ export function SuggestPage() {
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
uploader.add(fromInput(e.target.files));
|
||||
const picked = fromInput(e.target.files);
|
||||
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
|
||||
else uploader.add(picked);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{resume > 0 && (
|
||||
<section className={s.section}>
|
||||
<p>{t('uploads.resume_hint', { count: resume })}</p>
|
||||
<Button onClick={() => filesRef.current?.click()}>{t('uploads.resume_pick')}</Button>
|
||||
</section>
|
||||
)}
|
||||
{st.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>}
|
||||
|
||||
{st.items.length > 0 && (
|
||||
@@ -213,9 +225,10 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
|
||||
<span style={{ width: `${item.progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{needsFormatAttention(null, item.relPath) && <span className={u.warn}>{t('uploads.format_attention')}</span>}
|
||||
{note && <span className={note.cls}>{note.text}</span>}
|
||||
</div>
|
||||
<span className={u.size}>{formatBytes(item.file.size, i18n.language)}</span>
|
||||
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
|
||||
{locked ? (
|
||||
<span />
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -47,18 +48,19 @@ export function useUploadQuota() {
|
||||
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}>
|
||||
<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-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||
<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>
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.formatWarn {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.dropActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -230,6 +239,48 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Настройки очереди: подпись, поле и подсказка каждой ячейки — в общих строках, чтобы поля стояли на одном
|
||||
уровне, даже когда подпись переносится. */
|
||||
.settingsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
column-gap: 12px;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.settingsGrid > * {
|
||||
display: grid;
|
||||
grid-row: span 3;
|
||||
grid-template-rows: subgrid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settingsGrid select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settingsGrid > * > :first-child {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.settingsLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.settingsCheck {
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
@@ -269,3 +320,22 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.queueRow { grid-template-columns: 24px minmax(0, 1fr); gap: 10px; padding: 12px; }
|
||||
.queueRow > :nth-child(3), .queueRow > .rowActions { grid-column: 2; }
|
||||
.rowActions { flex-wrap: wrap; min-width: 0; }
|
||||
.rowActions audio { width: 100%; min-width: 0; max-width: 100%; }
|
||||
.meta { overflow-wrap: anywhere; }
|
||||
.bulk { bottom: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.bulk > span:nth-child(2) { display: none; }
|
||||
.bulk > button { min-width: 0; font-size: 13px; padding: 10px 8px; }
|
||||
.grid, .settingsGrid { grid-template-columns: minmax(0, 1fr); }
|
||||
.drop { padding: 20px 12px; }
|
||||
.dropActions > * { flex: 1 1 auto; }
|
||||
.quota { min-width: 0; max-width: 100%; }
|
||||
.file { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.file > :last-child { grid-column: 2; grid-row: 1; }
|
||||
.file > .size { grid-column: 1; }
|
||||
.textarea { font-size: 16px; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AuthSession } from '@pipu/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Player } from './player';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function fixture() {
|
||||
const player = new Player({} as AuthSession, document.createElement('audio'));
|
||||
const sample = vi.fn((data: Uint8Array) => {
|
||||
data.fill(0);
|
||||
// 48 kHz / 1024: bins 1..4 cover the bass band.
|
||||
data.fill(255, 1, 5);
|
||||
});
|
||||
Object.assign(player, {
|
||||
state: { ...player.getState(), playing: true },
|
||||
analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample },
|
||||
});
|
||||
return { player, sample };
|
||||
}
|
||||
|
||||
describe('audio reactivity', () => {
|
||||
it('separates bass from the other frequency bands and shares the frame', () => {
|
||||
vi.spyOn(performance, 'now').mockReturnValue(100);
|
||||
const { player, sample } = fixture();
|
||||
expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 });
|
||||
player.readAudio();
|
||||
expect(sample).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns silence after pausing or muting', () => {
|
||||
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
|
||||
const { player, sample } = fixture();
|
||||
player.readAudio();
|
||||
Object.assign(player, { state: { ...player.getState(), playing: false } });
|
||||
clock.mockReturnValue(120);
|
||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } });
|
||||
clock.mockReturnValue(140);
|
||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
expect(sample).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works when Web Audio is unavailable', () => {
|
||||
const player = new Player({} as AuthSession, document.createElement('audio'));
|
||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
});
|
||||
});
|
||||
+256
-17
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
EventBuffer,
|
||||
normalizeGain,
|
||||
PlayAttempt,
|
||||
PlayQueue,
|
||||
unwrap,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
type Track,
|
||||
} from '@pipu/core';
|
||||
|
||||
import { desktopHandle } from '../desktop';
|
||||
|
||||
export type { PlayContext, Track };
|
||||
|
||||
export interface WaveInfo {
|
||||
@@ -80,6 +83,9 @@ function deviceId(): string {
|
||||
*/
|
||||
export class Player {
|
||||
private readonly audio: HTMLAudioElement;
|
||||
private readonly overlap: HTMLAudioElement;
|
||||
private fading = false;
|
||||
private ignoreEnded = false;
|
||||
private readonly queue = new PlayQueue();
|
||||
private readonly events: EventBuffer;
|
||||
private attempt: PlayAttempt | null = null;
|
||||
@@ -93,6 +99,7 @@ export class Player {
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||
private waveLoading = false;
|
||||
private restoreVersion = 0;
|
||||
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||
onReaction: (() => void) | null = null;
|
||||
|
||||
@@ -102,6 +109,8 @@ export class Player {
|
||||
) {
|
||||
this.audio = audio ?? new Audio();
|
||||
this.audio.preload = 'auto';
|
||||
this.overlap = new Audio();
|
||||
this.overlap.preload = 'auto';
|
||||
this.events = new EventBuffer({
|
||||
send: async (events) => {
|
||||
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||
@@ -114,6 +123,7 @@ export class Player {
|
||||
};
|
||||
this.bindAudio();
|
||||
this.bindMediaSession();
|
||||
this.bindDesktopMedia();
|
||||
}
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
@@ -130,9 +140,13 @@ export class Player {
|
||||
|
||||
getState = () => this.state;
|
||||
|
||||
private lastDesktop: { id?: string; playing?: boolean } = {};
|
||||
private desktopUnlisten: (() => void) | null = null;
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
for (const fn of this.listeners) fn();
|
||||
this.syncDesktopMedia();
|
||||
}
|
||||
|
||||
private syncQueue() {
|
||||
@@ -153,6 +167,7 @@ export class Player {
|
||||
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||
const target = tracks[index];
|
||||
if (!target || !playable.length) return;
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
this.context = context;
|
||||
@@ -165,6 +180,7 @@ export class Player {
|
||||
|
||||
/** Запустить волну (или перезапустить с новым контекстом). */
|
||||
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
const res = unwrap(
|
||||
@@ -186,7 +202,10 @@ export class Player {
|
||||
void this.load(true);
|
||||
}
|
||||
|
||||
/** Сменить занятие / настроение на лету: текущий трек доигрывает, дальше — новая пачка. */
|
||||
/**
|
||||
* Сменить занятие / настроение на лету: сразу играет первый трек новой пачки (docs/recommendations.md#6.7).
|
||||
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
|
||||
*/
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
const wave = this.state.wave;
|
||||
if (!wave) return this.startWave(activity, mood);
|
||||
@@ -197,10 +216,17 @@ export class Player {
|
||||
body: { activity, mood },
|
||||
}),
|
||||
);
|
||||
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
|
||||
this.queue.dropUpcoming();
|
||||
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
|
||||
this.queue.append(fresh);
|
||||
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||
this.syncQueue();
|
||||
if (fresh.length && this.queue.next()) {
|
||||
this.finishAttempt('skip', true);
|
||||
this.syncQueue();
|
||||
void this.load(true);
|
||||
} else {
|
||||
this.syncQueue();
|
||||
}
|
||||
}
|
||||
|
||||
stopWave() {
|
||||
@@ -259,10 +285,11 @@ export class Player {
|
||||
|
||||
play() {
|
||||
if (!this.queue.current) return;
|
||||
if (!this.audio.src) {
|
||||
if (!this.audio.src || !this.attempt) {
|
||||
void this.load(true, this.state.positionMs);
|
||||
return;
|
||||
}
|
||||
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
|
||||
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
|
||||
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
|
||||
}
|
||||
@@ -314,15 +341,17 @@ export class Player {
|
||||
}
|
||||
|
||||
setVolume(v: number) {
|
||||
this.audio.volume = Math.min(Math.max(v, 0), 1);
|
||||
const volume = Math.min(Math.max(v, 0), 1);
|
||||
this.audio.muted = false;
|
||||
this.update({ volume: this.audio.volume, muted: false });
|
||||
this.update({ volume, muted: false });
|
||||
this.applyLoudness();
|
||||
this.saveLocal();
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.audio.muted = !this.audio.muted;
|
||||
this.update({ muted: this.audio.muted });
|
||||
this.applyLoudness();
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
@@ -369,9 +398,13 @@ export class Player {
|
||||
private async load(autoplay: boolean, startMs = 0) {
|
||||
const track = this.queue.current;
|
||||
if (!track?.stream_url) return;
|
||||
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||
this.audio.src = track.stream_url;
|
||||
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
||||
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||
// Keep the restored element's buffer when the user presses Play.
|
||||
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
|
||||
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
||||
this.updateMediaMetadata(track);
|
||||
if (!autoplay) return;
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(track, startMs);
|
||||
this.preloaded = null;
|
||||
this.events.push({
|
||||
@@ -381,11 +414,11 @@ export class Player {
|
||||
context_type: this.context?.type,
|
||||
context_id: this.context?.id,
|
||||
});
|
||||
this.updateMediaMetadata(track);
|
||||
this.saveServer();
|
||||
void this.refillWave();
|
||||
if (autoplay) {
|
||||
try {
|
||||
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
|
||||
await this.audio.play();
|
||||
} catch {
|
||||
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||
@@ -395,11 +428,19 @@ export class Player {
|
||||
}
|
||||
|
||||
/** Завершает текущую попытку: событие с реально прослушанным временем. */
|
||||
private finishAttempt(type: 'play_end' | 'skip') {
|
||||
/** neutral — трек прерван сменой контекста волны: не отрицательный сигнал и без короткого «скипа» в событиях. */
|
||||
private finishAttempt(type: 'play_end' | 'skip', neutral = false) {
|
||||
const a = this.attempt;
|
||||
if (!a) return;
|
||||
this.attempt = null;
|
||||
const pos = Math.round(this.audio.currentTime * 1000);
|
||||
if (neutral) {
|
||||
if (a.playedMs >= EARLY_SKIP_MS) {
|
||||
this.events.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||
void this.events.flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
|
||||
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||
void this.events.flush();
|
||||
@@ -411,6 +452,151 @@ export class Player {
|
||||
}
|
||||
}
|
||||
|
||||
private prefs() {
|
||||
const user = this.session.state.status === 'signed_in' ? this.session.state.user : null;
|
||||
return user?.ui_prefs;
|
||||
}
|
||||
|
||||
private normalizeOn() {
|
||||
return this.prefs()?.normalize !== false;
|
||||
}
|
||||
|
||||
private crossfadeSeconds() {
|
||||
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
|
||||
}
|
||||
|
||||
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
|
||||
private gain: GainNode | null = null;
|
||||
private wired = false;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private audioContext: AudioContext | null = null;
|
||||
private spectrum = new Uint8Array(512);
|
||||
private audioFrame = { bass: 0, mid: 0, treble: 0 };
|
||||
private sampledAt = -Infinity;
|
||||
|
||||
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
|
||||
readAudio = () => {
|
||||
const now = performance.now();
|
||||
if (now - this.sampledAt < 12) return this.audioFrame;
|
||||
this.sampledAt = now;
|
||||
const active = this.state.playing && !this.state.loading && !this.state.muted;
|
||||
const analyser = this.analyser;
|
||||
if (!active || !analyser) {
|
||||
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
|
||||
return this.audioFrame;
|
||||
}
|
||||
analyser.getByteFrequencyData(this.spectrum);
|
||||
const band = (low: number, high: number) => {
|
||||
const hz = analyser.context.sampleRate / analyser.fftSize;
|
||||
const start = Math.max(1, Math.ceil(low / hz));
|
||||
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
|
||||
let sum = 0;
|
||||
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
|
||||
return Math.sqrt(sum / Math.max(1, end - start));
|
||||
};
|
||||
this.audioFrame.bass = band(40, 220);
|
||||
this.audioFrame.mid = band(220, 2500);
|
||||
this.audioFrame.treble = band(2500, 10000);
|
||||
return this.audioFrame;
|
||||
};
|
||||
|
||||
|
||||
private applyLoudness(mix = 1) {
|
||||
const track = this.state.track;
|
||||
const user = this.state.muted ? 0 : this.state.volume;
|
||||
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
|
||||
if (!this.wired && typeof AudioContext !== 'undefined') {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
this.audioContext = ctx;
|
||||
const src = ctx.createMediaElementSource(this.audio);
|
||||
this.gain = ctx.createGain();
|
||||
src.connect(this.gain);
|
||||
this.gain.connect(ctx.destination);
|
||||
this.analyser = ctx.createAnalyser();
|
||||
this.analyser.fftSize = 1024;
|
||||
this.analyser.smoothingTimeConstant = 0.55;
|
||||
this.gain.connect(this.analyser);
|
||||
// The overlap is already audible through its own output; analyse it only.
|
||||
try {
|
||||
const overlapSource = ctx.createMediaElementSource(this.overlap);
|
||||
overlapSource.connect(ctx.destination);
|
||||
overlapSource.connect(this.analyser);
|
||||
} catch { /* Crossfade analysis is optional. */ }
|
||||
|
||||
this.audio.volume = 1;
|
||||
this.wired = true;
|
||||
} catch {
|
||||
this.wired = true;
|
||||
}
|
||||
}
|
||||
if (this.gain) this.gain.gain.value = level;
|
||||
else this.audio.volume = Math.min(1, level);
|
||||
}
|
||||
|
||||
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
||||
private maybeCrossfade(positionMs: number) {
|
||||
const cf = this.crossfadeSeconds();
|
||||
if (this.fading || cf <= 0 || this.state.repeat === 'one') return;
|
||||
const dur = this.audio.duration;
|
||||
if (!Number.isFinite(dur)) return;
|
||||
const left = dur * 1000 - positionMs;
|
||||
if (left <= 0 || left > cf * 1000) return;
|
||||
const next = this.state.queue[this.state.index + 1];
|
||||
if (!next?.stream_url || !next.available) return;
|
||||
this.fading = true;
|
||||
this.overlap.src = next.stream_url;
|
||||
this.overlap.volume = 0;
|
||||
void this.overlap.play().catch(() => {
|
||||
this.fading = false;
|
||||
});
|
||||
const started = performance.now();
|
||||
const step = () => {
|
||||
if (!this.fading) return;
|
||||
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
|
||||
this.applyLoudness(1 - p);
|
||||
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
|
||||
if (p < 1) {
|
||||
requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
this.finishCrossfade(next);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
private finishCrossfade(next: Track) {
|
||||
const at = this.overlap.currentTime;
|
||||
this.ignoreEnded = true;
|
||||
this.audio.pause();
|
||||
this.overlap.pause();
|
||||
this.finishAttempt('play_end');
|
||||
if (!this.queue.next(true)) {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
this.update({ playing: false, positionMs: 0 });
|
||||
return;
|
||||
}
|
||||
this.syncQueue();
|
||||
const src = next.stream_url;
|
||||
if (!src) {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
this.update({ playing: false });
|
||||
return;
|
||||
}
|
||||
this.audio.src = src;
|
||||
if (at > 0) this.audio.currentTime = at;
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
|
||||
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
|
||||
this.updateMediaMetadata(next);
|
||||
void this.audio.play().finally(() => {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
});
|
||||
}
|
||||
|
||||
private bindAudio() {
|
||||
const a = this.audio;
|
||||
a.addEventListener('playing', () => {
|
||||
@@ -423,20 +609,28 @@ export class Player {
|
||||
if (a.ended) return;
|
||||
this.update({ playing: false });
|
||||
const t = this.queue.current;
|
||||
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||
this.saveServer();
|
||||
if (t && this.attempt) {
|
||||
this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||
this.saveServer();
|
||||
}
|
||||
});
|
||||
a.addEventListener('waiting', () => {
|
||||
if (this.attempt) this.update({ loading: true });
|
||||
});
|
||||
a.addEventListener('waiting', () => this.update({ loading: true }));
|
||||
a.addEventListener('timeupdate', () => {
|
||||
const ms = Math.round(a.currentTime * 1000);
|
||||
this.attempt?.tick(ms);
|
||||
this.update({ positionMs: ms });
|
||||
this.updatePositionState();
|
||||
this.maybePreload(ms);
|
||||
if (this.state.playing) {
|
||||
this.maybePreload(ms);
|
||||
this.maybeCrossfade(ms);
|
||||
}
|
||||
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||
this.saveLocal();
|
||||
});
|
||||
a.addEventListener('ended', () => {
|
||||
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
||||
this.finishAttempt('play_end');
|
||||
if (this.queue.next(true)) {
|
||||
this.syncQueue();
|
||||
@@ -447,18 +641,24 @@ export class Player {
|
||||
}
|
||||
});
|
||||
a.addEventListener('error', () => void this.onError());
|
||||
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
|
||||
}
|
||||
|
||||
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||
private async onError() {
|
||||
const track = this.queue.current;
|
||||
if (!track) return;
|
||||
// A speculative load must never start playback or skip the saved track.
|
||||
if (!this.attempt) {
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
return;
|
||||
}
|
||||
if (this.refreshedFor !== track.id) {
|
||||
this.refreshedFor = track.id;
|
||||
try {
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
||||
const fresh = res.items[0];
|
||||
if (this.queue.current !== track || !this.attempt) return;
|
||||
if (fresh?.stream_url) {
|
||||
Object.assign(track, fresh);
|
||||
void this.load(true, this.state.positionMs);
|
||||
@@ -485,6 +685,33 @@ export class Player {
|
||||
|
||||
// --- 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;
|
||||
@@ -600,6 +827,7 @@ export class Player {
|
||||
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
||||
*/
|
||||
async restore() {
|
||||
const version = ++this.restoreVersion;
|
||||
let local: Saved | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_KEY);
|
||||
@@ -629,8 +857,9 @@ export class Player {
|
||||
} catch {
|
||||
// офлайн — остаёмся с локальной копией
|
||||
}
|
||||
if (!saved?.queue.length) return;
|
||||
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||
if (version !== this.restoreVersion) return;
|
||||
const wanted = saved.queue[saved.index];
|
||||
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||
if (!tracks.length) return;
|
||||
@@ -644,14 +873,24 @@ export class Player {
|
||||
this.syncQueue();
|
||||
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||
this.updateMediaMetadata(tracks[idx]!);
|
||||
// Prepare at the saved position without creating a listening attempt.
|
||||
await this.load(false, pos);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.restoreVersion++;
|
||||
this.fading = false;
|
||||
this.finishAttempt('skip');
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.overlap.pause();
|
||||
this.overlap.removeAttribute('src');
|
||||
this.overlap.load();
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
this.timers = [];
|
||||
this.desktopUnlisten?.();
|
||||
this.desktopUnlisten = null;
|
||||
void this.events.flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { AuthSession, Track } from '@pipu/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Player } from './player';
|
||||
|
||||
const track: Track = {
|
||||
id: 'a', title: 'A', duration_ms: 200_000, artists: [],
|
||||
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [], disc_no: null, track_no: null, available: true,
|
||||
stream_url: '/s/a', reaction: null, features: null,
|
||||
};
|
||||
const ok = (data: unknown) => ({ data, response: new Response() });
|
||||
|
||||
function fixture() {
|
||||
localStorage.removeItem('pipu.player');
|
||||
const client = {
|
||||
GET: vi.fn().mockResolvedValue(ok({
|
||||
track_id: 'a', queue: ['a'], queue_index: 0, position_ms: 42_000,
|
||||
volume: 0.8, shuffle: false, repeat_mode: 'off', updated_at: '2026-10-21',
|
||||
})),
|
||||
POST: vi.fn().mockResolvedValue(ok({ items: [track] })),
|
||||
PUT: vi.fn().mockResolvedValue(ok({})),
|
||||
};
|
||||
const audio = document.createElement('audio');
|
||||
const player = new Player({ client, state: { status: 'signed_out' } } as unknown as AuthSession, audio);
|
||||
return { player, audio, client };
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
|
||||
|
||||
describe('restored audio preparation', () => {
|
||||
it('buffers at the saved position without playback or listening events, then reuses the source', async () => {
|
||||
const { player, audio, client } = fixture();
|
||||
const play = vi.spyOn(audio, 'play');
|
||||
const source = vi.spyOn(audio, 'src', 'set');
|
||||
await player.restore();
|
||||
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||
expect(audio.currentTime).toBe(42);
|
||||
expect(audio.preload).toBe('auto');
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(client.PUT).not.toHaveBeenCalled();
|
||||
expect(player.getState()).toMatchObject({ playing: false, loading: false, positionMs: 42_000 });
|
||||
player.onHide();
|
||||
expect(client.POST).toHaveBeenCalledTimes(1); // Only the track lookup, no listening events.
|
||||
player.play();
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
expect(source).toHaveBeenCalledTimes(1);
|
||||
expect(player.getState().playing).toBe(true);
|
||||
player.dispose();
|
||||
});
|
||||
|
||||
it('does not autoplay or skip after a preload failure; Play retries the source', async () => {
|
||||
const { player, audio } = fixture();
|
||||
const play = vi.spyOn(audio, 'play');
|
||||
await player.restore();
|
||||
audio.dispatchEvent(new Event('error'));
|
||||
audio.dispatchEvent(new Event('ended'));
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(player.getState()).toMatchObject({ track: { id: 'a' }, positionMs: 42_000, playing: false });
|
||||
player.play();
|
||||
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
player.dispose();
|
||||
});
|
||||
|
||||
it.each(['selection', 'dispose'])('ignores a restore lookup completed after %s', async (action) => {
|
||||
const { player, client, audio } = fixture();
|
||||
let resolve!: (value: unknown) => void;
|
||||
client.POST.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
const restoring = player.restore();
|
||||
await vi.waitFor(() => expect(client.POST).toHaveBeenCalled());
|
||||
if (action === 'selection') player.playList([{ ...track, id: 'b', stream_url: '/s/b' }], 0, { type: 'search' });
|
||||
else player.dispose();
|
||||
resolve(ok({ items: [track] }));
|
||||
await restoring;
|
||||
expect(audio.getAttribute('src')).toBe(action === 'selection' ? '/s/b' : null);
|
||||
if (action === 'selection') player.dispose();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 261 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user