Compare commits

...
13 Commits
Author SHA1 Message Date
mrleo1nidandCursor 72567acd28 feat(web): polish catalog motion, empty states, and mobile now-playing
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:53:37 +03:00
mrleo1nidandCursor 39aaaed969 feat(web): list previous app builds on the download page
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 2m1s
ci / db / db (push) Successful in 1m4s
GET /app/clients now returns up to 8 Gitea releases per OS so users can download older installers while they still exist after prune.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:10:03 +03:00
mrleo1nidandCursor efe78d7e07 feat(desktop): wrap the web client in a Tauri app
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:45:03 +03:00
125 changed files with 5937 additions and 629 deletions

No files matched your search

+33
View File
@@ -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
+5 -1
View File
@@ -57,5 +57,9 @@ jobs:
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }} key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium # git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
- run: |
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
pnpm --filter @pipu/web exec playwright install chromium
- run: pnpm --filter @pipu/web test:e2e - run: pnpm --filter @pipu/web test:e2e
+147 -5
View File
@@ -1,6 +1,8 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK. # Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы # Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs). # и переписывают статус: собран / не удалась (scripts/release.mjs).
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh). # Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
# #
# Секреты (Settings → Actions → Secrets): # Секреты (Settings → Actions → Secrets):
@@ -132,9 +134,10 @@ jobs:
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace # Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки. # metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново" echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
./gradlew --stop || true ./gradlew --stop || true
find ~/.gradle -name "*.lock" -delete || true
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-* rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
"${gradle[@]}" assembleRelease -x lintVitalRelease "${gradle[@]}" assembleRelease -x lintVitalRelease
else else
@@ -196,3 +199,142 @@ jobs:
- name: Статус в релизе — сборка отменена - name: Статус в релизе — сборка отменена
if: cancelled() if: cancelled()
run: node scripts/release.mjs android cancelled run: node scripts/release.mjs android cancelled
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
desktop:
name: desktop (Linux и Windows в релиз)
needs: [release, android]
if: always() && needs.release.result == 'success'
timeout-minutes: 90
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
CARGO_BUILD_JOBS: "2"
XWIN_CACHE_DIR: /opt/ci-cache/xwin
steps:
- uses: actions/checkout@v4
- name: Статус в релизе — собирается
run: |
node scripts/release.mjs desktop linux building
node scripts/release.mjs desktop windows building
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libdbus-1-dev libsecret-1-dev patchelf \
nsis lld llvm clang
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-xwin
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
restore-keys: cargo-desktop-
- run: pnpm install --frozen-lockfile
- name: Сборка deb
id: linux
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:linux
bundle=src-tauri/target/release/bundle
shopt -s nullglob
for f in "$bundle"/deb/*.deb; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
done
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
rm -rf src-tauri/target/release
- name: Linux в релиз
id: linux-upload
run: |
set -euo pipefail
f="pipu-music-${PIPU_VERSION}-linux.deb"
if [ ! -f "$f" ]; then
echo "::error::нет $f после сборки"
exit 1
fi
node scripts/release.mjs desktop linux success "$f"
- name: Windows toolchain
if: ${{ !cancelled() }}
run: |
set -euo pipefail
rustup target add x86_64-pc-windows-msvc
if ! command -v cargo-xwin >/dev/null; then
cargo install --locked cargo-xwin
fi
if [ ! -d /opt/ci-cache ]; then
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
mkdir -p "$HOME/.cache/xwin"
else
mkdir -p /opt/ci-cache/xwin
fi
- name: Сборка NSIS
id: win
if: ${{ !cancelled() }}
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:windows:cross
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
shopt -s nullglob
copied=
for f in "$bundle"/*-setup.exe; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
copied=1
done
if [ -z "$copied" ]; then
echo "::error::нет NSIS setup.exe после сборки в $bundle"
ls -la "$bundle" || true
exit 1
fi
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Windows в релиз
id: win-upload
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Статус Linux — сборка не удалась
if: failure() && steps.linux-upload.outcome != 'success'
run: node scripts/release.mjs desktop linux failure
- name: Статус Windows — сборка не удалась
if: failure() && steps.win-upload.outcome != 'success'
run: node scripts/release.mjs desktop windows failure
- name: Статус в релизе — сборка отменена
if: cancelled()
run: |
node scripts/release.mjs desktop linux cancelled
node scripts/release.mjs desktop windows cancelled
prune:
name: prune (старые файлы и прогоны)
needs: [release, android, desktop]
if: always() && needs.release.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}" }
- name: Оставить 8 релизов и 8 прогонов
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: node scripts/release.mjs prune
+3
View File
@@ -41,3 +41,6 @@ apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/ apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/tauri.release.json apps/desktop/src-tauri/tauri.release.json
apps/desktop/release/ apps/desktop/release/
# Intermediate platform icon renders (final assets live under apps/).
/artifacts/icon-build/
+2 -1
View File
@@ -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, скрипты установки и обновления |
+187 -1
View File
@@ -22,6 +22,51 @@ tags:
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
/api/v1/admin/genius:
get:
operationId: getGeniusStats
tags: [admin]
responses:
"200":
description: Persistent enrichment statistics (admin only)
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
post:
operationId: controlGenius
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action:
type: string
enum: [start, pause, retry]
responses:
"200":
description: Updated statistics
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
/api/v1/tracks/{id}/genius:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: getTrackGenius
tags: [catalog]
responses:
"200":
description: Cached lyrics and annotations; never makes an upstream request
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusContent"
/healthz: /healthz:
get: get:
operationId: getHealthz operationId: getHealthz
@@ -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]
+19
View File
@@ -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}`);
+18
View File
@@ -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"
}
}
+44
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "Only the bundled main window may invoke desktop integration.",
"windows": ["main"],
"permissions": ["core:default", "core:event:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+157
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
pipu_desktop_lib::run();
}
+62
View File
@@ -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:?}"))
}
}
+170
View File
@@ -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");
}
}
+80
View File
@@ -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()),
},
}
}
+31
View File
@@ -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 } }
}
}
+6
View File
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
name: 'Pipu Music', name: 'Pipu Music',
slug: 'pipu-music', slug: 'pipu-music',
scheme: 'pipu', scheme: 'pipu',
icon: './assets/icon.png',
version, version,
orientation: 'portrait', orientation: 'portrait',
userInterfaceStyle: 'automatic', userInterfaceStyle: 'automatic',
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
newArchEnabled: false, newArchEnabled: false,
backgroundColor: '#05070D', backgroundColor: '#05070D',
android: { android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
monochromeImage: './assets/monochrome-icon.png',
backgroundColor: '#05070D',
},
package: 'app.pipu.music', package: 'app.pipu.music',
versionCode, versionCode,
edgeToEdgeEnabled: true, edgeToEdgeEnabled: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+6
View File
@@ -91,6 +91,12 @@ export default function PlayerScreen() {
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}> <Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')} {track.artists.map((a) => a.name).join(', ')}
</Text> </Text>
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
<Icon name="playlist-add" size={24} color={colors.text2} />
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
</Pressable>
</View> </View>
<View style={{ gap: 6 }}> <View style={{ gap: 6 }}>
+6 -1
View File
@@ -272,7 +272,7 @@ class Downloads {
if (track.album.cover_url) { if (track.album.cover_url) {
const cover = this.cover(track.album.id); const cover = this.cover(track.album.id);
const info = await FileSystem.getInfoAsync(cover); const info = await FileSystem.getInfoAsync(cover);
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined); if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
} }
const info = await FileSystem.getInfoAsync(this.file(id)); const info = await FileSystem.getInfoAsync(this.file(id));
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track); await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
@@ -286,4 +286,9 @@ class Downloads {
} }
} }
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
export function sizedCover(url: string, size: number): string {
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
}
export const downloads = new Downloads(); export const downloads = new Downloads();
+13 -10
View File
@@ -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) {
+2 -2
View File
@@ -20,7 +20,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session'; import { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads'; import { downloads, sizedCover } from '../offline/downloads';
import { absolute } from '../player/controller'; import { absolute } from '../player/controller';
import { radius, useColors, type Colors } from '../theme'; import { radius, useColors, type Colors } from '../theme';
@@ -161,7 +161,7 @@ export function Cover({
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200; const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети. // У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined)); const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined); const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
const bg = album.cover_colors?.[0] ?? colors.surface3; const bg = album.cover_colors?.[0] ?? colors.surface3;
return ( return (
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}> <View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
+3 -1
View File
@@ -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();
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<title>pipu</title> <title>pipu</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 840 B

+21 -1
View File
@@ -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 });
+40 -9
View File
@@ -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;
+28
View File
@@ -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__;
});
});
+94
View File
@@ -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;
}
+57 -2
View File
@@ -8,12 +8,18 @@
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
} }
.cover img { .coverImg {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease;
}
.coverImgOn {
opacity: 1;
} }
.round { .round {
@@ -59,10 +65,52 @@
width: 128px; width: 128px;
} }
.cardCoverWrap {
position: relative;
margin-bottom: 4px;
overflow: hidden;
border-radius: 12px;
}
.cardCover { .cardCover {
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 12px; border-radius: 12px;
margin-bottom: 4px; transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card:hover .cardCover,
.card:focus-within .cardCover {
transform: scale(1.04);
}
.cardPlay {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 1;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
color: #05070d;
opacity: 0;
transform: translateY(6px);
transition:
opacity 0.18s,
transform 0.18s;
}
.card:hover .cardPlay,
.card:focus-within .cardPlay,
.cardPlay:focus-visible {
opacity: 1;
transform: none;
}
.cardPlay:active {
transform: scale(0.94);
} }
.cardTitle { .cardTitle {
@@ -104,6 +152,12 @@
min-height: 56px; min-height: 56px;
padding: 6px 12px; padding: 6px 12px;
border-radius: 10px; border-radius: 10px;
animation: pipuIn 0.2s ease-out both;
animation-delay: var(--stagger, 0ms);
}
.trackStatic {
animation: none;
} }
.track:has(.trackAlbum) { .track:has(.trackAlbum) {
@@ -272,6 +326,7 @@
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.cardPlay { opacity: 1; transform: none; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; } .track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; } .track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
+37 -4
View File
@@ -1,10 +1,12 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, type ReactNode } from 'react'; import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists'; import { AddToPlaylist } from './playlists';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context'; import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player'; import type { PlayContext } from '../player/player';
@@ -28,6 +30,7 @@ function fallbackColors(id: string): string[] {
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */ /** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string { export function coverSrc(url: string, cssPx: number): string {
if (!url.includes('/api/v1/covers/')) return url;
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1); const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200; const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`; return `${url}&size=${size}`;
@@ -58,7 +61,7 @@ export function Cover({
aria-hidden aria-hidden
> >
{album.cover_url ? ( {album.cover_url ? (
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" /> <CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
) : ( ) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} /> <Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)} )}
@@ -82,10 +85,39 @@ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
); );
} }
function CoverImg({ src }: { src: string }) {
const [ready, setReady] = useState(false);
return (
<img
src={src}
alt=""
loading="lazy"
className={cx(s.coverImg, ready && s.coverImgOn)}
onLoad={() => setReady(true)}
/>
);
}
export function AlbumCard({ album }: { album: AlbumSummary }) { export function AlbumCard({ album }: { album: AlbumSummary }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const play = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
void (async () => {
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
})();
};
return ( return (
<Link to="/album/$id" params={{ id: album.id }} className={s.card}> <Link to="/album/$id" params={{ id: album.id }} className={s.card}>
<Cover album={album} size="100%" className={s.cardCover} /> <span className={s.cardCoverWrap}>
<Cover album={album} size="100%" className={s.cardCover} />
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
<Icon src={playIcon} size={22} />
</button>
</span>
<span className={s.cardTitle}>{album.title}</span> <span className={s.cardTitle}>{album.title}</span>
<span className={s.cardMeta}> <span className={s.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')} {[album.artist.name, album.year].filter(Boolean).join(' · ')}
@@ -154,7 +186,8 @@ export function TrackList({
role="listitem" role="listitem"
tabIndex={tr.available ? 0 : -1} tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined} aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)} className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
title={tr.available ? undefined : t('catalog.unavailable')} title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => { onClick={(e) => {
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play(); if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
+10 -9
View File
@@ -1,12 +1,12 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } 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} />}
</>; </>;
} }
+12 -3
View File
@@ -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>,
); );
+28 -17
View File
@@ -4,12 +4,12 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog'; import { Cover, TrackList, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists'; import { AddToPlaylist } from '../entities/playlists';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { playIcon, shuffleIcon } from '../shared/ui/icons'; import { playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui'; import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
@@ -23,30 +23,41 @@ export function AlbumPage({ id }: { id: string }) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })), queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
}); });
if (album.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (album.isError) return <NotFound error={album.error} />; if (album.isError) return <NotFound error={album.error} />;
const a = album.data; const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null; if (!a) return null;
const ctx = { type: 'album' as const, id, label: a?.title }; const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a.title };
return ( return (
<div className={s.page}> <div className={s.page}>
<header className={c.hero}> <header className={c.hero}>
{a.cover_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(a.cover_url, 300)} alt="" />
</span>
)}
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />} {colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
<Cover album={a ?? { id }} size={200} /> <Cover album={a} size={200} className={c.heroCover} />
<div className={c.heroBody}> <div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span> <span className={c.kind}>{t('catalog.album')}</span>
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1> <h1 className={c.heroTitle}>{a.title}</h1>
{a && ( <span className={c.heroMeta}>
<span className={c.heroMeta}> <Link to="/artist/$id" params={{ id: a.artist.id }}>
<Link to="/artist/$id" params={{ id: a.artist.id }}> {a.artist.name}
{a.artist.name} </Link>
</Link> {a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)} </span>
</span> {a.tracks.some((tr) => tr.available) && (
)}
{a && a.tracks.some((tr) => tr.available) && (
<div className={c.heroActions}> <div className={c.heroActions}>
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} /> <AddToPlaylist trackIds={a.tracks.map((tr) => tr.id)} />
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}> <Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
{t('catalog.play')} {t('catalog.play')}
</Button> </Button>
@@ -67,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
)} )}
</div> </div>
</header> </header>
{a && <TrackList tracks={a.tracks} numbered context={ctx} />} <TrackList tracks={a.tracks} numbered context={ctx} />
</div> </div>
); );
} }
+93 -32
View File
@@ -1,25 +1,27 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { formatBytes } from '../shared/lib/format'; import { formatBytes } from '../shared/lib/format';
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons'; import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui'; import { Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout'; import { AuthLayout } from './AuthLayout';
import s from './auth.module.css'; import s from './auth.module.css';
/** /**
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку. * Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление). * Сборки — до 8 последних релизов Gitea через сервер; первым предлагается клиент текущей ОС.
*/ */
export function AppDownloadPage() { export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']); const { t, i18n } = useTranslation(['auth', 'common']);
const session = useSession(); const session = useSession();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const detected = useMemo(() => detectAppClient(), []);
const latest = useQuery({ const latest = useQuery({
queryKey: ['android-latest'], queryKey: ['app-clients'],
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null, queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
}); });
const server = location.origin; const server = location.origin;
@@ -28,39 +30,69 @@ export function AppDownloadPage() {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
}); });
const r = latest.data; const data = latest.data;
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
const primary = detected ? files[detected] : undefined;
const others = otherAppClients(detected).filter((id) => files[id]);
return ( return (
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}> <AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
<span className={s.badge}>Android</span> <span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
<h1>{t('app.title')}</h1> <h1>{t('app.title')}</h1>
<p className={s.lead}>{t('app.subtitle')}</p> <p className={s.lead}>{t('app.subtitle')}</p>
{latest.isPending ? null : r ? ( {latest.isPending ? (
<p role="status">{t('common:loading')}</p>
) : latest.isError ? (
<div className={s.hint}>{t('app.unavailable')}</div>
) : data ? (
<> <>
<a className={s.download} href={r.download_url} download> {primary ? (
<Icon src={downloadIcon} size={22} /> <>
<span> <DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: primary.version ?? data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
{t('app.download')} <OlderVersions items={primary.older ?? []} />
<span className={s.downloadMeta}> </>
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)} ) : detected ? (
</span> <div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
</span> ) : null}
</a> {(primary || others.length > 0) && (
<ol className={s.steps}> <ol className={s.steps}>
<li>{t('app.step_install')}</li> {detected && <li>{t(`app.step_install.${detected}`)}</li>}
<li> <li>
{t('app.step_server')} {t('app.step_server')}
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}> <button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
<code>{server}</code> <code>{server}</code>
<Icon src={copied ? checkIcon : copyIcon} size={16} /> <Icon src={copied ? checkIcon : copyIcon} size={16} />
</button> </button>
</li> </li>
<li>{t('app.step_updates')}</li> {detected && <li>{t(`app.step_updates.${detected}`)}</li>}
</ol> </ol>
<details className={s.details}> )}
<summary>{t('app.checksum')}</summary> {primary?.sha256 && (
<code>{r.sha256}</code> <details className={s.details}>
</details> <summary>{t('app.checksum')}</summary>
<code>{primary.sha256}</code>
</details>
)}
{others.length > 0 && (
<div className={s.otherClients}>
<p className={s.otherTitle}>{t('app.other')}</p>
{others.map((id) => {
const file = files[id]!;
return (
<div key={id} className={s.otherBlock}>
<a className={s.downloadAlt} href={file.download_url} download={file.filename}>
{t(`app.download.${id}`)}
<span>
{t('app.version', { version: file.version ?? data.version })} · {formatBytes(file.size, i18n.language)}
</span>
</a>
<OlderVersions items={file.older ?? []} />
</div>
);
})}
</div>
)}
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
</> </>
) : ( ) : (
<div className={s.hint}>{t('app.unavailable')}</div> <div className={s.hint}>{t('app.unavailable')}</div>
@@ -68,3 +100,32 @@ export function AppDownloadPage() {
</AuthLayout> </AuthLayout>
); );
} }
function OlderVersions({ items }: { items: { version: string; filename?: string; size: number; download_url: string }[] }) {
const { t, i18n } = useTranslation(['auth', 'common']);
if (items.length === 0) return null;
return (
<details className={s.older}>
<summary>{t('app.older')}</summary>
{items.map((b) => (
<a key={b.version} className={s.downloadAlt} href={b.download_url} download={b.filename}>
{t('app.version', { version: b.version })}
<span>{formatBytes(b.size, i18n.language)}</span>
</a>
))}
</details>
);
}
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
const { t } = useTranslation('auth');
return (
<a className={s.download} href={href} download>
<Icon src={downloadIcon} size={22} />
<span>
{t(`app.download.${client}`)}
<span className={s.downloadMeta}>{meta}</span>
</span>
</a>
);
}
+35 -26
View File
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { AlbumGrid, Cover, TrackList } from '../entities/catalog'; import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { playIcon, waveIcon } from '../shared/ui/icons'; import { playIcon, waveIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui'; import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
@@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items, queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
}); });
if (artist.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (artist.isError) return <NotFound error={artist.error} />; if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data; const a = artist.data;
if (!a) return null;
const tracks = top.data ?? []; const tracks = top.data ?? [];
const ctx = { type: 'artist' as const, id, label: a?.name }; const ctx = { type: 'artist' as const, id, label: a.name };
const own = albums.data?.filter((al) => al.artist.id === id) ?? []; const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? []; const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
return ( return (
<div className={s.page}> <div className={s.page}>
<header className={c.hero}> <header className={c.hero}>
<Cover album={{ id }} size={168} round /> {a.image_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(a.image_url, 300)} alt="" />
</span>
)}
<Cover album={{ id, cover_url: a.image_url }} size={168} round className={c.heroCover} />
<div className={c.heroBody}> <div className={c.heroBody}>
<span className={c.kind}>{t('catalog.artist')}</span> <span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1> <h1 className={c.heroTitle}>{a.name}</h1>
{a && ( <span className={c.heroMeta}>
<span className={c.heroMeta}> {t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })} </span>
</span> <div className={c.heroActions}>
)} <Button
{a && ( variant="primary"
<div className={c.heroActions}> className={c.pill}
<Button icon={waveIcon}
variant="primary" onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
className={c.pill} >
icon={waveIcon} {t('catalog.artist_wave')}
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)} </Button>
> {tracks.length > 0 && (
{t('catalog.artist_wave')} <Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
{t('catalog.play')}
</Button> </Button>
{tracks.length > 0 && ( )}
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}> </div>
{t('catalog.play')}
</Button>
)}
</div>
)}
</div> </div>
</header> </header>
{tracks.length > 0 && ( {tracks.length > 0 && (
+6 -1
View File
@@ -2,16 +2,21 @@ import type { ReactNode } from 'react';
import { Logo } from '../shared/ui/ui'; import { Logo } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch'; import { LanguageSwitch } from '../widgets/LanguageSwitch';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './auth.module.css'; import s from './auth.module.css';
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */ /** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) { export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
return ( return (
<div className={s.screen}> <div className={s.screen}>
<div className={s.backdrop} aria-hidden> <div className={s.backdrop} aria-hidden>
<span className={s.blob1} /> <span className={s.blob1} />
<span className={s.blob2} /> <span className={s.blob2} />
<span className={s.blob3} /> <span className={s.blob3} />
<div className={s.waveDecor}>
<WaveButton interactive={false} size={620} label="" theme={theme} />
</div>
</div> </div>
<header className={s.top}> <header className={s.top}>
<Logo /> <Logo />
+18 -3
View File
@@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons'; import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui'; import { Button, Dialog, EmptyState, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import c from './catalog.module.css'; import c from './catalog.module.css';
import s from './page.module.css'; import s from './page.module.css';
@@ -109,7 +109,12 @@ function History() {
</div> </div>
)} )}
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError> <FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>} {history.isPending && <PageSkeleton />}
{history.isSuccess && items.length === 0 && (
<EmptyState icon={historyFillIcon} action={<Link to="/">{t('catalog.back_home')}</Link>}>
{t('collection.empty_history')}
</EmptyState>
)}
{groups.map((g) => ( {groups.map((g) => (
<section key={g.key} className={c.section}> <section key={g.key} className={c.section}>
<h2 className={c.day}>{g.title}</h2> <h2 className={c.day}>{g.title}</h2>
@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
</div> </div>
)} )}
<FormError>{list.error ? errorText(list.error) : null}</FormError> <FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <PageSkeleton />}
{list.isSuccess && tracks.length === 0 && ( {list.isSuccess && tracks.length === 0 && (
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div> <EmptyState
icon={reaction === 'like' ? likeFillIcon : dislikeFillIcon}
action={
<Link to="/search" search={{ q: '' }}>
{t('playlists.find_tracks')}
</Link>
}
>
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
</EmptyState>
)} )}
{tracks.length > 0 && ( {tracks.length > 0 && (
<TrackList <TrackList
+55
View File
@@ -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>
);
}
+14 -1
View File
@@ -21,7 +21,7 @@ import {
waveSunIcon, waveSunIcon,
waveWeekendIcon, waveWeekendIcon,
} from '../shared/ui/icons'; } from '../shared/ui/icons';
import { cx, FormError, Icon } from '../shared/ui/ui'; import { cx, FormError, Icon, Skeleton } from '../shared/ui/ui';
import { WaveButton } from '../widgets/wave/WaveButton'; import { WaveButton } from '../widgets/wave/WaveButton';
import s from './home.module.css'; import s from './home.module.css';
import { Onboarding } from './Onboarding'; import { Onboarding } from './Onboarding';
@@ -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 && (
+9 -1
View File
@@ -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
} }
+67 -16
View File
@@ -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>
);
}
+7 -2
View File
@@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog'; import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { Button, FormError } from '../shared/ui/ui'; import { searchIcon } from '../shared/ui/icons';
import { Button, EmptyState, FormError, PageSkeleton } from '../shared/ui/ui';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
@@ -68,7 +69,11 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
</div> </div>
<FormError>{results.error ? errorText(results.error) : null}</FormError> <FormError>{results.error ? errorText(results.error) : null}</FormError>
{empty && <div className={s.empty}>{t('search.nothing')}</div>} {query === '' && <EmptyState icon={searchIcon}>{t('search.type_query')}</EmptyState>}
{query !== '' && results.isPending && <PageSkeleton />}
{empty && (
<EmptyState icon={searchIcon}>{t('search.nothing')}</EmptyState>
)}
{artists.length > 0 && ( {artists.length > 0 && (
<section className={c.section}> <section className={c.section}>
+17
View File
@@ -1,10 +1,12 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { 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>
+36
View File
@@ -0,0 +1,36 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { FormError } from '../shared/ui/ui';
import s from './page.module.css';
import g from './genius.module.css';
export function TrackGenius({ id }: { id: string }) {
const session = useSession();
const { t } = useTranslation();
const errorText = useErrorText();
const query = useQuery({
queryKey: ['track-genius', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}/genius', { params: { path: { id } } })),
refetchInterval: (q) => ['pending', 'processing'].includes(q.state.data?.status ?? '') ? 10000 : false,
});
const data = query.data;
if (data?.status === 'disabled') return null;
return <section className={s.section}>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p>{t('genius.loading')}</p>}
{data?.description && <><h2>{t('genius.about')}</h2><p className={g.lyrics}>{data.description}</p></>}
<div className={s.head}><h2>{t('genius.lyrics')}</h2>{data?.source_url && <a href={data.source_url} target="_blank" rel="noopener noreferrer">Genius ↗</a>}</div>
{data && (data.lyrics ? <div className={g.lyrics}>{data.lyrics}</div> : <p className={s.subtitle}>{['pending', 'processing'].includes(data.status) ? t('genius.pending_text') : t('genius.no_lyrics')}</p>)}
{!!data?.annotations.length && <>
<h2>{t('genius.explanations')}</h2>
{data.annotations.map((a, i) => <details className={g.annotation} key={`${a.url}-${i}`}>
<summary>{a.fragment}{a.verified && <span className={g.badge}>{t('genius.verified')}</span>}</summary>
<p className={g.lyrics}>{a.text}</p>
<p className={s.subtitle}>{a.authors}{a.url && <> · <a href={a.url} target="_blank" rel="noopener noreferrer">Genius ↗</a></>}</p>
</details>)}
</>}
</section>;
}
+54 -12
View File
@@ -4,35 +4,77 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { ArtistLinks, Cover, TrackList } from '../entities/catalog'; import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { pauseIcon, playIcon } from '../shared/ui/icons';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
import { TrackGenius } from './TrackGenius';
export function TrackPage({ id }: { id: string }) { export function TrackPage({ id }: { id: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const player = usePlayer();
const st = usePlayerState();
const track = useQuery({ const track = useQuery({
queryKey: ['track', id], queryKey: ['track', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })), queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
}); });
if (track.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (track.isError) return <NotFound error={track.error} />; if (track.isError) return <NotFound error={track.error} />;
const tr = track.data; const tr = track.data;
if (!tr) return null;
const ctx = { type: 'queue' as const };
const current = st.track?.id === tr.id;
const playing = current && st.playing;
return ( return (
<div className={s.page}> <div className={s.page}>
<header className={c.hero}> <div className={c.listen}>
{tr && <Cover album={tr.album} size={200} />} <header className={c.hero}>
<div className={c.heroBody}> {tr.album.cover_url && (
<span className={c.kind}>{t('player.track_page')}</span> <span className={c.heroBg} aria-hidden>
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1> <img src={coverSrc(tr.album.cover_url, 300)} alt="" />
{tr && <> </span>
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span> )}
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link> <Cover album={tr.album} size={280} className={c.heroCover} />
</>} <div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr.title}</h1>
<span className={c.heroMeta}>
<ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}
</span>
<Link to="/album/$id" params={{ id: tr.album.id }}>
{tr.album.title}
</Link>
<div className={c.heroActions}>
{tr.available && (
<Button
variant="primary"
className={c.pill}
icon={playing ? pauseIcon : playIcon}
onClick={() => (current ? player.toggle() : player.playList([tr], 0, ctx))}
>
{t(playing ? 'player.pause' : 'catalog.play')}
</Button>
)}
<AddToPlaylist trackIds={[tr.id]} />
</div>
</div>
</header>
<div className={c.listenLyrics}>
<TrackGenius id={id} />
</div> </div>
</header> </div>
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
</div> </div>
); );
} }
+3 -3
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { formatDateTime } from '../../shared/lib/format'; import { formatDateTime } from '../../shared/lib/format';
import { Button, FormError } from '../../shared/ui/ui'; import { Button, EmptyState, FormError, PageSkeleton } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
type Entry = components['schemas']['AuditEntry']; type Entry = components['schemas']['AuditEntry'];
@@ -67,8 +67,8 @@ export function AuditPage() {
))} ))}
</tbody> </tbody>
</table> </table>
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>} {list.isSuccess && entries.length === 0 && <EmptyState>{t('audit.empty')}</EmptyState>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>} {list.isPending && <PageSkeleton />}
</div> </div>
{list.hasNextPage && ( {list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}> <Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
+46
View File
@@ -0,0 +1,46 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import g from '../genius.module.css';
export function GeniusPanel() {
const { t } = useTranslation();
const session = useSession();
const queries = useQueryClient();
const errorText = useErrorText();
const stats = useQuery({
queryKey: ['genius-stats'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/genius')),
refetchInterval: 5000,
});
const control = useMutation({
mutationFn: async (action: 'start' | 'pause' | 'retry') =>
unwrap(await session.client.POST('/api/v1/admin/genius', { body: { action } })),
onSuccess: (data) => { queries.setQueryData(['genius-stats'], data); },
});
const data = stats.data;
const cooling = data && Date.parse(data.cooldown_until) > Date.now();
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting');
return <section className={s.section} aria-label="Genius">
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
<p className={s.subtitle}>{t('genius.description')}</p>
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
{data && <>
{!data.configured && <p>{t('genius.configure')}</p>}
<dl className={g.stats}>
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)}
</dl>
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
<div className={g.actions}>
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
</div>
</>}
</section>;
}
+10 -3
View File
@@ -5,10 +5,11 @@ import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession, useUser } from '../../app/session';
import { GeniusPanel } from './GeniusPanel';
import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons'; import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui'; import { Button, Dialog, EmptyState, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
import l from './library.module.css'; import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus'; import { ScanLine, StatusBadge } from './ScanStatus';
@@ -19,6 +20,7 @@ type TestResult = components['schemas']['SourceTestResult'];
const SCAN_POLL_MS = 2000; const SCAN_POLL_MS = 2000;
export function SourcesPage() { export function SourcesPage() {
const user = useUser();
const { t } = useTranslation(['admin', 'common']); const { t } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
const errorText = useErrorText(); const errorText = useErrorText();
@@ -43,7 +45,12 @@ export function SourcesPage() {
</Button> </Button>
</div> </div>
<FormError>{sources.error ? errorText(sources.error) : null}</FormError> <FormError>{sources.error ? errorText(sources.error) : null}</FormError>
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>} {user.role === 'admin' && <GeniusPanel />}
{sources.isSuccess && sources.data.length === 0 && (
<EmptyState icon={folderIcon} action={<Button variant="primary" onClick={() => setEditing('new')}>{t('sources.add')}</Button>}>
{t('sources.empty')}
</EmptyState>
)}
<div className={l.list}> <div className={l.list}>
{sources.data?.map((src) => ( {sources.data?.map((src) => (
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} /> <SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
+3 -3
View File
@@ -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
+3 -3
View File
@@ -9,7 +9,7 @@ import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatRelative } from '../../shared/lib/format'; import { formatRelative } from '../../shared/lib/format';
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy'; import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons'; import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui'; import { Avatar, Button, cx, Dialog, EmptyState, Field, FormError, Icon, Menu, PageSkeleton, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
type AdminUser = components['schemas']['AdminUser']; type AdminUser = components['schemas']['AdminUser'];
@@ -232,8 +232,8 @@ export function UsersPage() {
})} })}
</tbody> </tbody>
</table> </table>
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>} {list.isSuccess && users.length === 0 && <EmptyState icon={searchIcon}>{t('users.empty')}</EmptyState>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>} {list.isPending && <PageSkeleton />}
</div> </div>
{list.hasNextPage && ( {list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}> <Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
+64 -1
View File
@@ -20,6 +20,15 @@
filter: blur(30px); filter: blur(30px);
} }
.waveDecor {
position: absolute;
left: 50%;
top: 46%;
transform: translate(-50%, -50%);
opacity: 0.55;
filter: blur(0);
}
.blob1 { .blob1 {
width: 62vmax; width: 62vmax;
height: 62vmax; height: 62vmax;
@@ -165,7 +174,7 @@
color: var(--text2); color: var(--text2);
} }
/* Страница Android-приложения (/android). */ /* Страница приложений (/download). */
.download { .download {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -238,6 +247,60 @@
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.otherClients {
display: flex;
flex-direction: column;
gap: 8px;
}
.otherBlock {
display: flex;
flex-direction: column;
gap: 8px;
}
.otherTitle {
margin: 8px 0 0;
color: var(--text3);
font-size: 12px;
font-weight: 600;
}
.downloadAlt {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-lg);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.downloadAlt span {
color: var(--text3);
font-size: 12px;
font-weight: 500;
}
.older {
font-size: 13px;
color: var(--text3);
}
.older summary {
cursor: pointer;
font-weight: 600;
}
.older .downloadAlt {
margin-top: 8px;
}
.link { .link {
align-self: center; align-self: center;
} }
+41 -2
View File
@@ -60,12 +60,23 @@
.heroBg { .heroBg {
position: absolute; position: absolute;
inset: 0; inset: -20%;
opacity: 0.35; opacity: 0.4;
filter: blur(60px); filter: blur(60px);
pointer-events: none; pointer-events: none;
} }
.heroBg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.heroCover {
position: relative;
z-index: 1;
}
.tint { .tint {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -81,12 +92,40 @@
.heroBody { .heroBody {
position: relative; position: relative;
z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
} }
.listen {
display: grid;
gap: 28px;
}
.listenLyrics {
min-width: 0;
}
@media (min-width: 900px) {
.listen {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
align-items: start;
}
.listen .hero {
flex-direction: column;
align-items: stretch;
margin: 0;
padding: 0;
}
.listen .heroTitle {
font-size: clamp(28px, 3vw, 40px);
}
}
.kind { .kind {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
+8
View File
@@ -0,0 +1,8 @@
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; margin: 0; }
.stats dt { color: var(--text2); font-size: 13px; }
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
.annotation summary { cursor: pointer; line-height: 1.6; }
.badge { display: inline-block; margin-left: 12px; font-size: 12px; color: var(--text2); }
+4
View File
@@ -98,6 +98,10 @@
background: var(--surface3); background: var(--surface3);
} }
.chip:active {
transform: scale(0.97);
}
.chipOn, .chipOn,
.chipOn:hover { .chipOn:hover {
background: var(--accent-fill); background: var(--accent-fill);
+13 -4
View File
@@ -1,12 +1,21 @@
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); } .card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface); animation: pipuIn 0.2s ease-out both; }
.card > :first-child { aspect-ratio: 1; border-radius: 12px; } .coverWrap { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; }
.coverWrap > :first-child { width: 100%; height: 100%; }
.card strong { overflow-wrap: anywhere; } .card strong { overflow-wrap: anywhere; }
.card span { font-size: 13px; } .card span { font-size: 13px; }
.card:hover { background: #ffffff10; } .card:hover { background: var(--hover); }
.play {
position: absolute; right: 10px; bottom: 10px; z-index: 1; display: grid; place-items: center;
width: 44px; height: 44px; border-radius: 50%; background: #fff; color: #05070d;
opacity: 0; transform: translateY(6px); transition: opacity 0.18s, transform 0.18s;
}
.card:hover .play, .card:focus-within .play, .play:focus-visible { opacity: 1; transform: none; }
.description { white-space: pre-wrap; overflow-wrap: anywhere; } .description { white-space: pre-wrap; overflow-wrap: anywhere; }
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.actions { display: flex; align-items: center; } .actions { display: flex; align-items: center; }
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; } .actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
.actions button:disabled { opacity: .3; cursor: default; } .actions button:disabled { opacity: .3; cursor: default; }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } } .hero { overflow-wrap: anywhere; }
@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } .play { opacity: 1; transform: none; } }
+6 -2
View File
@@ -8,7 +8,7 @@ import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { formatBytes, formatDateTime } from '../../shared/lib/format'; import { formatBytes, formatDateTime } from '../../shared/lib/format';
import { addIcon } from '../../shared/ui/icons'; import { addIcon } from '../../shared/ui/icons';
import { Button, cx, FormError } from '../../shared/ui/ui'; import { Button, cx, EmptyState, FormError } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common'; import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
import u from './uploads.module.css'; import u from './uploads.module.css';
@@ -86,7 +86,11 @@ export function MyUploadsPage() {
</Link> </Link>
</div> </div>
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError> <FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>} {list.isSuccess && items.length === 0 && (
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
{t('uploads.mine_empty')}
</EmptyState>
)}
{items.length > 0 && ( {items.length > 0 && (
<div className={s.tableWrap}> <div className={s.tableWrap}>
<table className={s.table}> <table className={s.table}>
+36
View File
@@ -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();
} }
} }
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { detectAppClient, otherAppClients } from './platform';
describe('detectAppClient', () => {
it('picks the native client from the user agent', () => {
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
});
it('lists the remaining platforms after the detected one', () => {
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
});
});
+17
View File
@@ -0,0 +1,17 @@
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
if (/Android/i.test(ua)) return 'android';
if (/iPhone|iPad|iPod/i.test(ua)) return null;
if (/Windows/i.test(ua)) return 'windows';
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
if (/Linux|X11/i.test(ua)) return 'linux';
return null;
}
export function otherAppClients(primary: AppClient | null): AppClient[] {
return ORDER.filter((c) => c !== primary);
}
+1
View File
@@ -70,4 +70,5 @@ export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_e
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg'; export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg'; export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg'; export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg'; export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
+118 -3
View File
@@ -21,9 +21,10 @@
} }
.logoDot { .logoDot {
display: inline-block;
flex: none;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8); background: url('/favicon.svg') center / contain no-repeat;
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
} }
.button { .button {
@@ -40,7 +41,12 @@
transition: transition:
background 0.15s, background 0.15s,
box-shadow 0.15s, box-shadow 0.15s,
opacity 0.15s; opacity 0.15s,
transform 0.12s;
}
.button:active:not(:disabled) {
transform: scale(0.97);
} }
.button:disabled { .button:disabled {
@@ -52,6 +58,115 @@
cursor: progress; cursor: progress;
} }
.spinner {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: pipuSpin 0.6s linear infinite;
}
@keyframes pipuSpin {
to {
transform: rotate(360deg);
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
text-align: center;
color: var(--text3);
}
.emptyIcon {
color: var(--text3);
opacity: 0.7;
}
.emptyText {
margin: 0;
max-width: 28rem;
font-size: 15px;
line-height: 1.45;
}
.empty a,
.empty button {
color: var(--accent2);
font-weight: 600;
text-decoration: none;
}
.empty a:hover {
text-decoration: underline;
}
.skeleton {
display: block;
background: var(--surface3);
border-radius: 8px;
animation: pipuShimmer 1.2s ease-in-out infinite;
}
.skeletonRound {
border-radius: 50%;
}
@keyframes pipuShimmer {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.pageSkel {
display: flex;
flex-direction: column;
gap: 16px;
}
.skelHero {
display: flex;
align-items: flex-end;
gap: 24px;
margin-bottom: 8px;
}
.skelCover {
width: 160px;
height: 160px;
border-radius: 12px;
flex: none;
}
.skelCopy {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
padding-bottom: 8px;
}
.skelRow {
display: flex;
align-items: center;
gap: 14px;
}
.primary { .primary {
background: var(--accent-fill); background: var(--accent-fill);
color: #fff; color: #fff;
+57 -1
View File
@@ -4,6 +4,7 @@ import {
useRef, useRef,
useState, useState,
type ButtonHTMLAttributes, type ButtonHTMLAttributes,
type CSSProperties,
type InputHTMLAttributes, type InputHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
type ReactNode, type ReactNode,
@@ -52,7 +53,7 @@ export function Button({ variant = 'secondary', icon, busy, className, children,
aria-busy={busy || undefined} aria-busy={busy || undefined}
className={cx(s.button, s[variant], className)} className={cx(s.button, s[variant], className)}
> >
{icon && <Icon src={icon} size={18} />} {busy ? <span className={s.spinner} aria-hidden /> : icon ? <Icon src={icon} size={18} /> : null}
{children} {children}
</button> </button>
); );
@@ -283,6 +284,61 @@ export function Menu({
); );
} }
/** Пустой список: иконка, текст и действие (docs/design.md#5). */
export function EmptyState({
icon,
children,
action,
}: {
icon?: string;
children: ReactNode;
action?: ReactNode;
}) {
return (
<div className={s.empty} role="status">
{icon && <Icon src={icon} size={40} className={s.emptyIcon} />}
<p className={s.emptyText}>{children}</p>
{action}
</div>
);
}
export function Skeleton({
className,
style,
round,
}: {
className?: string;
style?: CSSProperties;
round?: boolean;
}) {
return <span className={cx(s.skeleton, round && s.skeletonRound, className)} style={style} aria-hidden />;
}
/** Каркас героя и строк списка, пока страница ещё грузится. */
export function PageSkeleton() {
const { t } = useTranslation();
return (
<div className={s.pageSkel} role="status" aria-busy="true" aria-label={t('loading')}>
<div className={s.skelHero}>
<Skeleton className={s.skelCover} />
<div className={s.skelCopy}>
<Skeleton style={{ width: 72, height: 12 }} />
<Skeleton style={{ width: '55%', height: 28 }} />
<Skeleton style={{ width: '38%', height: 14 }} />
</div>
</div>
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className={s.skelRow}>
<Skeleton style={{ width: 40, height: 40 }} />
<Skeleton style={{ flex: 1, height: 14 }} />
<Skeleton style={{ width: 36, height: 12 }} />
</div>
))}
</div>
);
}
export function Avatar({ name, size = 32 }: { name: string; size?: number }) { export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
const initials = name const initials = name
.split(/[\s._-]+/) .split(/[\s._-]+/)
+31
View File
@@ -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') {
+21
View File
@@ -227,12 +227,33 @@
color: var(--accent2); color: var(--accent2);
} }
.skip {
position: absolute;
left: 12px;
top: 8px;
z-index: 50;
padding: 8px 14px;
border-radius: 10px;
background: var(--elev);
color: var(--text);
font-weight: 600;
text-decoration: none;
transform: translateY(-120%);
}
.skip:focus {
transform: none;
}
.content { .content {
grid-area: content; grid-area: content;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 32px 36px; padding: 32px 36px;
}
.routeIn {
animation: pipuIn 0.2s ease-out; animation: pipuIn 0.2s ease-out;
} }
+25 -9
View File
@@ -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();
+120 -3
View File
@@ -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 {
+166 -5
View File
@@ -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,
); );
} }
+14 -7
View File
@@ -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}
+9
View File
@@ -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
+4
View File
@@ -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
+2 -2
View File
@@ -5,7 +5,7 @@
## Суть ## Суть
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера. Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие. музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше. Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
@@ -16,7 +16,7 @@
## Доступ и учётные записи ## Доступ и учётные записи
- При первом открытии приложение показывает **экран входа** (логин + пароль). - При первом открытии приложение показывает **экран входа** (логин + пароль).
В мобильном приложении на этом экране также указывается **адрес сервера**. В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
- **Регистрации нет.** Учётные записи создаёт администрация. - **Регистрации нет.** Учётные записи создаёт администрация.
- Роли: - Роли:
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов - **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
+2
View File
@@ -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 |
+6 -2
View File
@@ -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. Наблюдение
+2
View File
@@ -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) |
+3 -2
View File
@@ -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 клиент + типы
+1 -1
View File
@@ -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` | |
+7 -1
View File
@@ -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`.
После изменения значков установленные приложения нужно пересобрать и обновить.
### Типографика ### Типографика
+17
View File
@@ -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.
+83
View File
@@ -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 проверяется
отдельно из сети сервера с собственным токеном.
+1 -1
View File
@@ -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, как в этой инструкции.
+2 -2
View File
@@ -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`).
+1 -1
View File
@@ -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
+1
View File
@@ -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",
+246
View File
@@ -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;
+32 -7
View File
@@ -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"
} }
} }
+15 -2
View File
@@ -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}}",
+32 -7
View File
@@ -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": "Войти в веб-версию"
} }
} }
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more