Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72567acd28 | ||
|
|
39aaaed969 | ||
|
|
8eb6b36888 | ||
|
|
83b604437c | ||
|
|
73ad61e7a5 | ||
|
|
fb20258489 | ||
|
|
78c006184b | ||
|
|
d3116aba96 | ||
|
|
b7518aa450 | ||
|
|
eb8031b126 | ||
|
|
e4b841a8d8 | ||
|
|
597067689a | ||
|
|
efe78d7e07 |
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
|
||||||
|
# (Linux .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
|
||||||
@@ -57,5 +57,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium
|
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
|
||||||
|
- run: |
|
||||||
|
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
|
||||||
|
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
|
||||||
|
pnpm --filter @pipu/web exec playwright install chromium
|
||||||
- run: pnpm --filter @pipu/web test:e2e
|
- run: pnpm --filter @pipu/web test:e2e
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
|
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
|
||||||
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
|
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
||||||
# и переписывает статус: собран / не удалась (scripts/release.mjs).
|
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
||||||
|
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
||||||
|
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
|
||||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||||
#
|
#
|
||||||
# Секреты (Settings → Actions → Secrets):
|
# Секреты (Settings → Actions → Secrets):
|
||||||
@@ -132,9 +134,10 @@ jobs:
|
|||||||
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||||||
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||||||
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
||||||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
|
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
|
||||||
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
|
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
|
||||||
./gradlew --stop || true
|
./gradlew --stop || true
|
||||||
|
find ~/.gradle -name "*.lock" -delete || true
|
||||||
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||||||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||||
else
|
else
|
||||||
@@ -196,3 +199,142 @@ jobs:
|
|||||||
- name: Статус в релизе — сборка отменена
|
- name: Статус в релизе — сборка отменена
|
||||||
if: cancelled()
|
if: cancelled()
|
||||||
run: node scripts/release.mjs android cancelled
|
run: node scripts/release.mjs android cancelled
|
||||||
|
|
||||||
|
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
|
||||||
|
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
|
||||||
|
desktop:
|
||||||
|
name: desktop (Linux и Windows в релиз)
|
||||||
|
needs: [release, android]
|
||||||
|
if: always() && needs.release.result == 'success'
|
||||||
|
timeout-minutes: 90
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PIPU_VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
XWIN_CACHE_DIR: /opt/ci-cache/xwin
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Статус в релизе — собирается
|
||||||
|
run: |
|
||||||
|
node scripts/release.mjs desktop linux building
|
||||||
|
node scripts/release.mjs desktop windows building
|
||||||
|
- name: Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||||
|
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||||
|
libdbus-1-dev libsecret-1-dev patchelf \
|
||||||
|
nsis lld llvm clang
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
~/.cargo/bin/cargo-xwin
|
||||||
|
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
|
||||||
|
restore-keys: cargo-desktop-
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- name: Сборка deb
|
||||||
|
id: linux
|
||||||
|
working-directory: apps/desktop
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pnpm bundle:linux
|
||||||
|
bundle=src-tauri/target/release/bundle
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in "$bundle"/deb/*.deb; do
|
||||||
|
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
|
done
|
||||||
|
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
|
rm -rf src-tauri/target/release
|
||||||
|
- name: Linux в релиз
|
||||||
|
id: linux-upload
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
f="pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
|
if [ ! -f "$f" ]; then
|
||||||
|
echo "::error::нет $f после сборки"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
node scripts/release.mjs desktop linux success "$f"
|
||||||
|
- name: Windows toolchain
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rustup target add x86_64-pc-windows-msvc
|
||||||
|
if ! command -v cargo-xwin >/dev/null; then
|
||||||
|
cargo install --locked cargo-xwin
|
||||||
|
fi
|
||||||
|
if [ ! -d /opt/ci-cache ]; then
|
||||||
|
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
|
||||||
|
mkdir -p "$HOME/.cache/xwin"
|
||||||
|
else
|
||||||
|
mkdir -p /opt/ci-cache/xwin
|
||||||
|
fi
|
||||||
|
- name: Сборка NSIS
|
||||||
|
id: win
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
working-directory: apps/desktop
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pnpm bundle:windows:cross
|
||||||
|
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
|
||||||
|
shopt -s nullglob
|
||||||
|
copied=
|
||||||
|
for f in "$bundle"/*-setup.exe; do
|
||||||
|
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||||
|
copied=1
|
||||||
|
done
|
||||||
|
if [ -z "$copied" ]; then
|
||||||
|
echo "::error::нет NSIS setup.exe после сборки в $bundle"
|
||||||
|
ls -la "$bundle" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||||
|
- name: Windows в релиз
|
||||||
|
id: win-upload
|
||||||
|
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
|
||||||
|
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||||
|
- name: Статус Linux — сборка не удалась
|
||||||
|
if: failure() && steps.linux-upload.outcome != 'success'
|
||||||
|
run: node scripts/release.mjs desktop linux failure
|
||||||
|
- name: Статус Windows — сборка не удалась
|
||||||
|
if: failure() && steps.win-upload.outcome != 'success'
|
||||||
|
run: node scripts/release.mjs desktop windows failure
|
||||||
|
- name: Статус в релизе — сборка отменена
|
||||||
|
if: cancelled()
|
||||||
|
run: |
|
||||||
|
node scripts/release.mjs desktop linux cancelled
|
||||||
|
node scripts/release.mjs desktop windows cancelled
|
||||||
|
|
||||||
|
prune:
|
||||||
|
name: prune (старые файлы и прогоны)
|
||||||
|
needs: [release, android, desktop]
|
||||||
|
if: always() && needs.release.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "${{ env.NODE_VERSION }}" }
|
||||||
|
- name: Оставить 8 релизов и 8 прогонов
|
||||||
|
env:
|
||||||
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: node scripts/release.mjs prune
|
||||||
@@ -41,3 +41,6 @@ apps/desktop/src-tauri/target/
|
|||||||
apps/desktop/src-tauri/gen/
|
apps/desktop/src-tauri/gen/
|
||||||
apps/desktop/src-tauri/tauri.release.json
|
apps/desktop/src-tauri/tauri.release.json
|
||||||
apps/desktop/release/
|
apps/desktop/release/
|
||||||
|
|
||||||
|
# Intermediate platform icon renders (final assets live under apps/).
|
||||||
|
/artifacts/icon-build/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# pipu-music
|
# pipu-music
|
||||||
|
|
||||||
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
|
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
|
||||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||||
|
|
||||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||||
@@ -15,6 +15,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
|
|||||||
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
||||||
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
||||||
| `apps/web` | Веб-клиент (React + Vite) |
|
| `apps/web` | Веб-клиент (React + Vite) |
|
||||||
|
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
|
||||||
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
||||||
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
||||||
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
||||||
|
|||||||
@@ -22,6 +22,51 @@ tags:
|
|||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
paths:
|
||||||
|
/api/v1/admin/genius:
|
||||||
|
get:
|
||||||
|
operationId: getGeniusStats
|
||||||
|
tags: [admin]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Persistent enrichment statistics (admin only)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GeniusStats"
|
||||||
|
post:
|
||||||
|
operationId: controlGenius
|
||||||
|
tags: [admin]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [action]
|
||||||
|
properties:
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [start, pause, retry]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated statistics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GeniusStats"
|
||||||
|
/api/v1/tracks/{id}/genius:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/EntityId"
|
||||||
|
get:
|
||||||
|
operationId: getTrackGenius
|
||||||
|
tags: [catalog]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Cached lyrics and annotations; never makes an upstream request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GeniusContent"
|
||||||
/healthz:
|
/healthz:
|
||||||
get:
|
get:
|
||||||
operationId: getHealthz
|
operationId: getHealthz
|
||||||
@@ -1497,11 +1542,58 @@ paths:
|
|||||||
$ref: "#/components/schemas/WaveBatch"
|
$ref: "#/components/schemas/WaveBatch"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/app/clients:
|
||||||
|
get:
|
||||||
|
operationId: getAppClients
|
||||||
|
tags: [app]
|
||||||
|
security: [] # публично: страница /download без входа
|
||||||
|
summary: Клиенты последних релизов (Android и десктоп)
|
||||||
|
description: |
|
||||||
|
Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
|
||||||
|
У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
|
||||||
|
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Доступные сборки
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AppClients"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/app/download/{client}/{version}:
|
||||||
|
get:
|
||||||
|
operationId: downloadAppClient
|
||||||
|
tags: [app]
|
||||||
|
security: []
|
||||||
|
summary: Файл клиента (кэшируется на сервере, поддерживает Range)
|
||||||
|
parameters:
|
||||||
|
- name: client
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AppClient"
|
||||||
|
- name: version
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Установщик или APK
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
/api/v1/app/android/latest:
|
/api/v1/app/android/latest:
|
||||||
get:
|
get:
|
||||||
operationId: getAndroidLatest
|
operationId: getAndroidLatest
|
||||||
tags: [app]
|
tags: [app]
|
||||||
security: [] # публично: страница /android раздаёт приложение без входа
|
security: [] # публично: страница /download раздаёт приложение без входа
|
||||||
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||||
description: |
|
description: |
|
||||||
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
||||||
@@ -2970,10 +3062,51 @@ components:
|
|||||||
description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости
|
description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/components/schemas/TrackFeatures"
|
- $ref: "#/components/schemas/TrackFeatures"
|
||||||
|
GeniusStats:
|
||||||
|
type: object
|
||||||
|
required: [configured, paused, running, total, pending, matched, lyrics, annotations, images, unmatched, errors, last_error, cooldown_until]
|
||||||
|
properties:
|
||||||
|
configured: { type: boolean }
|
||||||
|
paused: { type: boolean }
|
||||||
|
running: { type: boolean }
|
||||||
|
total: { type: integer }
|
||||||
|
pending: { type: integer }
|
||||||
|
matched: { type: integer }
|
||||||
|
lyrics: { type: integer }
|
||||||
|
annotations: { type: integer }
|
||||||
|
images: { type: integer }
|
||||||
|
unmatched: { type: integer }
|
||||||
|
errors: { type: integer }
|
||||||
|
last_error: { type: string }
|
||||||
|
cooldown_until: { type: string, format: date-time }
|
||||||
|
GeniusContent:
|
||||||
|
type: object
|
||||||
|
required: [status, lyrics, source_url, song_image_url, artist_image_url, description, annotations]
|
||||||
|
properties:
|
||||||
|
status: { type: string }
|
||||||
|
lyrics: { type: string }
|
||||||
|
source_url: { type: string }
|
||||||
|
song_image_url: { type: string }
|
||||||
|
artist_image_url: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
annotations:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/GeniusAnnotation"
|
||||||
|
GeniusAnnotation:
|
||||||
|
type: object
|
||||||
|
required: [fragment, text, url, verified, authors]
|
||||||
|
properties:
|
||||||
|
fragment: { type: string }
|
||||||
|
text: { type: string }
|
||||||
|
url: { type: string }
|
||||||
|
verified: { type: boolean }
|
||||||
|
authors: { type: string }
|
||||||
Artist:
|
Artist:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name, album_count, track_count]
|
required: [id, name, album_count, track_count]
|
||||||
properties:
|
properties:
|
||||||
|
image_url: { type: string }
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
@@ -3446,6 +3579,59 @@ components:
|
|||||||
like_rate:
|
like_rate:
|
||||||
type: number
|
type: number
|
||||||
nullable: true
|
nullable: true
|
||||||
|
AppClient:
|
||||||
|
type: string
|
||||||
|
enum: [android, windows, linux, macos]
|
||||||
|
AppClientBuild:
|
||||||
|
type: object
|
||||||
|
required: [version, filename, size, download_url]
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
filename:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
sha256:
|
||||||
|
type: string
|
||||||
|
download_url:
|
||||||
|
type: string
|
||||||
|
AppClientFile:
|
||||||
|
type: object
|
||||||
|
required: [client, filename, size, download_url]
|
||||||
|
properties:
|
||||||
|
client:
|
||||||
|
$ref: "#/components/schemas/AppClient"
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
filename:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
sha256:
|
||||||
|
type: string
|
||||||
|
download_url:
|
||||||
|
type: string
|
||||||
|
older:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AppClientBuild"
|
||||||
|
AppClients:
|
||||||
|
type: object
|
||||||
|
required: [version, version_code, notes_md, clients]
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
version_code:
|
||||||
|
type: integer
|
||||||
|
notes_md:
|
||||||
|
type: string
|
||||||
|
clients:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AppClientFile"
|
||||||
AndroidRelease:
|
AndroidRelease:
|
||||||
type: object
|
type: object
|
||||||
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
||||||
|
|||||||
@@ -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 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": "node ../../scripts/generate-icons.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[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 = true
|
||||||
|
codegen-units = 1
|
||||||
|
opt-level = "s"
|
||||||
|
panic = "abort"
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 46 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": false }
|
||||||
|
},
|
||||||
|
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
name: 'Pipu Music',
|
name: 'Pipu Music',
|
||||||
slug: 'pipu-music',
|
slug: 'pipu-music',
|
||||||
scheme: 'pipu',
|
scheme: 'pipu',
|
||||||
|
icon: './assets/icon.png',
|
||||||
version,
|
version,
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
userInterfaceStyle: 'automatic',
|
userInterfaceStyle: 'automatic',
|
||||||
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
newArchEnabled: false,
|
newArchEnabled: false,
|
||||||
backgroundColor: '#05070D',
|
backgroundColor: '#05070D',
|
||||||
android: {
|
android: {
|
||||||
|
adaptiveIcon: {
|
||||||
|
foregroundImage: './assets/adaptive-icon.png',
|
||||||
|
monochromeImage: './assets/monochrome-icon.png',
|
||||||
|
backgroundColor: '#05070D',
|
||||||
|
},
|
||||||
package: 'app.pipu.music',
|
package: 'app.pipu.music',
|
||||||
versionCode,
|
versionCode,
|
||||||
edgeToEdgeEnabled: true,
|
edgeToEdgeEnabled: true,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -91,6 +91,12 @@ export default function PlayerScreen() {
|
|||||||
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
||||||
{track.artists.map((a) => a.name).join(', ')}
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
|
||||||
|
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
|
||||||
|
<Icon name="playlist-add" size={24} color={colors.text2} />
|
||||||
|
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ gap: 6 }}>
|
<View style={{ gap: 6 }}>
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ class Downloads {
|
|||||||
if (track.album.cover_url) {
|
if (track.album.cover_url) {
|
||||||
const cover = this.cover(track.album.id);
|
const cover = this.cover(track.album.id);
|
||||||
const info = await FileSystem.getInfoAsync(cover);
|
const info = await FileSystem.getInfoAsync(cover);
|
||||||
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
|
if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
|
||||||
}
|
}
|
||||||
const info = await FileSystem.getInfoAsync(this.file(id));
|
const info = await FileSystem.getInfoAsync(this.file(id));
|
||||||
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
||||||
@@ -286,4 +286,9 @@ class Downloads {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
|
||||||
|
export function sizedCover(url: string, size: number): string {
|
||||||
|
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
export const downloads = new Downloads();
|
export const downloads = new Downloads();
|
||||||
@@ -17,7 +17,7 @@ import TrackPlayer, {
|
|||||||
} from 'react-native-track-player';
|
} from 'react-native-track-player';
|
||||||
|
|
||||||
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||||
import { downloads } from '../offline/downloads';
|
import { downloads, sizedCover } from '../offline/downloads';
|
||||||
import { PersistentEvents } from '../offline/events';
|
import { PersistentEvents } from '../offline/events';
|
||||||
import { offlineWave } from './offlineWave';
|
import { offlineWave } from './offlineWave';
|
||||||
import { eventId } from './eventId';
|
import { eventId } from './eventId';
|
||||||
@@ -224,15 +224,18 @@ class Controller {
|
|||||||
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
||||||
const q = await quality();
|
const q = await quality();
|
||||||
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||||
return tracks.map((t) => ({
|
return tracks.map((t) => {
|
||||||
id: t.id,
|
const cover = absolute(this.server, t.album.cover_url);
|
||||||
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
return {
|
||||||
title: t.title,
|
id: t.id,
|
||||||
artist: t.artists.map((a) => a.name).join(', '),
|
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||||
album: t.album.title,
|
title: t.title,
|
||||||
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
|
artist: t.artists.map((a) => a.name).join(', '),
|
||||||
duration: t.duration_ms / 1000,
|
album: t.album.title,
|
||||||
}));
|
artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
|
||||||
|
duration: t.duration_ms / 1000,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async playList(tracks: Track[], index: number, context: PlayContext) {
|
async playList(tracks: Track[], index: number, context: PlayContext) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { useSessionCtx } from '../lib/session';
|
import { useSessionCtx } from '../lib/session';
|
||||||
import { downloads } from '../offline/downloads';
|
import { downloads, sizedCover } from '../offline/downloads';
|
||||||
import { absolute } from '../player/controller';
|
import { absolute } from '../player/controller';
|
||||||
import { radius, useColors, type Colors } from '../theme';
|
import { radius, useColors, type Colors } from '../theme';
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ export function Cover({
|
|||||||
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
||||||
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
||||||
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
|
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
|
||||||
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
|
const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
|
||||||
const bg = album.cover_colors?.[0] ?? colors.surface3;
|
const bg = album.cover_colors?.[0] ?? colors.surface3;
|
||||||
return (
|
return (
|
||||||
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ for (const width of [320, 1280]) {
|
|||||||
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
||||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||||
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
|
await page.getByRole('listitem').first().click();
|
||||||
|
const player = page.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
||||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
||||||
<title>pipu</title>
|
<title>pipu</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1,17 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#9CC6FF"/><stop offset=".55" stop-color="#2F80FF"/><stop offset="1" stop-color="#3A1FA8"/></radialGradient></defs><circle cx="16" cy="16" r="14" fill="url(#g)"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="orb" cx="28%" cy="18%" r="95%">
|
||||||
|
<stop stop-color="#9CC6FF"/>
|
||||||
|
<stop offset=".42" stop-color="#2F80FF"/>
|
||||||
|
<stop offset=".78" stop-color="#6354E8"/>
|
||||||
|
<stop offset="1" stop-color="#3A1FA8"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="note" x1="0" y1="0" x2=".3" y2="1">
|
||||||
|
<stop stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#DFEBFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="256" cy="256" r="240" fill="url(#orb)"/>
|
||||||
|
<path d="M208 348V164H270C356 164 356 280 270 280H208" fill="none" stroke="url(#note)" stroke-width="44" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<ellipse cx="181" cy="348" rx="49" ry="36" fill="url(#note)" transform="rotate(-18 181 348)"/>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 840 B |
@@ -291,7 +291,7 @@ describe('player', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
||||||
expect(router.state.location.pathname).toBe(path);
|
expect(router.state.location.pathname).toBe(path);
|
||||||
await user.click(screen.getByRole('listitem'));
|
await user.click(screen.getByRole('button', { name: 'Слушать' }));
|
||||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||||
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
||||||
@@ -409,6 +409,26 @@ describe('safeRedirect', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('playlists', () => {
|
describe('playlists', () => {
|
||||||
|
it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
|
await screen.findByRole('heading', { name: 'Группа крови' });
|
||||||
|
await user.click(screen.getAllByRole('listitem')[0]!);
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'Из плеера');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => expect(api.playlists.size).toBe(1));
|
||||||
|
expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
|
||||||
|
// Simulate automatic advancement while the picker is open.
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
|
||||||
|
});
|
||||||
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
||||||
const alice = makeUser('alice', 'user');
|
const alice = makeUser('alice', 'user');
|
||||||
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((
|
|||||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
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 SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||||
|
import type { DesktopHandle } from '../desktop';
|
||||||
import { AlbumPage } from '../pages/AlbumPage';
|
import { AlbumPage } from '../pages/AlbumPage';
|
||||||
|
import { ConnectPage } from '../pages/ConnectPage';
|
||||||
import { TrackPage } from '../pages/TrackPage';
|
import { TrackPage } from '../pages/TrackPage';
|
||||||
import { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
import { CollectionPage } from '../pages/CollectionPage';
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
@@ -34,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
|
|||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
session: AuthSession;
|
session: AuthSession;
|
||||||
|
desktop: DesktopHandle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||||
@@ -52,9 +55,12 @@ export function safeRedirect(target: unknown): string {
|
|||||||
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
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);
|
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;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +69,26 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|||||||
errorComponent: RouteError,
|
errorComponent: RouteError,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
|
||||||
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage });
|
||||||
|
const androidAppRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/android',
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/download' });
|
||||||
|
},
|
||||||
|
component: AppDownloadPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectRoute = createRoute({
|
||||||
|
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({
|
const loginRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@@ -72,6 +96,7 @@ const loginRoute = createRoute({
|
|||||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||||
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||||
beforeLoad: ({ context, search }) => {
|
beforeLoad: ({ context, search }) => {
|
||||||
|
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
|
||||||
const user = signedInUser(context.session);
|
const user = signedInUser(context.session);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
@@ -85,7 +110,7 @@ const setupAdminRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/admin',
|
path: '/setup/admin',
|
||||||
beforeLoad: ({ context, location }) => {
|
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) });
|
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="admin" />,
|
component: () => <SetupPage variant="admin" />,
|
||||||
@@ -95,7 +120,7 @@ const setupPasswordRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/password',
|
path: '/setup/password',
|
||||||
beforeLoad: ({ context, location }) => {
|
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) });
|
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="password" />,
|
component: () => <SetupPage variant="password" />,
|
||||||
@@ -106,7 +131,7 @@ const appRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
id: 'app',
|
id: 'app',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
if (home !== '/') throw redirect({ to: home });
|
if (home !== '/') throw redirect({ to: home });
|
||||||
return { user };
|
return { user };
|
||||||
@@ -245,7 +270,9 @@ const adminSettingsRoute = createRoute({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
|
downloadAppRoute,
|
||||||
androidAppRoute,
|
androidAppRoute,
|
||||||
|
connectRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
setupAdminRoute,
|
setupAdminRoute,
|
||||||
setupPasswordRoute,
|
setupPasswordRoute,
|
||||||
@@ -273,8 +300,12 @@ const routeTree = rootRoute.addChildren([
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
export function createAppRouter(
|
||||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
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.
|
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||||
session.subscribe(() => void router.invalidate());
|
session.subscribe(() => void router.invalidate());
|
||||||
return router;
|
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;
|
||||||
|
}
|
||||||
@@ -8,12 +8,18 @@
|
|||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover img {
|
.coverImg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverImgOn {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round {
|
.round {
|
||||||
@@ -59,10 +65,52 @@
|
|||||||
width: 128px;
|
width: 128px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cardCoverWrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.cardCover {
|
.cardCover {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 4px;
|
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cardCover,
|
||||||
|
.card:focus-within .cardCover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPlay {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
color: #05070d;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cardPlay,
|
||||||
|
.card:focus-within .cardPlay,
|
||||||
|
.cardPlay:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPlay:active {
|
||||||
|
transform: scale(0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardTitle {
|
.cardTitle {
|
||||||
@@ -104,6 +152,12 @@
|
|||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
animation: pipuIn 0.2s ease-out both;
|
||||||
|
animation-delay: var(--stagger, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackStatic {
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track:has(.trackAlbum) {
|
.track:has(.trackAlbum) {
|
||||||
@@ -272,6 +326,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.cardPlay { opacity: 1; transform: none; }
|
||||||
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
|
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
|
||||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Fragment, useRef, type ReactNode } from 'react';
|
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
|
||||||
import { AddToPlaylist } from './playlists';
|
import { AddToPlaylist } from './playlists';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { usePlayer, usePlayerState } from '../player/context';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import type { PlayContext } from '../player/player';
|
import type { PlayContext } from '../player/player';
|
||||||
@@ -28,6 +30,7 @@ function fallbackColors(id: string): string[] {
|
|||||||
|
|
||||||
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
||||||
export function coverSrc(url: string, cssPx: number): string {
|
export function coverSrc(url: string, cssPx: number): string {
|
||||||
|
if (!url.includes('/api/v1/covers/')) return url;
|
||||||
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
||||||
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
||||||
return `${url}&size=${size}`;
|
return `${url}&size=${size}`;
|
||||||
@@ -58,7 +61,7 @@ export function Cover({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{album.cover_url ? (
|
{album.cover_url ? (
|
||||||
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
|
<CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
|
||||||
) : (
|
) : (
|
||||||
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
||||||
)}
|
)}
|
||||||
@@ -82,10 +85,39 @@ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CoverImg({ src }: { src: string }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className={cx(s.coverImg, ready && s.coverImgOn)}
|
||||||
|
onLoad={() => setReady(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const play = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void (async () => {
|
||||||
|
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
|
||||||
|
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
|
||||||
|
})();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
||||||
<Cover album={album} size="100%" className={s.cardCover} />
|
<span className={s.cardCoverWrap}>
|
||||||
|
<Cover album={album} size="100%" className={s.cardCover} />
|
||||||
|
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
|
||||||
|
<Icon src={playIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
<span className={s.cardTitle}>{album.title}</span>
|
<span className={s.cardTitle}>{album.title}</span>
|
||||||
<span className={s.cardMeta}>
|
<span className={s.cardMeta}>
|
||||||
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
||||||
@@ -154,7 +186,8 @@ export function TrackList({
|
|||||||
role="listitem"
|
role="listitem"
|
||||||
tabIndex={tr.available ? 0 : -1}
|
tabIndex={tr.available ? 0 : -1}
|
||||||
aria-current={isCurrent || undefined}
|
aria-current={isCurrent || undefined}
|
||||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
|
||||||
|
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
|
||||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCallback, useState } from 'react';
|
import { memo, useCallback, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||||
import { queueIcon } from '../shared/ui/icons';
|
import { playlistAddIcon } from '../shared/ui/icons';
|
||||||
import s from './catalog.module.css';
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
export type Playlist = components['schemas']['Playlist'];
|
export type Playlist = components['schemas']['Playlist'];
|
||||||
@@ -61,7 +61,7 @@ export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
||||||
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const cache = useQueryClient();
|
const cache = useQueryClient();
|
||||||
@@ -84,14 +84,15 @@ function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: ()
|
|||||||
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
});
|
||||||
|
|
||||||
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
|
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
|
||||||
|
const close = useCallback(() => setSelectedIds(null), []);
|
||||||
return <>
|
return <>
|
||||||
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
|
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
|
||||||
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
|
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
|
||||||
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
|
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createAppRouter } from './app/router';
|
import { createAppRouter } from './app/router';
|
||||||
import { applyTheme } from './app/session';
|
import { applyTheme } from './app/session';
|
||||||
|
import { createDesktop, DesktopProvider } from './desktop';
|
||||||
|
|
||||||
const LOCALE_KEY = 'pipu.locale';
|
const LOCALE_KEY = 'pipu.locale';
|
||||||
|
|
||||||
@@ -63,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 роутера решают сразу.
|
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||||
await session.restore();
|
await session.restore();
|
||||||
const router = createAppRouter(session);
|
const router = createAppRouter(session, { desktop });
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<App session={session} router={router} />
|
<DesktopProvider value={desktop}>
|
||||||
|
<App session={session} router={router} />
|
||||||
|
</DesktopProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -4,12 +4,12 @@ import { Link } from '@tanstack/react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { Cover, TrackList } from '../entities/catalog';
|
import { Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||||
import { AddToPlaylist } from '../entities/playlists';
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
import { Button } from '../shared/ui/ui';
|
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
@@ -23,30 +23,41 @@ export function AlbumPage({ id }: { id: string }) {
|
|||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (album.isPending) {
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<PageSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (album.isError) return <NotFound error={album.error} />;
|
if (album.isError) return <NotFound error={album.error} />;
|
||||||
const a = album.data;
|
const a = album.data;
|
||||||
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
|
if (!a) return null;
|
||||||
const ctx = { type: 'album' as const, id, label: a?.title };
|
const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
|
||||||
|
const ctx = { type: 'album' as const, id, label: a.title };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<header className={c.hero}>
|
<header className={c.hero}>
|
||||||
|
{a.cover_url && (
|
||||||
|
<span className={c.heroBg} aria-hidden>
|
||||||
|
<img src={coverSrc(a.cover_url, 300)} alt="" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
||||||
<Cover album={a ?? { id }} size={200} />
|
<Cover album={a} size={200} className={c.heroCover} />
|
||||||
<div className={c.heroBody}>
|
<div className={c.heroBody}>
|
||||||
<span className={c.kind}>{t('catalog.album')}</span>
|
<span className={c.kind}>{t('catalog.album')}</span>
|
||||||
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
|
<h1 className={c.heroTitle}>{a.title}</h1>
|
||||||
{a && (
|
<span className={c.heroMeta}>
|
||||||
<span className={c.heroMeta}>
|
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||||
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
{a.artist.name}
|
||||||
{a.artist.name}
|
</Link>
|
||||||
</Link>
|
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||||
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
</span>
|
||||||
</span>
|
{a.tracks.some((tr) => tr.available) && (
|
||||||
)}
|
|
||||||
{a && a.tracks.some((tr) => tr.available) && (
|
|
||||||
<div className={c.heroActions}>
|
<div className={c.heroActions}>
|
||||||
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
|
<AddToPlaylist trackIds={a.tracks.map((tr) => tr.id)} />
|
||||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||||
{t('catalog.play')}
|
{t('catalog.play')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -67,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
|
<TrackList tracks={a.tracks} numbered context={ctx} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { formatBytes } from '../shared/lib/format';
|
import { formatBytes } from '../shared/lib/format';
|
||||||
|
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
|
||||||
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||||
import { Icon } from '../shared/ui/ui';
|
import { Icon } from '../shared/ui/ui';
|
||||||
import { AuthLayout } from './AuthLayout';
|
import { AuthLayout } from './AuthLayout';
|
||||||
import s from './auth.module.css';
|
import s from './auth.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
|
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
||||||
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
|
* Сборки — до 8 последних релизов Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||||
*/
|
*/
|
||||||
export function AppDownloadPage() {
|
export function AppDownloadPage() {
|
||||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const detected = useMemo(() => detectAppClient(), []);
|
||||||
const latest = useQuery({
|
const latest = useQuery({
|
||||||
queryKey: ['android-latest'],
|
queryKey: ['app-clients'],
|
||||||
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
|
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
});
|
});
|
||||||
const server = location.origin;
|
const server = location.origin;
|
||||||
@@ -28,39 +30,69 @@ export function AppDownloadPage() {
|
|||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
});
|
});
|
||||||
const r = latest.data;
|
const data = latest.data;
|
||||||
|
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
|
||||||
|
const primary = detected ? files[detected] : undefined;
|
||||||
|
const others = otherAppClients(detected).filter((id) => files[id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||||
<span className={s.badge}>Android</span>
|
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
||||||
<h1>{t('app.title')}</h1>
|
<h1>{t('app.title')}</h1>
|
||||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||||
{latest.isPending ? null : r ? (
|
{latest.isPending ? (
|
||||||
|
<p role="status">{t('common:loading')}</p>
|
||||||
|
) : latest.isError ? (
|
||||||
|
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||||
|
) : data ? (
|
||||||
<>
|
<>
|
||||||
<a className={s.download} href={r.download_url} download>
|
{primary ? (
|
||||||
<Icon src={downloadIcon} size={22} />
|
<>
|
||||||
<span>
|
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: primary.version ?? data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||||
{t('app.download')}
|
<OlderVersions items={primary.older ?? []} />
|
||||||
<span className={s.downloadMeta}>
|
</>
|
||||||
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
|
) : detected ? (
|
||||||
</span>
|
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
||||||
</span>
|
) : null}
|
||||||
</a>
|
{(primary || others.length > 0) && (
|
||||||
<ol className={s.steps}>
|
<ol className={s.steps}>
|
||||||
<li>{t('app.step_install')}</li>
|
{detected && <li>{t(`app.step_install.${detected}`)}</li>}
|
||||||
<li>
|
<li>
|
||||||
{t('app.step_server')}
|
{t('app.step_server')}
|
||||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||||
<code>{server}</code>
|
<code>{server}</code>
|
||||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>{t('app.step_updates')}</li>
|
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
|
||||||
</ol>
|
</ol>
|
||||||
<details className={s.details}>
|
)}
|
||||||
<summary>{t('app.checksum')}</summary>
|
{primary?.sha256 && (
|
||||||
<code>{r.sha256}</code>
|
<details className={s.details}>
|
||||||
</details>
|
<summary>{t('app.checksum')}</summary>
|
||||||
|
<code>{primary.sha256}</code>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div className={s.otherClients}>
|
||||||
|
<p className={s.otherTitle}>{t('app.other')}</p>
|
||||||
|
{others.map((id) => {
|
||||||
|
const file = files[id]!;
|
||||||
|
return (
|
||||||
|
<div key={id} className={s.otherBlock}>
|
||||||
|
<a className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||||
|
{t(`app.download.${id}`)}
|
||||||
|
<span>
|
||||||
|
{t('app.version', { version: file.version ?? data.version })} · {formatBytes(file.size, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<OlderVersions items={file.older ?? []} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||||
@@ -68,3 +100,32 @@ export function AppDownloadPage() {
|
|||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OlderVersions({ items }: { items: { version: string; filename?: string; size: number; download_url: string }[] }) {
|
||||||
|
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<details className={s.older}>
|
||||||
|
<summary>{t('app.older')}</summary>
|
||||||
|
{items.map((b) => (
|
||||||
|
<a key={b.version} className={s.downloadAlt} href={b.download_url} download={b.filename}>
|
||||||
|
{t('app.version', { version: b.version })}
|
||||||
|
<span>{formatBytes(b.size, i18n.language)}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
|
return (
|
||||||
|
<a className={s.download} href={href} download>
|
||||||
|
<Icon src={downloadIcon} size={22} />
|
||||||
|
<span>
|
||||||
|
{t(`app.download.${client}`)}
|
||||||
|
<span className={s.downloadMeta}>{meta}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
|
import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { playIcon, waveIcon } from '../shared/ui/icons';
|
import { playIcon, waveIcon } from '../shared/ui/icons';
|
||||||
import { Button } from '../shared/ui/ui';
|
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
@@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) {
|
|||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (artist.isPending) {
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<PageSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (artist.isError) return <NotFound error={artist.error} />;
|
if (artist.isError) return <NotFound error={artist.error} />;
|
||||||
const a = artist.data;
|
const a = artist.data;
|
||||||
|
if (!a) return null;
|
||||||
const tracks = top.data ?? [];
|
const tracks = top.data ?? [];
|
||||||
const ctx = { type: 'artist' as const, id, label: a?.name };
|
const ctx = { type: 'artist' as const, id, label: a.name };
|
||||||
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
||||||
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<header className={c.hero}>
|
<header className={c.hero}>
|
||||||
<Cover album={{ id }} size={168} round />
|
{a.image_url && (
|
||||||
|
<span className={c.heroBg} aria-hidden>
|
||||||
|
<img src={coverSrc(a.image_url, 300)} alt="" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Cover album={{ id, cover_url: a.image_url }} size={168} round className={c.heroCover} />
|
||||||
<div className={c.heroBody}>
|
<div className={c.heroBody}>
|
||||||
<span className={c.kind}>{t('catalog.artist')}</span>
|
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||||
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
<h1 className={c.heroTitle}>{a.name}</h1>
|
||||||
{a && (
|
<span className={c.heroMeta}>
|
||||||
<span className={c.heroMeta}>
|
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||||
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
</span>
|
||||||
</span>
|
<div className={c.heroActions}>
|
||||||
)}
|
<Button
|
||||||
{a && (
|
variant="primary"
|
||||||
<div className={c.heroActions}>
|
className={c.pill}
|
||||||
<Button
|
icon={waveIcon}
|
||||||
variant="primary"
|
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
||||||
className={c.pill}
|
>
|
||||||
icon={waveIcon}
|
{t('catalog.artist_wave')}
|
||||||
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
</Button>
|
||||||
>
|
{tracks.length > 0 && (
|
||||||
{t('catalog.artist_wave')}
|
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
||||||
|
{t('catalog.play')}
|
||||||
</Button>
|
</Button>
|
||||||
{tracks.length > 0 && (
|
)}
|
||||||
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
</div>
|
||||||
{t('catalog.play')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import type { ReactNode } from 'react';
|
|||||||
|
|
||||||
import { Logo } from '../shared/ui/ui';
|
import { Logo } from '../shared/ui/ui';
|
||||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||||
|
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||||
import s from './auth.module.css';
|
import s from './auth.module.css';
|
||||||
|
|
||||||
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
|
/** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */
|
||||||
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||||
|
const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
||||||
return (
|
return (
|
||||||
<div className={s.screen}>
|
<div className={s.screen}>
|
||||||
<div className={s.backdrop} aria-hidden>
|
<div className={s.backdrop} aria-hidden>
|
||||||
<span className={s.blob1} />
|
<span className={s.blob1} />
|
||||||
<span className={s.blob2} />
|
<span className={s.blob2} />
|
||||||
<span className={s.blob3} />
|
<span className={s.blob3} />
|
||||||
|
<div className={s.waveDecor}>
|
||||||
|
<WaveButton interactive={false} size={620} label="" theme={theme} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<header className={s.top}>
|
<header className={s.top}>
|
||||||
<Logo />
|
<Logo />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection';
|
|||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
|
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
|
import { Button, Dialog, EmptyState, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
|
|
||||||
@@ -109,7 +109,12 @@ function History() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
|
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
|
||||||
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
{history.isPending && <PageSkeleton />}
|
||||||
|
{history.isSuccess && items.length === 0 && (
|
||||||
|
<EmptyState icon={historyFillIcon} action={<Link to="/">{t('catalog.back_home')}</Link>}>
|
||||||
|
{t('collection.empty_history')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<section key={g.key} className={c.section}>
|
<section key={g.key} className={c.section}>
|
||||||
<h2 className={c.day}>{g.title}</h2>
|
<h2 className={c.day}>{g.title}</h2>
|
||||||
@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
|
{list.isPending && <PageSkeleton />}
|
||||||
{list.isSuccess && tracks.length === 0 && (
|
{list.isSuccess && tracks.length === 0 && (
|
||||||
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
|
<EmptyState
|
||||||
|
icon={reaction === 'like' ? likeFillIcon : dislikeFillIcon}
|
||||||
|
action={
|
||||||
|
<Link to="/search" search={{ q: '' }}>
|
||||||
|
{t('playlists.find_tracks')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
|
||||||
|
</EmptyState>
|
||||||
)}
|
)}
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<TrackList
|
<TrackList
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
waveSunIcon,
|
waveSunIcon,
|
||||||
waveWeekendIcon,
|
waveWeekendIcon,
|
||||||
} from '../shared/ui/icons';
|
} from '../shared/ui/icons';
|
||||||
import { cx, FormError, Icon } from '../shared/ui/ui';
|
import { cx, FormError, Icon, Skeleton } from '../shared/ui/ui';
|
||||||
import { WaveButton } from '../widgets/wave/WaveButton';
|
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||||
import s from './home.module.css';
|
import s from './home.module.css';
|
||||||
import { Onboarding } from './Onboarding';
|
import { Onboarding } from './Onboarding';
|
||||||
@@ -54,6 +54,13 @@ export function HomePage() {
|
|||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
if (onboarding.isPending) {
|
||||||
|
return (
|
||||||
|
<div className={s.home}>
|
||||||
|
<Skeleton round style={{ width: 320, height: 320, maxWidth: '70vw', maxHeight: '70vw' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (onboarding.data?.required) {
|
if (onboarding.data?.required) {
|
||||||
return (
|
return (
|
||||||
<Onboarding
|
<Onboarding
|
||||||
@@ -136,6 +143,12 @@ function WaveHome() {
|
|||||||
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
|
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
|
||||||
<FormError>{error ? errorText(error) : null}</FormError>
|
<FormError>{error ? errorText(error) : null}</FormError>
|
||||||
|
|
||||||
|
{options.isPending && (showActivities || showMoods) && (
|
||||||
|
<div className={s.picker}>
|
||||||
|
<Skeleton style={{ width: 280, height: 38, borderRadius: 999 }} />
|
||||||
|
<Skeleton style={{ width: 240, height: 38, borderRadius: 999 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{options.data && (showActivities || showMoods) && (
|
{options.data && (showActivities || showMoods) && (
|
||||||
<div className={s.picker}>
|
<div className={s.picker}>
|
||||||
{showActivities && (
|
{showActivities && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||||
import { AuthLayout } from './AuthLayout';
|
import { AuthLayout } from './AuthLayout';
|
||||||
@@ -11,6 +12,7 @@ import s from './auth.module.css';
|
|||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation(['auth', 'common']);
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -41,7 +43,13 @@ export function LoginPage() {
|
|||||||
footer={
|
footer={
|
||||||
info.data ? (
|
info.data ? (
|
||||||
<>
|
<>
|
||||||
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
pipu {info.data.version}
|
||||||
|
{desktop ? null : (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<a href="/download">{t('app.link')}</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link, useNavigate } from '@tanstack/react-router';
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
import { useState } from 'react';
|
import { useState, type MouseEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { Cover, TrackList } from '../entities/catalog';
|
import { Cover, TrackList } from '../entities/catalog';
|
||||||
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
||||||
|
import type { components } from '@pipu/api-client';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
|
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import p from './playlists.module.css';
|
import p from './playlists.module.css';
|
||||||
@@ -27,13 +28,16 @@ export function PlaylistsPage() {
|
|||||||
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
||||||
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
||||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
{list.isPending && <PageSkeleton />}
|
||||||
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
|
{list.isSuccess && !items.length && (
|
||||||
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
<EmptyState
|
||||||
<Cover album={item} size="100%" />
|
icon={search ? searchIcon : playlistAddIcon}
|
||||||
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
action={!search ? <Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button> : undefined}
|
||||||
{item.description && <span className={p.description}>{item.description}</span>}
|
>
|
||||||
</Link>)}</div>
|
{t(search ? 'playlists.no_results' : 'playlists.empty')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
<div className={p.grid}>{items.map(item => <PlaylistCard key={item.id} item={item} />)}</div>
|
||||||
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,7 @@ export function PlaylistPage({ id }: { id: string }) {
|
|||||||
return <div className={s.page}>
|
return <div className={s.page}>
|
||||||
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||||
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
|
{query.isPending && <PageSkeleton />}
|
||||||
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
@@ -93,7 +97,11 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
|||||||
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
|
{!playlist.tracks.length && (
|
||||||
|
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
|
||||||
|
{t('playlists.empty_tracks')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||||||
actions={manage ? (track, i) => <span className={p.actions}>
|
actions={manage ? (track, i) => <span className={p.actions}>
|
||||||
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||||||
@@ -101,9 +109,52 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
|||||||
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||||||
</span> : undefined} />
|
</span> : undefined} />
|
||||||
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||||
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
|
{remove && (
|
||||||
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
<Dialog
|
||||||
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
title={t('playlists.delete')}
|
||||||
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
|
onClose={() => {
|
||||||
|
if (!deletion.isPending) setRemove(false);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||||
|
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>{t('playlists.delete_confirm', { name: playlist.name })}</p>
|
||||||
|
<FormError>{deletion.error ? errorText(deletion.error) : null}</FormError>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlaylistCard({ item }: { item: components['schemas']['PlaylistSummary'] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const play = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void (async () => {
|
||||||
|
const full = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id: item.id } } }));
|
||||||
|
const tracks = full.tracks.filter((x) => x.available);
|
||||||
|
if (tracks.length) player.playList(tracks, 0, { type: 'collection', id: item.id, label: item.name });
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Link to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||||
|
<span className={p.coverWrap}>
|
||||||
|
<Cover album={item} size="100%" />
|
||||||
|
{item.track_count > 0 && (
|
||||||
|
<button type="button" className={p.play} aria-label={t('catalog.play')} onClick={play}>
|
||||||
|
<Icon src={playIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||||
|
{item.description && <span className={p.description}>{item.description}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
|
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { Button, FormError } from '../shared/ui/ui';
|
import { searchIcon } from '../shared/ui/icons';
|
||||||
|
import { Button, EmptyState, FormError, PageSkeleton } from '../shared/ui/ui';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
|
|
||||||
@@ -68,7 +69,11 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormError>{results.error ? errorText(results.error) : null}</FormError>
|
<FormError>{results.error ? errorText(results.error) : null}</FormError>
|
||||||
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
|
{query === '' && <EmptyState icon={searchIcon}>{t('search.type_query')}</EmptyState>}
|
||||||
|
{query !== '' && results.isPending && <PageSkeleton />}
|
||||||
|
{empty && (
|
||||||
|
<EmptyState icon={searchIcon}>{t('search.nothing')}</EmptyState>
|
||||||
|
)}
|
||||||
|
|
||||||
{artists.length > 0 && (
|
{artists.length > 0 && (
|
||||||
<section className={c.section}>
|
<section className={c.section}>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession, useUser } from '../app/session';
|
import { useSession, useUser } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||||
import { formatDateTime } from '../shared/lib/format';
|
import { formatDateTime } from '../shared/lib/format';
|
||||||
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||||
@@ -297,6 +299,8 @@ function PasswordSection() {
|
|||||||
function SessionsSection() {
|
function SessionsSection() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
const sessions = useQuery({
|
const sessions = useQuery({
|
||||||
@@ -333,6 +337,19 @@ function SessionsSection() {
|
|||||||
</div>
|
</div>
|
||||||
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||||
<div>
|
<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)}>
|
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||||
{t('settings.logout_all')}
|
{t('settings.logout_all')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { FormError } from '../shared/ui/ui';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import g from './genius.module.css';
|
||||||
|
|
||||||
|
export function TrackGenius({ id }: { id: string }) {
|
||||||
|
const session = useSession();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['track-genius', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}/genius', { params: { path: { id } } })),
|
||||||
|
refetchInterval: (q) => ['pending', 'processing'].includes(q.state.data?.status ?? '') ? 10000 : false,
|
||||||
|
});
|
||||||
|
const data = query.data;
|
||||||
|
if (data?.status === 'disabled') return null;
|
||||||
|
return <section className={s.section}>
|
||||||
|
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||||
|
{query.isPending && <p>{t('genius.loading')}</p>}
|
||||||
|
{data?.description && <><h2>{t('genius.about')}</h2><p className={g.lyrics}>{data.description}</p></>}
|
||||||
|
<div className={s.head}><h2>{t('genius.lyrics')}</h2>{data?.source_url && <a href={data.source_url} target="_blank" rel="noopener noreferrer">Genius ↗</a>}</div>
|
||||||
|
{data && (data.lyrics ? <div className={g.lyrics}>{data.lyrics}</div> : <p className={s.subtitle}>{['pending', 'processing'].includes(data.status) ? t('genius.pending_text') : t('genius.no_lyrics')}</p>)}
|
||||||
|
{!!data?.annotations.length && <>
|
||||||
|
<h2>{t('genius.explanations')}</h2>
|
||||||
|
{data.annotations.map((a, i) => <details className={g.annotation} key={`${a.url}-${i}`}>
|
||||||
|
<summary>{a.fragment}{a.verified && <span className={g.badge}>{t('genius.verified')}</span>}</summary>
|
||||||
|
<p className={g.lyrics}>{a.text}</p>
|
||||||
|
<p className={s.subtitle}>{a.authors}{a.url && <> · <a href={a.url} target="_blank" rel="noopener noreferrer">Genius ↗</a></>}</p>
|
||||||
|
</details>)}
|
||||||
|
</>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -4,35 +4,77 @@ import { Link } from '@tanstack/react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
|
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
|
||||||
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { pauseIcon, playIcon } from '../shared/ui/icons';
|
||||||
|
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
|
import { TrackGenius } from './TrackGenius';
|
||||||
|
|
||||||
export function TrackPage({ id }: { id: string }) {
|
export function TrackPage({ id }: { id: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const st = usePlayerState();
|
||||||
const track = useQuery({
|
const track = useQuery({
|
||||||
queryKey: ['track', id],
|
queryKey: ['track', id],
|
||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
|
||||||
});
|
});
|
||||||
|
if (track.isPending) {
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<PageSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (track.isError) return <NotFound error={track.error} />;
|
if (track.isError) return <NotFound error={track.error} />;
|
||||||
const tr = track.data;
|
const tr = track.data;
|
||||||
|
if (!tr) return null;
|
||||||
|
const ctx = { type: 'queue' as const };
|
||||||
|
const current = st.track?.id === tr.id;
|
||||||
|
const playing = current && st.playing;
|
||||||
return (
|
return (
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<header className={c.hero}>
|
<div className={c.listen}>
|
||||||
{tr && <Cover album={tr.album} size={200} />}
|
<header className={c.hero}>
|
||||||
<div className={c.heroBody}>
|
{tr.album.cover_url && (
|
||||||
<span className={c.kind}>{t('player.track_page')}</span>
|
<span className={c.heroBg} aria-hidden>
|
||||||
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
|
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
|
||||||
{tr && <>
|
</span>
|
||||||
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
|
)}
|
||||||
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
|
<Cover album={tr.album} size={280} className={c.heroCover} />
|
||||||
</>}
|
<div className={c.heroBody}>
|
||||||
|
<span className={c.kind}>{t('player.track_page')}</span>
|
||||||
|
<h1 className={c.heroTitle}>{tr.title}</h1>
|
||||||
|
<span className={c.heroMeta}>
|
||||||
|
<ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}
|
||||||
|
</span>
|
||||||
|
<Link to="/album/$id" params={{ id: tr.album.id }}>
|
||||||
|
{tr.album.title}
|
||||||
|
</Link>
|
||||||
|
<div className={c.heroActions}>
|
||||||
|
{tr.available && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className={c.pill}
|
||||||
|
icon={playing ? pauseIcon : playIcon}
|
||||||
|
onClick={() => (current ? player.toggle() : player.playList([tr], 0, ctx))}
|
||||||
|
>
|
||||||
|
{t(playing ? 'player.pause' : 'catalog.play')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<AddToPlaylist trackIds={[tr.id]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className={c.listenLyrics}>
|
||||||
|
<TrackGenius id={id} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useSession } from '../../app/session';
|
import { useSession } from '../../app/session';
|
||||||
import { useErrorText } from '../../shared/lib/errors';
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
import { formatDateTime } from '../../shared/lib/format';
|
import { formatDateTime } from '../../shared/lib/format';
|
||||||
import { Button, FormError } from '../../shared/ui/ui';
|
import { Button, EmptyState, FormError, PageSkeleton } from '../../shared/ui/ui';
|
||||||
import s from '../page.module.css';
|
import s from '../page.module.css';
|
||||||
|
|
||||||
type Entry = components['schemas']['AuditEntry'];
|
type Entry = components['schemas']['AuditEntry'];
|
||||||
@@ -67,8 +67,8 @@ export function AuditPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
|
{list.isSuccess && entries.length === 0 && <EmptyState>{t('audit.empty')}</EmptyState>}
|
||||||
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
{list.isPending && <PageSkeleton />}
|
||||||
</div>
|
</div>
|
||||||
{list.hasNextPage && (
|
{list.hasNextPage && (
|
||||||
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { Button, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import g from '../genius.module.css';
|
||||||
|
|
||||||
|
export function GeniusPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const queries = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const stats = useQuery({
|
||||||
|
queryKey: ['genius-stats'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/genius')),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const control = useMutation({
|
||||||
|
mutationFn: async (action: 'start' | 'pause' | 'retry') =>
|
||||||
|
unwrap(await session.client.POST('/api/v1/admin/genius', { body: { action } })),
|
||||||
|
onSuccess: (data) => { queries.setQueryData(['genius-stats'], data); },
|
||||||
|
});
|
||||||
|
const data = stats.data;
|
||||||
|
const cooling = data && Date.parse(data.cooldown_until) > Date.now();
|
||||||
|
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting');
|
||||||
|
return <section className={s.section} aria-label="Genius">
|
||||||
|
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
|
||||||
|
<p className={s.subtitle}>{t('genius.description')}</p>
|
||||||
|
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
|
||||||
|
{data && <>
|
||||||
|
{!data.configured && <p>{t('genius.configure')}</p>}
|
||||||
|
<dl className={g.stats}>
|
||||||
|
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
|
||||||
|
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)}
|
||||||
|
</dl>
|
||||||
|
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
|
||||||
|
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
|
||||||
|
<div className={g.actions}>
|
||||||
|
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
|
||||||
|
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -5,10 +5,11 @@ import { useNavigate } from '@tanstack/react-router';
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../../app/session';
|
import { useSession, useUser } from '../../app/session';
|
||||||
|
import { GeniusPanel } from './GeniusPanel';
|
||||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
|
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
|
||||||
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
|
import { Button, Dialog, EmptyState, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
|
||||||
import s from '../page.module.css';
|
import s from '../page.module.css';
|
||||||
import l from './library.module.css';
|
import l from './library.module.css';
|
||||||
import { ScanLine, StatusBadge } from './ScanStatus';
|
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||||
@@ -19,6 +20,7 @@ type TestResult = components['schemas']['SourceTestResult'];
|
|||||||
const SCAN_POLL_MS = 2000;
|
const SCAN_POLL_MS = 2000;
|
||||||
|
|
||||||
export function SourcesPage() {
|
export function SourcesPage() {
|
||||||
|
const user = useUser();
|
||||||
const { t } = useTranslation(['admin', 'common']);
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
@@ -43,7 +45,12 @@ export function SourcesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
|
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
|
||||||
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
|
{user.role === 'admin' && <GeniusPanel />}
|
||||||
|
{sources.isSuccess && sources.data.length === 0 && (
|
||||||
|
<EmptyState icon={folderIcon} action={<Button variant="primary" onClick={() => setEditing('new')}>{t('sources.add')}</Button>}>
|
||||||
|
{t('sources.empty')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
<div className={l.list}>
|
<div className={l.list}>
|
||||||
{sources.data?.map((src) => (
|
{sources.data?.map((src) => (
|
||||||
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
|
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { useSession } from '../../app/session';
|
|||||||
import { needsFormatAttention } from '../../features/uploads/files';
|
import { needsFormatAttention } from '../../features/uploads/files';
|
||||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||||
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
import { checkIcon, closeIcon, editIcon, inboxIcon, refreshIcon } from '../../shared/ui/icons';
|
||||||
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
|
import { Button, cx, Dialog, EmptyState, Field, FormError, Icon } from '../../shared/ui/ui';
|
||||||
import s from '../page.module.css';
|
import s from '../page.module.css';
|
||||||
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
|
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
|
||||||
import u from '../uploads/uploads.module.css';
|
import u from '../uploads/uploads.module.css';
|
||||||
@@ -123,7 +123,7 @@ export function UploadQueuePage() {
|
|||||||
{failed.length > 0 && (
|
{failed.length > 0 && (
|
||||||
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
||||||
)}
|
)}
|
||||||
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
|
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
|
||||||
|
|
||||||
{batches.map((files) => (
|
{batches.map((files) => (
|
||||||
<BatchCard
|
<BatchCard
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
|||||||
import { formatRelative } from '../../shared/lib/format';
|
import { formatRelative } from '../../shared/lib/format';
|
||||||
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||||
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
|
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
|
||||||
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
|
import { Avatar, Button, cx, Dialog, EmptyState, Field, FormError, Icon, Menu, PageSkeleton, type MenuItem } from '../../shared/ui/ui';
|
||||||
import s from '../page.module.css';
|
import s from '../page.module.css';
|
||||||
|
|
||||||
type AdminUser = components['schemas']['AdminUser'];
|
type AdminUser = components['schemas']['AdminUser'];
|
||||||
@@ -232,8 +232,8 @@ export function UsersPage() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
|
{list.isSuccess && users.length === 0 && <EmptyState icon={searchIcon}>{t('users.empty')}</EmptyState>}
|
||||||
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
{list.isPending && <PageSkeleton />}
|
||||||
</div>
|
</div>
|
||||||
{list.hasNextPage && (
|
{list.hasNextPage && (
|
||||||
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
|||||||
@@ -20,6 +20,15 @@
|
|||||||
filter: blur(30px);
|
filter: blur(30px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.waveDecor {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 46%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 0.55;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
.blob1 {
|
.blob1 {
|
||||||
width: 62vmax;
|
width: 62vmax;
|
||||||
height: 62vmax;
|
height: 62vmax;
|
||||||
@@ -165,7 +174,7 @@
|
|||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Страница Android-приложения (/android). */
|
/* Страница приложений (/download). */
|
||||||
.download {
|
.download {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -238,6 +247,60 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.otherClients {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otherBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otherTitle {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAlt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAlt span {
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.older {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.older summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.older .downloadAlt {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
@@ -60,12 +60,23 @@
|
|||||||
|
|
||||||
.heroBg {
|
.heroBg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: -20%;
|
||||||
opacity: 0.35;
|
opacity: 0.4;
|
||||||
filter: blur(60px);
|
filter: blur(60px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heroBg img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroCover {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.tint {
|
.tint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -81,12 +92,40 @@
|
|||||||
|
|
||||||
.heroBody {
|
.heroBody {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listen {
|
||||||
|
display: grid;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listenLyrics {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.listen {
|
||||||
|
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listen .hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listen .heroTitle {
|
||||||
|
font-size: clamp(28px, 3vw, 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.kind {
|
.kind {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; margin: 0; }
|
||||||
|
.stats dt { color: var(--text2); font-size: 13px; }
|
||||||
|
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
|
||||||
|
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
|
||||||
|
.annotation summary { cursor: pointer; line-height: 1.6; }
|
||||||
|
.badge { display: inline-block; margin-left: 12px; font-size: 12px; color: var(--text2); }
|
||||||
@@ -98,6 +98,10 @@
|
|||||||
background: var(--surface3);
|
background: var(--surface3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
.chipOn,
|
.chipOn,
|
||||||
.chipOn:hover {
|
.chipOn:hover {
|
||||||
background: var(--accent-fill);
|
background: var(--accent-fill);
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
|
||||||
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
|
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface); animation: pipuIn 0.2s ease-out both; }
|
||||||
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
|
.coverWrap { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; }
|
||||||
|
.coverWrap > :first-child { width: 100%; height: 100%; }
|
||||||
.card strong { overflow-wrap: anywhere; }
|
.card strong { overflow-wrap: anywhere; }
|
||||||
.card span { font-size: 13px; }
|
.card span { font-size: 13px; }
|
||||||
.card:hover { background: #ffffff10; }
|
.card:hover { background: var(--hover); }
|
||||||
|
.play {
|
||||||
|
position: absolute; right: 10px; bottom: 10px; z-index: 1; display: grid; place-items: center;
|
||||||
|
width: 44px; height: 44px; border-radius: 50%; background: #fff; color: #05070d;
|
||||||
|
opacity: 0; transform: translateY(6px); transition: opacity 0.18s, transform 0.18s;
|
||||||
|
}
|
||||||
|
.card:hover .play, .card:focus-within .play, .play:focus-visible { opacity: 1; transform: none; }
|
||||||
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
|
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
.actions { display: flex; align-items: center; }
|
.actions { display: flex; align-items: center; }
|
||||||
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
|
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
|
||||||
.actions button:disabled { opacity: .3; cursor: default; }
|
.actions button:disabled { opacity: .3; cursor: default; }
|
||||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
|
.hero { overflow-wrap: anywhere; }
|
||||||
|
@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
|
||||||
|
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } .play { opacity: 1; transform: none; } }
|
||||||
@@ -8,7 +8,7 @@ import { useSession } from '../../app/session';
|
|||||||
import { useErrorText } from '../../shared/lib/errors';
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
import { formatBytes, formatDateTime } from '../../shared/lib/format';
|
import { formatBytes, formatDateTime } from '../../shared/lib/format';
|
||||||
import { addIcon } from '../../shared/ui/icons';
|
import { addIcon } from '../../shared/ui/icons';
|
||||||
import { Button, cx, FormError } from '../../shared/ui/ui';
|
import { Button, cx, EmptyState, FormError } from '../../shared/ui/ui';
|
||||||
import s from '../page.module.css';
|
import s from '../page.module.css';
|
||||||
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
|
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
|
||||||
import u from './uploads.module.css';
|
import u from './uploads.module.css';
|
||||||
@@ -86,7 +86,11 @@ export function MyUploadsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
|
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
|
||||||
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>}
|
{list.isSuccess && items.length === 0 && (
|
||||||
|
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
|
||||||
|
{t('uploads.mine_empty')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className={s.tableWrap}>
|
<div className={s.tableWrap}>
|
||||||
<table className={s.table}>
|
<table className={s.table}>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type Track,
|
type Track,
|
||||||
} from '@pipu/core';
|
} from '@pipu/core';
|
||||||
|
|
||||||
|
import { desktopHandle } from '../desktop';
|
||||||
|
|
||||||
export type { PlayContext, Track };
|
export type { PlayContext, Track };
|
||||||
|
|
||||||
export interface WaveInfo {
|
export interface WaveInfo {
|
||||||
@@ -121,6 +123,7 @@ export class Player {
|
|||||||
};
|
};
|
||||||
this.bindAudio();
|
this.bindAudio();
|
||||||
this.bindMediaSession();
|
this.bindMediaSession();
|
||||||
|
this.bindDesktopMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||||
@@ -137,9 +140,13 @@ export class Player {
|
|||||||
|
|
||||||
getState = () => this.state;
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private lastDesktop: { id?: string; playing?: boolean } = {};
|
||||||
|
private desktopUnlisten: (() => void) | null = null;
|
||||||
|
|
||||||
private update(patch: Partial<PlayerState>) {
|
private update(patch: Partial<PlayerState>) {
|
||||||
this.state = { ...this.state, ...patch };
|
this.state = { ...this.state, ...patch };
|
||||||
for (const fn of this.listeners) fn();
|
for (const fn of this.listeners) fn();
|
||||||
|
this.syncDesktopMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncQueue() {
|
private syncQueue() {
|
||||||
@@ -678,6 +685,33 @@ export class Player {
|
|||||||
|
|
||||||
// --- Media Session ---
|
// --- 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() {
|
private bindMediaSession() {
|
||||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
if (!ms) return;
|
if (!ms) return;
|
||||||
@@ -855,6 +889,8 @@ export class Player {
|
|||||||
this.overlap.load();
|
this.overlap.load();
|
||||||
for (const t of this.timers) clearInterval(t);
|
for (const t of this.timers) clearInterval(t);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
|
this.desktopUnlisten?.();
|
||||||
|
this.desktopUnlisten = null;
|
||||||
void this.events.flush();
|
void this.events.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { detectAppClient, otherAppClients } from './platform';
|
||||||
|
|
||||||
|
describe('detectAppClient', () => {
|
||||||
|
it('picks the native client from the user agent', () => {
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the remaining platforms after the detected one', () => {
|
||||||
|
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
|
||||||
|
|
||||||
|
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
|
||||||
|
|
||||||
|
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
|
||||||
|
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
|
||||||
|
if (/Android/i.test(ua)) return 'android';
|
||||||
|
if (/iPhone|iPad|iPod/i.test(ua)) return null;
|
||||||
|
if (/Windows/i.test(ua)) return 'windows';
|
||||||
|
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
|
||||||
|
if (/Linux|X11/i.test(ua)) return 'linux';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function otherAppClients(primary: AppClient | null): AppClient[] {
|
||||||
|
return ORDER.filter((c) => c !== primary);
|
||||||
|
}
|
||||||
@@ -70,4 +70,5 @@ export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_e
|
|||||||
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
|
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
|
||||||
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
|
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
|
||||||
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
|
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
|
||||||
|
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
|
||||||
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
|
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
|
||||||
@@ -21,9 +21,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logoDot {
|
.logoDot {
|
||||||
|
display: inline-block;
|
||||||
|
flex: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
|
background: url('/favicon.svg') center / contain no-repeat;
|
||||||
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
@@ -40,7 +41,12 @@
|
|||||||
transition:
|
transition:
|
||||||
background 0.15s,
|
background 0.15s,
|
||||||
box-shadow 0.15s,
|
box-shadow 0.15s,
|
||||||
opacity 0.15s;
|
opacity 0.15s,
|
||||||
|
transform 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:disabled {
|
.button:disabled {
|
||||||
@@ -52,6 +58,115 @@
|
|||||||
cursor: progress;
|
cursor: progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pipuSpin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pipuSpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
color: var(--text3);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 28rem;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty a,
|
||||||
|
.empty button {
|
||||||
|
color: var(--accent2);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
display: block;
|
||||||
|
background: var(--surface3);
|
||||||
|
border-radius: 8px;
|
||||||
|
animation: pipuShimmer 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonRound {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pipuShimmer {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageSkel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skelHero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skelCover {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 12px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skelCopy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skelRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
background: var(--accent-fill);
|
background: var(--accent-fill);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ButtonHTMLAttributes,
|
type ButtonHTMLAttributes,
|
||||||
|
type CSSProperties,
|
||||||
type InputHTMLAttributes,
|
type InputHTMLAttributes,
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -52,7 +53,7 @@ export function Button({ variant = 'secondary', icon, busy, className, children,
|
|||||||
aria-busy={busy || undefined}
|
aria-busy={busy || undefined}
|
||||||
className={cx(s.button, s[variant], className)}
|
className={cx(s.button, s[variant], className)}
|
||||||
>
|
>
|
||||||
{icon && <Icon src={icon} size={18} />}
|
{busy ? <span className={s.spinner} aria-hidden /> : icon ? <Icon src={icon} size={18} /> : null}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -283,6 +284,61 @@ export function Menu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Пустой список: иконка, текст и действие (docs/design.md#5). */
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={s.empty} role="status">
|
||||||
|
{icon && <Icon src={icon} size={40} className={s.emptyIcon} />}
|
||||||
|
<p className={s.emptyText}>{children}</p>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
round,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
round?: boolean;
|
||||||
|
}) {
|
||||||
|
return <span className={cx(s.skeleton, round && s.skeletonRound, className)} style={style} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каркас героя и строк списка, пока страница ещё грузится. */
|
||||||
|
export function PageSkeleton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={s.pageSkel} role="status" aria-busy="true" aria-label={t('loading')}>
|
||||||
|
<div className={s.skelHero}>
|
||||||
|
<Skeleton className={s.skelCover} />
|
||||||
|
<div className={s.skelCopy}>
|
||||||
|
<Skeleton style={{ width: 72, height: 12 }} />
|
||||||
|
<Skeleton style={{ width: '55%', height: 28 }} />
|
||||||
|
<Skeleton style={{ width: '38%', height: 14 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<div key={i} className={s.skelRow}>
|
||||||
|
<Skeleton style={{ width: 40, height: 40 }} />
|
||||||
|
<Skeleton style={{ flex: 1, height: 14 }} />
|
||||||
|
<Skeleton style={{ width: 36, height: 12 }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
|
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
|
||||||
const initials = name
|
const initials = name
|
||||||
.split(/[\s._-]+/)
|
.split(/[\s._-]+/)
|
||||||
|
|||||||
@@ -116,6 +116,34 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
min_android_version_code: 0,
|
min_android_version_code: 0,
|
||||||
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
||||||
});
|
});
|
||||||
|
case 'GET /api/v1/app/clients':
|
||||||
|
return json({
|
||||||
|
version: '1.3.0',
|
||||||
|
version_code: 1_030_099,
|
||||||
|
notes_md: '',
|
||||||
|
clients: [
|
||||||
|
{
|
||||||
|
client: 'windows',
|
||||||
|
filename: 'pipu-music_1.3.0_x64-setup.exe',
|
||||||
|
size: 20_000_000,
|
||||||
|
download_url: '/api/v1/app/download/windows/1.3.0',
|
||||||
|
older: [{ version: '1.2.0', filename: 'pipu-music_1.2.0_x64-setup.exe', size: 19_000_000, download_url: '/api/v1/app/download/windows/1.2.0' }],
|
||||||
|
},
|
||||||
|
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.deb', size: 4_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
|
||||||
|
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
|
||||||
|
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
case 'GET /api/v1/app/android/latest':
|
||||||
|
return json({
|
||||||
|
version: '1.3.0',
|
||||||
|
version_code: 1_030_099,
|
||||||
|
notes_md: '',
|
||||||
|
size: 12_000_000,
|
||||||
|
sha256: 'ab'.repeat(32),
|
||||||
|
download_url: '/api/v1/app/android/download/1.3.0',
|
||||||
|
min_supported: 0,
|
||||||
|
});
|
||||||
case 'POST /api/v1/auth/login': {
|
case 'POST /api/v1/auth/login': {
|
||||||
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
||||||
if (!u) return problem(401, 'auth.invalid_credentials');
|
if (!u) return problem(401, 'auth.invalid_credentials');
|
||||||
@@ -280,6 +308,9 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
users.set(target.id, target);
|
users.set(target.id, target);
|
||||||
return json(admin(target));
|
return json(admin(target));
|
||||||
}
|
}
|
||||||
|
if (req.method === 'GET' && /^\/api\/v1\/tracks\/[^/]+\/genius$/.test(path)) {
|
||||||
|
return json({ status: 'disabled', lyrics: '', source_url: '', song_image_url: '', artist_image_url: '', description: '', annotations: [] });
|
||||||
|
}
|
||||||
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
||||||
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
|
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
|
||||||
if (trackPath && req.method === 'GET') {
|
if (trackPath && req.method === 'GET') {
|
||||||
|
|||||||
@@ -227,12 +227,33 @@
|
|||||||
color: var(--accent2);
|
color: var(--accent2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skip {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 8px;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--elev);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transform: translateY(-120%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip:focus {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 32px 36px;
|
padding: 32px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeIn {
|
||||||
animation: pipuIn 0.2s ease-out;
|
animation: pipuIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { queueIcon } from '../shared/ui/icons';
|
import { queueIcon } from '../shared/ui/icons';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
|
||||||
import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuthState, useSession, useUser } from '../app/session';
|
import { useAuthState, useSession, useUser } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useCollectionCounts } from '../entities/collection';
|
import { useCollectionCounts } from '../entities/collection';
|
||||||
import {
|
import {
|
||||||
moreIcon,
|
moreIcon,
|
||||||
@@ -25,11 +26,11 @@ import {
|
|||||||
dislikeFillIcon,
|
dislikeFillIcon,
|
||||||
dislikeIcon,
|
dislikeIcon,
|
||||||
settingsIcon,
|
settingsIcon,
|
||||||
androidIcon,
|
downloadIcon,
|
||||||
chevronRightIcon,
|
chevronRightIcon,
|
||||||
storageFillIcon,
|
storageFillIcon,
|
||||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||||
import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
|
import { Avatar, cx, Dialog, Icon, Logo, Menu, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { LanguageSwitch } from './LanguageSwitch';
|
import { LanguageSwitch } from './LanguageSwitch';
|
||||||
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
|
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
|
||||||
import { Ambient } from './Ambient';
|
import { Ambient } from './Ambient';
|
||||||
@@ -155,6 +156,7 @@ function Shell() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const name = user.display_name ?? user.username;
|
const name = user.display_name ?? user.username;
|
||||||
const staff = user.role !== 'user';
|
const staff = user.role !== 'user';
|
||||||
@@ -224,6 +226,9 @@ function Shell() {
|
|||||||
return (
|
return (
|
||||||
<div className={cx(s.shell, island && s.island)}>
|
<div className={cx(s.shell, island && s.island)}>
|
||||||
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
||||||
|
<a href="#content" className={s.skip}>
|
||||||
|
{t('nav.skip')}
|
||||||
|
</a>
|
||||||
<header className={s.top}>
|
<header className={s.top}>
|
||||||
<div className={s.topLeft}>
|
<div className={s.topLeft}>
|
||||||
<Link to="/" className={s.logoLink}>
|
<Link to="/" className={s.logoLink}>
|
||||||
@@ -252,7 +257,9 @@ function Shell() {
|
|||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||||
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
...(!desktop
|
||||||
|
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
|
||||||
|
: []),
|
||||||
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -280,17 +287,26 @@ function Shell() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className={s.content}>
|
<ShellMain />
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Outlet />
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShellMain() {
|
||||||
|
const key = useRouterState({ select: (st) => st.location.pathname });
|
||||||
|
return (
|
||||||
|
<main id="content" className={s.content} tabIndex={-1}>
|
||||||
|
<div key={key} className={s.routeIn}>
|
||||||
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Третье пятно фона — в цвете обложки текущего трека. */
|
/** Третье пятно фона — в цвете обложки текущего трека. */
|
||||||
function ShellAmbient() {
|
function ShellAmbient() {
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
|
|||||||
@@ -149,6 +149,115 @@ a.source:hover {
|
|||||||
transform: scale(1.06);
|
transform: scale(1.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.play:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowArt {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowEq {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverLive {
|
||||||
|
animation: pipuCoverPulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pipuCoverPulse {
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.queueScrim,
|
||||||
|
.sheetScrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 15;
|
||||||
|
background: var(--overlay);
|
||||||
|
animation: pipuIn 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 16;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
max-height: min(92dvh, 760px);
|
||||||
|
padding: 12px 20px calc(24px + env(safe-area-inset-bottom, 0px));
|
||||||
|
border-radius: 24px 24px 0 0;
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.3);
|
||||||
|
animation: pipuIn 0.22s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetHead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetCover {
|
||||||
|
width: min(240px, 56vw) !important;
|
||||||
|
height: min(240px, 56vw) !important;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetTitle {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetControls,
|
||||||
|
.sheetExtra {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet .progress {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet .volume {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
.play[aria-busy='true'] {
|
.play[aria-busy='true'] {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@@ -218,6 +327,10 @@ a.source:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.now {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
@@ -228,7 +341,7 @@ a.source:hover {
|
|||||||
.right,
|
.right,
|
||||||
.controls > :first-child,
|
.controls > :first-child,
|
||||||
.controls > :last-child,
|
.controls > :last-child,
|
||||||
.now > button {
|
.now > button:not(.nowArt) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,7 +350,7 @@ a.source:hover {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
bottom: 100px;
|
bottom: 100px;
|
||||||
z-index: 6;
|
z-index: 16;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: min(380px, calc(100% - 32px));
|
width: min(380px, calc(100% - 32px));
|
||||||
@@ -311,13 +424,17 @@ a.source:hover {
|
|||||||
.controls { gap: 2px; }
|
.controls { gap: 2px; }
|
||||||
.play, .icon { width: 44px; height: 44px; }
|
.play, .icon { width: 44px; height: 44px; }
|
||||||
.right { display: flex; gap: 0; }
|
.right { display: flex; gap: 0; }
|
||||||
.right > :not(.queueButton):not(.shareButton) { display: none; }
|
.right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; }
|
||||||
|
.error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px;
|
||||||
|
padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; }
|
||||||
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
||||||
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
|
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 359px) {
|
@media (max-width: 359px) {
|
||||||
.now > :first-child { display: none; }
|
.now > :first-child { display: none; }
|
||||||
|
/* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */
|
||||||
|
.shareButton { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.shareStatus {
|
.shareStatus {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { createPortal } from 'react-dom';
|
|||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
|
||||||
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
import { usePlayer, usePlayerState } from '../player/context';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { shareTrack } from '../shared/lib/share';
|
import { shareTrack } from '../shared/lib/share';
|
||||||
@@ -36,6 +37,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
|
const narrow = useNarrow();
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
|
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
@@ -96,8 +99,24 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
||||||
<div className={s.now}>
|
<div
|
||||||
<Cover album={track.album} size={52} />
|
className={s.now}
|
||||||
|
onClick={
|
||||||
|
narrow
|
||||||
|
? (e) => {
|
||||||
|
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{narrow ? (
|
||||||
|
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
|
||||||
|
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||||
|
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||||
|
)}
|
||||||
<div className={s.meta}>
|
<div className={s.meta}>
|
||||||
<span className={s.title}>{track.title}</span>
|
<span className={s.title}>{track.title}</span>
|
||||||
<span className={s.artists}>
|
<span className={s.artists}>
|
||||||
@@ -178,6 +197,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.right}>
|
<div className={s.right}>
|
||||||
|
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
|
||||||
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||||
<Icon src={shareIcon} size={20} />
|
<Icon src={shareIcon} size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -230,12 +250,150 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{sheetOpen && narrow && (
|
||||||
|
<NowPlayingSheet
|
||||||
|
variant={variant}
|
||||||
|
onClose={() => setSheetOpen(false)}
|
||||||
|
share={share}
|
||||||
|
sharing={sharing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||||
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useNarrow() {
|
||||||
|
const [narrow, setNarrow] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia?.('(max-width: 767px)');
|
||||||
|
if (!mq) return;
|
||||||
|
const sync = () => setNarrow(mq.matches);
|
||||||
|
sync();
|
||||||
|
mq.addEventListener?.('change', sync);
|
||||||
|
return () => mq.removeEventListener?.('change', sync);
|
||||||
|
}, []);
|
||||||
|
return narrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NowPlayingSheet({
|
||||||
|
variant,
|
||||||
|
onClose,
|
||||||
|
share,
|
||||||
|
sharing,
|
||||||
|
}: {
|
||||||
|
variant: 'bar' | 'island';
|
||||||
|
onClose: () => void;
|
||||||
|
share: () => Promise<void>;
|
||||||
|
sharing: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const player = usePlayer();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const track = st.track;
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
if (!track) return null;
|
||||||
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||||
|
return createPortal(
|
||||||
|
<div className={s.sheetScrim} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={cx(s.sheet, variant === 'island' && s.sheetIsland)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('player.now_playing')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={s.sheetHead}>
|
||||||
|
<span>{t('player.now_playing')}</span>
|
||||||
|
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||||
|
<Icon src={closeIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Cover album={track.album} size={240} className={cx(s.sheetCover, st.playing && s.coverLive)} />
|
||||||
|
<div className={s.sheetMeta}>
|
||||||
|
<Link to="/track/$id" params={{ id: track.id }} className={s.sheetTitle} onClick={onClose}>
|
||||||
|
{track.title}
|
||||||
|
</Link>
|
||||||
|
<span className={s.artists}>
|
||||||
|
<ArtistLinks artists={track.artists} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={s.progress}>
|
||||||
|
<span className={s.time}>{formatDuration(st.positionMs)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={s.range}
|
||||||
|
min={0}
|
||||||
|
max={st.durationMs || track.duration_ms}
|
||||||
|
step={1000}
|
||||||
|
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||||
|
aria-label={t('player.progress')}
|
||||||
|
onChange={(e) => player.seek(Number(e.target.value))}
|
||||||
|
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||||
|
/>
|
||||||
|
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={s.sheetControls}>
|
||||||
|
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
|
||||||
|
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||||
|
<Icon src={prevIcon} size={28} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
|
||||||
|
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||||
|
<Icon src={nextIcon} size={28} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
|
||||||
|
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={s.sheetExtra}>
|
||||||
|
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
|
||||||
|
<Icon src={shuffleIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
|
||||||
|
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||||
|
<Icon src={shareIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
|
||||||
|
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={cx(s.range, s.volume)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={st.muted ? 0 : st.volume}
|
||||||
|
aria-label={t('player.volume')}
|
||||||
|
onChange={(e) => player.setVolume(Number(e.target.value))}
|
||||||
|
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
||||||
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
|
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -254,7 +412,9 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
|
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
<>
|
||||||
|
<div className={s.queueScrim} onClick={onClose} />
|
||||||
|
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||||
<div className={s.drawerHead}>
|
<div className={s.drawerHead}>
|
||||||
<span>{t('player.queue')}</span>
|
<span>{t('player.queue')}</span>
|
||||||
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||||
@@ -280,7 +440,8 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
|
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
|
||||||
</aside>,
|
</aside>
|
||||||
|
</>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { Cover } from '../entities/catalog';
|
import { Cover } from '../entities/catalog';
|
||||||
|
import { usePlayer } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { searchIcon } from '../shared/ui/icons';
|
import { searchIcon } from '../shared/ui/icons';
|
||||||
import { cx, Icon } from '../shared/ui/ui';
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
@@ -36,6 +37,7 @@ export function SearchBox({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const listId = useId();
|
const listId = useId();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -118,8 +120,7 @@ export function SearchBox({
|
|||||||
title: tr.title,
|
title: tr.title,
|
||||||
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
|
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
|
||||||
cover: tr.album,
|
cover: tr.album,
|
||||||
// Воспроизведение — M3; пока трек открывает свой альбом.
|
go: () => player.playList([tr], 0, { type: 'search', id: debounced, label: debounced }),
|
||||||
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +150,8 @@ export function SearchBox({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showList = open && debounced !== '' && data !== undefined;
|
const showPopover = open && debounced !== '';
|
||||||
|
const showList = showPopover && data !== undefined;
|
||||||
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
|
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
|
||||||
const optionId = (i: number) => `${listId}-${i}`;
|
const optionId = (i: number) => `${listId}-${i}`;
|
||||||
|
|
||||||
@@ -188,12 +190,17 @@ export function SearchBox({
|
|||||||
<kbd className={s.kbd} aria-hidden>
|
<kbd className={s.kbd} aria-hidden>
|
||||||
/
|
/
|
||||||
</kbd>
|
</kbd>
|
||||||
{showList && (
|
{showPopover && (
|
||||||
<div className={s.popover}>
|
<div className={s.popover}>
|
||||||
{data.corrected_query && (
|
{showPopover && !showList && (
|
||||||
|
<div className={s.nothing} role="status">
|
||||||
|
{t('search.searching')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data?.corrected_query && (
|
||||||
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
|
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
|
||||||
)}
|
)}
|
||||||
<div id={listId} role="listbox" aria-label={t('search.label')}>
|
{showList && <div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||||
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
|
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
|
||||||
{groups.map((g) => {
|
{groups.map((g) => {
|
||||||
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
|
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
|
||||||
@@ -221,7 +228,7 @@ export function SearchBox({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={s.all}
|
className={s.all}
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ export function WaveButton(props: WaveButtonProps) {
|
|||||||
aria-pressed={interactive ? !!props.playing : undefined}
|
aria-pressed={interactive ? !!props.playing : undefined}
|
||||||
aria-hidden={interactive ? undefined : true}
|
aria-hidden={interactive ? undefined : true}
|
||||||
onMouseMove={interactive ? onMove : undefined}
|
onMouseMove={interactive ? onMove : undefined}
|
||||||
|
onFocus={interactive ? () => (s.hoverT = 1) : undefined}
|
||||||
|
onBlur={
|
||||||
|
interactive
|
||||||
|
? () => {
|
||||||
|
s.hoverT = 0;
|
||||||
|
s.pressT = 0;
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
|
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
|
||||||
onMouseLeave={
|
onMouseLeave={
|
||||||
interactive
|
interactive
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ LOG_LEVEL=info
|
|||||||
SUPPORTED_LOCALES=ru,en
|
SUPPORTED_LOCALES=ru,en
|
||||||
MIN_ANDROID_VERSION_CODE=0
|
MIN_ANDROID_VERSION_CODE=0
|
||||||
|
|
||||||
|
# Genius: empty token disables enrichment. Queue and controls: Admin → Sources.
|
||||||
|
GENIUS_ACCESS_TOKEN=
|
||||||
|
GENIUS_REQUEST_INTERVAL=2s
|
||||||
|
|
||||||
# --- Самообновление Android (M5) ---
|
# --- Самообновление Android (M5) ---
|
||||||
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
|
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
|
||||||
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
|
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## Суть
|
## Суть
|
||||||
|
|
||||||
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
||||||
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||||
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
||||||
|
|
||||||
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
## Доступ и учётные записи
|
## Доступ и учётные записи
|
||||||
|
|
||||||
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
||||||
В мобильном приложении на этом экране также указывается **адрес сервера**.
|
В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
|
||||||
- **Регистрации нет.** Учётные записи создаёт администрация.
|
- **Регистрации нет.** Учётные записи создаёт администрация.
|
||||||
- Роли:
|
- Роли:
|
||||||
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
|||||||
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
|
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
|
||||||
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
|
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
|
||||||
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
|
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
|
||||||
|
| [genius.md](genius.md) | Genius: токен, поиск метаданных, парсинг текстов и проверочная команда |
|
||||||
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
|
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
|
||||||
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
|
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
|
||||||
| [web.md](web.md) | Веб-клиент |
|
| [web.md](web.md) | Веб-клиент |
|
||||||
|
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
|
||||||
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
|
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
|
||||||
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
||||||
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
||||||
|
|||||||
@@ -102,8 +102,12 @@
|
|||||||
прочитайте заметки к релизу на странице релизов Gitea.
|
прочитайте заметки к релизу на странице релизов Gitea.
|
||||||
|
|
||||||
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
||||||
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
|
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
|
||||||
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
|
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
|
||||||
|
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. После каждого релиза CI
|
||||||
|
оставляет файлы только у последних 8 релизов и последние 8 прогонов Actions (теги и заметки не удаляет).
|
||||||
|
Та же страница — внизу экрана входа
|
||||||
|
и в меню профиля.
|
||||||
|
|
||||||
## 7. Наблюдение
|
## 7. Наблюдение
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@
|
|||||||
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
|
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url, older:[{version, filename, size, download_url}]}]}` — до 8 релизов на платформу |
|
||||||
|
| GET | `/app/download/{client}/{version}` | файл клиента этой версии (прокси / кэш релиза Gitea) |
|
||||||
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
|
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
|
||||||
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
|
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
- Всё разворачивается на одном сервере через Docker Compose.
|
- Всё разворачивается на одном сервере через Docker Compose.
|
||||||
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
|
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
|
||||||
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
|
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
|
||||||
- Клиенты: веб (SPA) и Android-приложение.
|
- Клиенты: веб (SPA), десктоп (Tauri) и Android-приложение.
|
||||||
|
|
||||||
## 2. Компоненты
|
## 2. Компоненты
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────┐ ┌────────────────┐
|
┌──────────────┐ ┌────────────────┐
|
||||||
Web SPA ─────┐ │ │ │ │
|
Web SPA ─────┐ │ │ │ │
|
||||||
├──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
Desktop ─────┼──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
||||||
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
|
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
|
||||||
│ X-Accel) │ │ │──► Meilisearch
|
│ X-Accel) │ │ │──► Meilisearch
|
||||||
└──────┬───────┘ └───┬────────┬───┘
|
└──────┬───────┘ └───┬────────┬───┘
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
pipu-music/
|
pipu-music/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── web/ # React + Vite SPA
|
│ ├── web/ # React + Vite SPA
|
||||||
|
│ ├── desktop/ # Tauri 2: то же SPA в нативном окне
|
||||||
│ └── mobile/ # React Native (Expo) Android
|
│ └── mobile/ # React Native (Expo) Android
|
||||||
├── packages/ # общий TypeScript-код (pnpm workspaces)
|
├── packages/ # общий TypeScript-код (pnpm workspaces)
|
||||||
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
|
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
||||||
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
||||||
| `TRANSCODE_CONCURRENCY` | `2` | |
|
| `TRANSCODE_CONCURRENCY` | `2` | |
|
||||||
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
|
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/download` |
|
||||||
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
||||||
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
||||||
| `LOG_LEVEL` | `info` | |
|
| `LOG_LEVEL` | `info` | |
|
||||||
|
|||||||
@@ -43,7 +43,13 @@
|
|||||||
| `overlay` | `rgba(3,5,10,.66)` | `rgba(20,30,60,.32)` | Подложка диалогов |
|
| `overlay` | `rgba(3,5,10,.66)` | `rgba(20,30,60,.32)` | Подложка диалогов |
|
||||||
| `glass` | `rgba(12,16,28,.78)` | `rgba(255,255,255,.84)` | Стеклянные карточки (вход, диалоги) + `backdrop-filter: blur(28px) saturate(1.3)` |
|
| `glass` | `rgba(12,16,28,.78)` | `rgba(255,255,255,.84)` | Стеклянные карточки (вход, диалоги) + `backdrop-filter: blur(28px) saturate(1.3)` |
|
||||||
|
|
||||||
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — радиальный градиент `#9CC6FF → #2F80FF → #3A1FA8`.
|
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
|
||||||
|
|
||||||
|
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
|
||||||
|
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
|
||||||
|
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
|
||||||
|
и монохромный слои с отступами для масок лаунчера; фон — `#05070D`.
|
||||||
|
После изменения значков установленные приложения нужно пересобрать и обновить.
|
||||||
|
|
||||||
### Типографика
|
### Типографика
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Десктоп (`apps/desktop`)
|
||||||
|
|
||||||
|
Windows, Linux и macOS: тот же веб-клиент в окне [Tauri 2](https://tauri.app/). Запросы к API идут через локальный прокси на `127.0.0.1`, чтобы плеер не открывал чужой origin. Refresh-токен хранится в системной связке ключей. Тип клиента — `desktop`.
|
||||||
|
|
||||||
|
При первом запуске нужно указать адрес сервера (как в Android). Дальше вход тот же, что в вебе. Кнопка «Другой сервер» в настройках забывает адрес и токен. Трея: левый клик открывает окно; меню — открыть и выход. Клавиши медиаплеера ОС управляют воспроизведением.
|
||||||
|
|
||||||
|
Плейлисты, ссылка на трек и остальной интерфейс совпадают с вебом. Сборка подставляет версию из git-тега.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @pipu/desktop icons
|
||||||
|
pnpm --filter @pipu/desktop dev
|
||||||
|
pnpm --filter @pipu/desktop bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
На Windows получается NSIS-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
|
||||||
|
|
||||||
|
Релиз по тегу собирает Linux `.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Genius: поиск метаданных и получение текста
|
||||||
|
|
||||||
|
Реализован Go-провайдер `services/api/internal/genius` и отдельная команда
|
||||||
|
`services/api/cmd/genius`. Поиск через API возвращает кандидатов, ссылки на изображения
|
||||||
|
и исполнителя. Текст выбранной песни извлекается из HTML страницы: из контейнеров
|
||||||
|
`data-lyrics-container` либо из `songPage.lyricsData.body.html` во встроенном состоянии.
|
||||||
|
JavaScript не исполняется. Используется HTML-парсер `golang.org/x/net/html`.
|
||||||
|
|
||||||
|
Это проверяемый первый этап: команда работает отдельно от сервера. Автоматическое
|
||||||
|
дополнение каталога, хранение текстов в БД, аннотации и отображение в плеере пока
|
||||||
|
не подключены. Поэтому переменная ниже пока нужна именно процессу команды,
|
||||||
|
а не Docker Compose или веб-клиенту.
|
||||||
|
|
||||||
|
## Почему Go
|
||||||
|
|
||||||
|
Основной API Pipu написан на Go. Небольшой провайдер можно подключить к нему без
|
||||||
|
отдельного Python-сервиса. LyricsGenius удобен для Python, но собственный адаптер
|
||||||
|
позволяет явно контролировать выбор версии песни, сетевые ограничения и ошибки.
|
||||||
|
HTML Genius не является стабильным API: при изменении разметки парсер потребует обновления.
|
||||||
|
|
||||||
|
## Получение токена
|
||||||
|
|
||||||
|
1. Войти или зарегистрироваться на https://genius.com.
|
||||||
|
2. Открыть https://genius.com/api-clients и создать новый API Client.
|
||||||
|
3. Указать название, например `Pipu Music`, и URL своего приложения. Если форма
|
||||||
|
требует Redirect URI, указать URL под своим контролем; для чтения с Client Access
|
||||||
|
Token OAuth-переходы и обработчик callback в Pipu не нужны.
|
||||||
|
4. В созданном клиенте нажать **Generate Access Token**.
|
||||||
|
5. Сохранить **Client Access Token** в переменной `GENIUS_ACCESS_TOKEN` процесса.
|
||||||
|
Client ID и Client Secret вместо него не подходят.
|
||||||
|
|
||||||
|
Токен нужен для поиска через `api.genius.com`. Получение HTML по уже известному
|
||||||
|
URL не требует токена и не отправляет заголовок Authorization. Не помещать токен
|
||||||
|
в git, клиентские переменные `VITE_*`, логи или переписку.
|
||||||
|
|
||||||
|
Источник: https://lyricsgenius.readthedocs.io/en/master/setup.html.
|
||||||
|
|
||||||
|
## Проверка в PowerShell 7
|
||||||
|
|
||||||
|
Из корня репозитория:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location services/api
|
||||||
|
$env:GENIUS_ACCESS_TOKEN = Read-Host 'Genius Client Access Token' -MaskInput
|
||||||
|
go run ./cmd/genius -search 'Исполнитель Название песни'
|
||||||
|
```
|
||||||
|
|
||||||
|
Выбрать нужного исполнителя и версию песни из результатов. Команда намеренно
|
||||||
|
не считает первый результат правильным. Затем подставить возвращённый `url`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run ./cmd/genius -url 'https://genius.com/Artist-song-lyrics'
|
||||||
|
Remove-Item Env:GENIUS_ACCESS_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
URL во втором примере — шаблон. Результат — JSON с `text`, `source_url` и временем
|
||||||
|
получения `fetched_at`. Это обычный текст без временной синхронизации.
|
||||||
|
|
||||||
|
## Ограничения и дальнейшее подключение
|
||||||
|
|
||||||
|
- HTTP 401, 403, 404, 429 и другие ошибки возвращаются явно; `HTTPError` сохраняет
|
||||||
|
`Retry-After`. Автоматических повторов и обхода блокировок нет.
|
||||||
|
- Таймаут запроса — 15 секунд, предел ответа — 8 MiB. Разрешён только HTTPS
|
||||||
|
на Genius; перенаправления на другой хост запрещены.
|
||||||
|
- Отсутствие распознаваемого текста возвращает `ErrLyricsUnavailable`; HTML
|
||||||
|
блокировки или навигация не становятся текстом песни.
|
||||||
|
- Перед интеграцией в каталог нужны кэш результатов, лимит частоты запросов,
|
||||||
|
сохранение выбранного Genius ID и обработка временных ошибок без потери старого текста.
|
||||||
|
- Токен API не предоставляет лицензию на тексты. Документация LyricsGenius
|
||||||
|
предупреждает, что парсинг противоречит условиям Genius; отображление и хранение
|
||||||
|
в публичном сервисе требуют отдельной проверки разрешений:
|
||||||
|
https://lyricsgenius.readthedocs.io/en/latest/how_it_works.html.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./internal/genius ./cmd/genius
|
||||||
|
```
|
||||||
|
|
||||||
|
Тесты используют синтетические тексты и подмену HTTP: переносы, HTML-сущности,
|
||||||
|
служебные элементы, встроенное состояние, ошибки HTTP, проверка URL и
|
||||||
|
отсутствие токена при запросе страницы. Реальный доступ к Genius проверяется
|
||||||
|
отдельно из сети сервера с собственным токеном.
|
||||||
@@ -246,7 +246,7 @@ curl -s https://music.example.com/readyz
|
|||||||
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
||||||
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
||||||
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
||||||
ссылка `https://<ваш-домен>/android`.
|
ссылка `https://<ваш-домен>/download`.
|
||||||
|
|
||||||
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
||||||
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
||||||
|
|||||||
@@ -99,13 +99,13 @@
|
|||||||
|
|
||||||
## Самообновление
|
## Самообновление
|
||||||
|
|
||||||
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
|
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, предыдущие версии (до 8 последних релизов, сколько есть), адрес сервера и инструкция.
|
||||||
|
|
||||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||||
|
|
||||||
**Поток:**
|
**Поток:**
|
||||||
1. При запуске (не чаще раза в 6 ч) и вручную в профиле: `GET /api/v1/app/android/latest` на **сервере Pipu**.
|
1. При запуске (не чаще раза в 6 ч) и вручную в профиле: `GET /api/v1/app/android/latest` на **сервере Pipu**.
|
||||||
Сервер сам опрашивает Gitea API (`GET /api/v1/repos/{owner}/{repo}/releases/latest`) с серверным токеном,
|
Сервер сам опрашивает Gitea API (список релизов, до 8 с файлами; fallback — `/releases/latest`) с серверным токеном,
|
||||||
кэширует ответ на 10 мин и APK на диске. Так:
|
кэширует ответ на 10 мин и APK на диске. Так:
|
||||||
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
|
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
|
||||||
- версия приложения согласована с версией сервера (`min_supported` из `server-info`).
|
- версия приложения согласована с версией сервера (`min_supported` из `server-info`).
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
```
|
```
|
||||||
apps/web/src/
|
apps/web/src/
|
||||||
├── app/ # провайдеры, роутер, глобальные стили, гарды
|
├── app/ # провайдеры, роутер, глобальные стили, гарды
|
||||||
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
|
├── pages/ # login, setup-admin, setup-password, download, home, search, artist, album,
|
||||||
│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings,
|
│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings,
|
||||||
│ # admin/{users,sources,library,upload,upload-queue,settings}
|
│ # admin/{users,sources,library,upload,upload-queue,settings}
|
||||||
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
|
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"icons": "node scripts/generate-icons.mjs",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
|
|||||||
@@ -4,6 +4,40 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface paths {
|
export interface paths {
|
||||||
|
"/api/v1/admin/genius": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getGeniusStats"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["controlGenius"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/tracks/{id}/genius": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: components["parameters"]["EntityId"];
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getTrackGenius"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/healthz": {
|
"/healthz": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1293,6 +1327,45 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/app/clients": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Клиенты последних релизов (Android и десктоп)
|
||||||
|
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
|
||||||
|
* У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
|
||||||
|
* Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||||
|
*/
|
||||||
|
get: operations["getAppClients"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/app/download/{client}/{version}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Файл клиента (кэшируется на сервере, поддерживает Range) */
|
||||||
|
get: operations["downloadAppClient"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/app/android/latest": {
|
"/api/v1/app/android/latest": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2030,7 +2103,40 @@ export interface components {
|
|||||||
/** @description Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости */
|
/** @description Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости */
|
||||||
features: components["schemas"]["TrackFeatures"] | null;
|
features: components["schemas"]["TrackFeatures"] | null;
|
||||||
};
|
};
|
||||||
|
GeniusStats: {
|
||||||
|
configured: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
running: boolean;
|
||||||
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
matched: number;
|
||||||
|
lyrics: number;
|
||||||
|
annotations: number;
|
||||||
|
images: number;
|
||||||
|
unmatched: number;
|
||||||
|
errors: number;
|
||||||
|
last_error: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
cooldown_until: string;
|
||||||
|
};
|
||||||
|
GeniusContent: {
|
||||||
|
status: string;
|
||||||
|
lyrics: string;
|
||||||
|
source_url: string;
|
||||||
|
song_image_url: string;
|
||||||
|
artist_image_url: string;
|
||||||
|
description: string;
|
||||||
|
annotations: components["schemas"]["GeniusAnnotation"][];
|
||||||
|
};
|
||||||
|
GeniusAnnotation: {
|
||||||
|
fragment: string;
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
verified: boolean;
|
||||||
|
authors: string;
|
||||||
|
};
|
||||||
Artist: {
|
Artist: {
|
||||||
|
image_url?: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2249,6 +2355,32 @@ export interface components {
|
|||||||
early_skip_rate: number | null;
|
early_skip_rate: number | null;
|
||||||
like_rate: number | null;
|
like_rate: number | null;
|
||||||
};
|
};
|
||||||
|
/** @enum {string} */
|
||||||
|
AppClient: "android" | "windows" | "linux" | "macos";
|
||||||
|
AppClientBuild: {
|
||||||
|
version: string;
|
||||||
|
filename: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
size: number;
|
||||||
|
sha256?: string;
|
||||||
|
download_url: string;
|
||||||
|
};
|
||||||
|
AppClientFile: {
|
||||||
|
client: components["schemas"]["AppClient"];
|
||||||
|
version?: string;
|
||||||
|
filename: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
size: number;
|
||||||
|
sha256?: string;
|
||||||
|
download_url: string;
|
||||||
|
older?: components["schemas"]["AppClientBuild"][];
|
||||||
|
};
|
||||||
|
AppClients: {
|
||||||
|
version: string;
|
||||||
|
version_code: number;
|
||||||
|
notes_md: string;
|
||||||
|
clients: components["schemas"]["AppClientFile"][];
|
||||||
|
};
|
||||||
AndroidRelease: {
|
AndroidRelease: {
|
||||||
/** @example 0.6.1 */
|
/** @example 0.6.1 */
|
||||||
version: string;
|
version: string;
|
||||||
@@ -2326,6 +2458,75 @@ export interface components {
|
|||||||
}
|
}
|
||||||
export type $defs = Record<string, never>;
|
export type $defs = Record<string, never>;
|
||||||
export interface operations {
|
export interface operations {
|
||||||
|
getGeniusStats: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Persistent enrichment statistics (admin only) */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["GeniusStats"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
controlGenius: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @enum {string} */
|
||||||
|
action: "start" | "pause" | "retry";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Updated statistics */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["GeniusStats"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getTrackGenius: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: components["parameters"]["EntityId"];
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Cached lyrics and annotations; never makes an upstream request */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["GeniusContent"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getHealthz: {
|
getHealthz: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4189,6 +4390,51 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getAppClients: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Доступные сборки */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AppClients"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
downloadAppClient: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
client: components["schemas"]["AppClient"];
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Установщик или APK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/octet-stream": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getAndroidLatest: {
|
getAndroidLatest: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -22,17 +22,42 @@
|
|||||||
"logout": "Sign out"
|
"logout": "Sign out"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"link": "Android app",
|
"link": "Download apps",
|
||||||
"title": "pipu for Android",
|
"badge": "Apps",
|
||||||
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
|
"title": "pipu on your devices",
|
||||||
"download": "Download APK",
|
"subtitle": "Clients for this server: phone, desktop and the web. The build for your system comes first.",
|
||||||
|
"platform": {
|
||||||
|
"android": "Android",
|
||||||
|
"windows": "Windows",
|
||||||
|
"linux": "Linux",
|
||||||
|
"macos": "macOS"
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"android": "Download APK",
|
||||||
|
"windows": "Download for Windows",
|
||||||
|
"linux": "Download .deb",
|
||||||
|
"macos": "Download for macOS"
|
||||||
|
},
|
||||||
"version": "version {{version}}",
|
"version": "version {{version}}",
|
||||||
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
|
"other": "Other platforms",
|
||||||
|
"older": "Previous versions",
|
||||||
|
"step_install": {
|
||||||
|
"android": "Open the downloaded file. If Android asks, allow installs from this source.",
|
||||||
|
"windows": "Run the installer and follow the steps. pipu will appear in the Start menu.",
|
||||||
|
"linux": "Install the package: sudo apt install ./pipu-music-….deb. Needs WebKitGTK 4.1 and GStreamer.",
|
||||||
|
"macos": "Open the disk image and drag pipu into Applications."
|
||||||
|
},
|
||||||
"step_server": "Launch pipu and enter the server address:",
|
"step_server": "Launch pipu and enter the server address:",
|
||||||
"copy_server": "Copy the server address",
|
"copy_server": "Copy the server address",
|
||||||
"step_updates": "Sign in with your account. The app offers new versions by itself.",
|
"step_updates": {
|
||||||
|
"android": "Sign in with your account. The app offers new versions by itself.",
|
||||||
|
"windows": "Sign in with your account. Get a new version from this page.",
|
||||||
|
"linux": "Sign in with your account. Get a new version from this page.",
|
||||||
|
"macos": "Sign in with your account. Get a new version from this page."
|
||||||
|
},
|
||||||
"checksum": "SHA-256 checksum",
|
"checksum": "SHA-256 checksum",
|
||||||
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
"unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
||||||
|
"unavailable_platform": "The latest release does not include a build for {{platform}} yet.",
|
||||||
"to_login": "Sign in to the web app"
|
"to_login": "Sign in to the web app"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
{
|
{
|
||||||
|
"genius": {
|
||||||
|
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.",
|
||||||
|
"loading": "Loading…", "disabled": "Not configured", "paused": "Paused", "cooldown": "Waiting after Genius rate limit", "running": "Processing", "waiting": "Waiting for new tracks",
|
||||||
|
"configure": "Add GENIUS_ACCESS_TOKEN to deploy/.env and recreate the API container.",
|
||||||
|
"total": "Total tracks", "pending": "Queued", "matched": "Matched on Genius", "lyrics": "Lyrics", "annotations": "With annotations", "images": "With artwork", "unmatched": "No exact match", "errors": "Errors / incomplete data",
|
||||||
|
"last_error": "Last error", "token_error": "Genius rejected the token. Check it and restart the API.", "access_error": "Genius restricted access. Processing is paused.", "no_lyrics": "Lyrics are currently unavailable.", "request_error": "Some data could not be retrieved. A retry is scheduled automatically.", "resume_at": "Retry no earlier than",
|
||||||
|
"start": "Resume", "pause": "Pause", "retry": "Retry errors and unmatched", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "pipu",
|
"name": "pipu",
|
||||||
"tagline": "Your music from your own server"
|
"tagline": "Your music from your own server"
|
||||||
@@ -19,6 +27,7 @@
|
|||||||
"en": "English"
|
"en": "English"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
"skip": "Skip to content",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
"main": "Navigation",
|
"main": "Navigation",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -41,7 +50,7 @@
|
|||||||
"menu": "Profile menu",
|
"menu": "Profile menu",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Sign out",
|
"logout": "Sign out",
|
||||||
"android_app": "Android app"
|
"download_apps": "Download apps"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "Administrator",
|
"admin": "Administrator",
|
||||||
@@ -88,7 +97,9 @@
|
|||||||
"source_queue": "Queue",
|
"source_queue": "Queue",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"source_wave_seed": "Artist wave · {{label}}",
|
"source_wave_seed": "Artist wave · {{label}}",
|
||||||
"queue_wave": "The wave never ends: next tracks are picked as you listen"
|
"queue_wave": "The wave never ends: next tracks are picked as you listen",
|
||||||
|
"now_playing": "Now playing",
|
||||||
|
"expand": "Open player"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -153,6 +164,8 @@
|
|||||||
"tracks": "Tracks",
|
"tracks": "Tracks",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"nothing": "Nothing found",
|
"nothing": "Nothing found",
|
||||||
|
"searching": "Searching…",
|
||||||
|
"type_query": "Type a query in the search field above",
|
||||||
"corrected": "Showing results for “{{query}}”",
|
"corrected": "Showing results for “{{query}}”",
|
||||||
"show_all": "All results",
|
"show_all": "All results",
|
||||||
"title": "Search: {{query}}",
|
"title": "Search: {{query}}",
|
||||||
|
|||||||
@@ -22,17 +22,42 @@
|
|||||||
"logout": "Выйти"
|
"logout": "Выйти"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"link": "Приложение для Android",
|
"link": "Скачать приложения",
|
||||||
"title": "pipu для Android",
|
"badge": "Приложения",
|
||||||
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
|
"title": "pipu на ваших устройствах",
|
||||||
"download": "Скачать APK",
|
"subtitle": "Клиенты для этого сервера: телефон, компьютер и веб. Сначала предлагается сборка под вашу систему.",
|
||||||
|
"platform": {
|
||||||
|
"android": "Android",
|
||||||
|
"windows": "Windows",
|
||||||
|
"linux": "Linux",
|
||||||
|
"macos": "macOS"
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"android": "Скачать APK",
|
||||||
|
"windows": "Скачать для Windows",
|
||||||
|
"linux": "Скачать .deb",
|
||||||
|
"macos": "Скачать для macOS"
|
||||||
|
},
|
||||||
"version": "версия {{version}}",
|
"version": "версия {{version}}",
|
||||||
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
"other": "Другие платформы",
|
||||||
|
"older": "Предыдущие версии",
|
||||||
|
"step_install": {
|
||||||
|
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
||||||
|
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
|
||||||
|
"linux": "Установите пакет: sudo apt install ./pipu-music-….deb. Нужны WebKitGTK 4.1 и GStreamer.",
|
||||||
|
"macos": "Откройте образ и перетащите pipu в Программы."
|
||||||
|
},
|
||||||
"step_server": "Запустите pipu и введите адрес сервера:",
|
"step_server": "Запустите pipu и введите адрес сервера:",
|
||||||
"copy_server": "Скопировать адрес сервера",
|
"copy_server": "Скопировать адрес сервера",
|
||||||
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
"step_updates": {
|
||||||
|
"android": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
||||||
|
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||||
|
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||||
|
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы."
|
||||||
|
},
|
||||||
"checksum": "Контрольная сумма SHA-256",
|
"checksum": "Контрольная сумма SHA-256",
|
||||||
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
||||||
|
"unavailable_platform": "В последнем релизе пока нет сборки для {{platform}}.",
|
||||||
"to_login": "Войти в веб-версию"
|
"to_login": "Войти в веб-версию"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||