Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f087ec9f88 | ||
|
|
cd84ceecb2 | ||
|
|
582c0dc3d4 | ||
|
|
1a693848f2 | ||
|
|
11a16cd023 | ||
|
|
0a05bfe2cd | ||
|
|
f1528de948 | ||
|
|
0a4cd4bff1 | ||
|
|
eed7038109 | ||
|
|
7fa7ee2c7d | ||
|
|
24707a0bfc | ||
|
|
a6c9fd8a00 | ||
|
|
bc55528afd | ||
|
|
a29a8b18ad | ||
|
|
e33abc948c | ||
|
|
ab49e22357 | ||
|
|
75ae2092ad | ||
|
|
0dc0384c78 | ||
|
|
17b6dd2041 | ||
|
|
0a59059738 | ||
|
|
bfcf6e48b2 | ||
|
|
02307802bc | ||
|
|
fff91c446b | ||
|
|
e158558da3 | ||
|
|
c84f90e2cc | ||
|
|
72567acd28 | ||
|
|
39aaaed969 | ||
|
|
8eb6b36888 | ||
|
|
83b604437c | ||
|
|
73ad61e7a5 | ||
|
|
fb20258489 | ||
|
|
78c006184b | ||
|
|
d3116aba96 | ||
|
|
b7518aa450 | ||
|
|
eb8031b126 | ||
|
|
e4b841a8d8 | ||
|
|
597067689a | ||
|
|
efe78d7e07 | ||
|
|
fa79394869 | ||
|
|
3a097c15c8 | ||
|
|
20abe20686 | ||
|
|
c34272d508 | ||
|
|
b0cbd0f149 | ||
|
|
4c2286e70d | ||
|
|
9223516547 | ||
|
|
7b7ce78de8 | ||
|
|
4d2c6ce22f | ||
|
|
dca7173a88 | ||
|
|
1e3b350dfc | ||
|
|
74da457362 | ||
|
|
066ba24834 | ||
|
|
2b76060ea3 | ||
|
|
dfc72b1d69 | ||
|
|
978f03b8f8 | ||
|
|
b27b915b63 | ||
|
|
73e642c1ec | ||
|
|
66f5749fc1 | ||
|
|
823d1eb841 | ||
|
|
860072e542 | ||
|
|
d2f17d8d62 | ||
|
|
4763339335 | ||
|
|
fda6ed48ab | ||
|
|
2d49cb3f19 | ||
|
|
3da82e902a | ||
|
|
421b1a3eb9 | ||
|
|
b8243c3072 | ||
|
|
30f063456e | ||
|
|
de4e59d9f8 | ||
|
|
c0e50bf88e | ||
|
|
86c3e4e477 | ||
|
|
aacf55679a | ||
|
|
d91ff89c64 | ||
|
|
fbbe5d7528 | ||
|
|
e250837de9 | ||
|
|
58bd14ef38 | ||
|
|
fb07f962eb | ||
|
|
ff0aafdb1e | ||
|
|
f7722e28ad | ||
|
|
ed0302d90f | ||
|
|
85fceaa686 | ||
|
|
6ad49fe0e3 | ||
|
|
7cf741d82f | ||
|
|
4fb4e124a0 | ||
|
|
4cca427b82 | ||
|
|
2285107d04 | ||
|
|
f5c276092f | ||
|
|
1c4e209e86 | ||
|
|
3afed02f23 | ||
|
|
a3f212d253 | ||
|
|
ca106e8eb1 | ||
|
|
f66be2ba0a | ||
|
|
9b50038bb4 | ||
|
|
ed063d3763 | ||
|
|
756e915ff9 | ||
|
|
be34ee5361 | ||
|
|
765345e0f4 | ||
|
|
6a67c27f50 | ||
|
|
9fd616da57 | ||
|
|
eaedd1fc62 | ||
|
|
3908814cda | ||
|
|
ef29b85320 | ||
|
|
976ffb574b | ||
|
|
ec3c2810c8 | ||
|
|
c05e523cbe | ||
|
|
8b76aa3964 | ||
|
|
a685b1b7ca | ||
|
|
c1160cccd3 | ||
|
|
c583991d14 | ||
|
|
ab092def54 | ||
|
|
f075863f15 | ||
|
|
ed4a492dba | ||
|
|
b6def39e29 | ||
|
|
6fe76aea90 | ||
|
|
0acdc3fbb3 | ||
|
|
a996b97694 | ||
|
|
b8cfd64208 | ||
|
|
0d9418df48 | ||
|
|
bf3c00ca71 | ||
|
|
633a8f79af | ||
|
|
a239650075 | ||
|
|
718125b719 | ||
|
|
d3b2e7bfc3 | ||
|
|
622eb84bc9 | ||
|
|
65aef3164a | ||
|
|
12fc866c26 | ||
|
|
f76b3c89b3 | ||
|
|
38edcd3e0e | ||
|
|
42d64cd49f |
No files matched your search
@@ -4,14 +4,43 @@
|
|||||||
{
|
{
|
||||||
"name": "design",
|
"name": "design",
|
||||||
"runtimeExecutable": "python",
|
"runtimeExecutable": "python",
|
||||||
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"],
|
"runtimeArgs": [
|
||||||
|
"-m",
|
||||||
|
"http.server",
|
||||||
|
"8765",
|
||||||
|
"--bind",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--directory",
|
||||||
|
"docs"
|
||||||
|
],
|
||||||
"port": 8765
|
"port": 8765
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["--filter", "@pipu/web", "dev"],
|
"runtimeArgs": [
|
||||||
|
"--filter",
|
||||||
|
"@pipu/web",
|
||||||
|
"dev"
|
||||||
|
],
|
||||||
"port": 5173
|
"port": 5173
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "web-fake",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"--filter",
|
||||||
|
"@pipu/web",
|
||||||
|
"exec",
|
||||||
|
"vite",
|
||||||
|
"--port",
|
||||||
|
"4173",
|
||||||
|
"--strictPort"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"VITE_E2E": "1"
|
||||||
|
},
|
||||||
|
"port": 4173
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Go: сервис api и storage-agent. Только при изменении их кода или контракта.
|
||||||
|
name: ci / api
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'services/api/**'
|
||||||
|
- 'api/openapi.yaml'
|
||||||
|
- '.gitea/workflows/ci-api.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'services/api/**'
|
||||||
|
- 'api/openapi.yaml'
|
||||||
|
- '.gitea/workflows/ci-api.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
api:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults: { run: { working-directory: services/api } }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||||
|
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: go test -race -coverprofile=coverage.out ./...
|
||||||
|
- run: go build ./cmd/...
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: ci / module boundaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'packages/core/src/**'
|
||||||
|
- 'apps/web/src/features/uploads/**'
|
||||||
|
- 'services/api/internal/uploads/**'
|
||||||
|
- 'scripts/check-boundaries.mjs'
|
||||||
|
- '.gitea/workflows/ci-boundaries.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'packages/core/src/**'
|
||||||
|
- 'apps/web/src/features/uploads/**'
|
||||||
|
- 'services/api/internal/uploads/**'
|
||||||
|
- 'scripts/check-boundaries.mjs'
|
||||||
|
- '.gitea/workflows/ci-boundaries.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
boundaries:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: '22' }
|
||||||
|
- run: node scripts/check-boundaries.mjs
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками.
|
||||||
|
name: ci / contract
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'api/openapi.yaml'
|
||||||
|
- 'services/api/internal/api/gen/**'
|
||||||
|
- 'services/api/go.mod'
|
||||||
|
- 'services/api/go.sum'
|
||||||
|
- 'packages/api-client/**'
|
||||||
|
- 'packages/design-tokens/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'tsconfig.base.json'
|
||||||
|
- '.gitea/workflows/ci-contract.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'api/openapi.yaml'
|
||||||
|
- 'services/api/internal/api/gen/**'
|
||||||
|
- 'services/api/go.mod'
|
||||||
|
- 'services/api/go.sum'
|
||||||
|
- 'packages/api-client/**'
|
||||||
|
- 'packages/design-tokens/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'tsconfig.base.json'
|
||||||
|
- '.gitea/workflows/ci-contract.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
contract:
|
||||||
|
name: contract (сгенерированный код совпадает с openapi.yaml)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "22", cache: pnpm }
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
|
||||||
|
run: |
|
||||||
|
(cd services/api && go generate ./...)
|
||||||
|
pnpm generate
|
||||||
|
- name: Проверка, что всё закоммичено
|
||||||
|
# status, а не diff: diff не видит новые неотслеживаемые файлы
|
||||||
|
run: |
|
||||||
|
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
|
||||||
|
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Интеграционные проверки SQL на настоящем PostgreSQL + pgvector: миграции вверх / вниз / вверх,
|
||||||
|
# запросы сканера, отпечатков, шаблонов и очереди, сквозной сценарий пользователя через HTTP (Go),
|
||||||
|
# загрузка каталога, профиля и обучение ALS (Python).
|
||||||
|
name: ci / db
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'services/api/**'
|
||||||
|
- 'services/recommender/**'
|
||||||
|
- '.gitea/workflows/ci-db.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'services/api/**'
|
||||||
|
- 'services/recommender/**'
|
||||||
|
- '.gitea/workflows/ci-db.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
db:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:0.8.7-pg17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: pipu
|
||||||
|
POSTGRES_PASSWORD: pipu
|
||||||
|
POSTGRES_DB: pipu
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U pipu"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
redis:
|
||||||
|
image: redis:7.4-alpine
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.11
|
||||||
|
env:
|
||||||
|
MEILI_MASTER_KEY: ci-master-key-0123456789
|
||||||
|
MEILI_ENV: development
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
|
||||||
|
TEST_REDIS_URL: redis://redis:6379/0
|
||||||
|
TEST_MEILI_URL: http://meilisearch:7700
|
||||||
|
TEST_MEILI_KEY: ci-master-key-0123456789
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||||
|
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
|
||||||
|
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
|
||||||
|
run: |
|
||||||
|
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
|
||||||
|
free=$(df -Pm / | awk 'NR==2 {print $4}')
|
||||||
|
echo "свободно на раннере: ${free} МБ"
|
||||||
|
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
|
||||||
|
for svc in postgres:5432 redis:6379 meilisearch:7700; do
|
||||||
|
host=${svc%:*}; port=${svc#*:}
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
|
||||||
|
echo "$svc готов"; break
|
||||||
|
fi
|
||||||
|
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
done
|
||||||
|
- name: Go — миграции и запросы store
|
||||||
|
working-directory: services/api
|
||||||
|
# These suites share one database; dbtest also rolls all migrations down
|
||||||
|
# and cleans orphan catalog rows. Do not run the packages concurrently.
|
||||||
|
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/
|
||||||
|
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
|
||||||
|
working-directory: services/api
|
||||||
|
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
|
||||||
|
- uses: astral-sh/setup-uv@v6
|
||||||
|
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
|
||||||
|
- name: Python — каталог, профиль, обучение ALS
|
||||||
|
working-directory: services/recommender
|
||||||
|
run: |
|
||||||
|
uv sync --frozen
|
||||||
|
uv run pytest -q tests/test_db_integration.py
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Expo: expo-doctor и Metro-бандл Android. Полная сборка APK — только в release.yml.
|
||||||
|
name: ci / mobile
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'apps/mobile/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'tsconfig.base.json'
|
||||||
|
- '.gitea/workflows/ci-mobile.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'apps/mobile/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'tsconfig.base.json'
|
||||||
|
- '.gitea/workflows/ci-mobile.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mobile:
|
||||||
|
name: mobile (Metro-бандл Android)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults: { run: { working-directory: apps/mobile } }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "22", cache: pnpm }
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
# npx expo-doctor качает пакет в кэш npm; Metro хранит трансформации в /tmp/metro-cache
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.npm/_npx
|
||||||
|
/tmp/metro-cache
|
||||||
|
key: mobile-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: mobile-${{ hashFiles('pnpm-lock.yaml') }}-
|
||||||
|
- run: npx expo-doctor
|
||||||
|
- run: npx expo export --platform android --output-dir dist
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Python: recommender и analyzer.
|
||||||
|
name: ci / recommender
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'services/recommender/**'
|
||||||
|
- '.gitea/workflows/ci-recommender.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'services/recommender/**'
|
||||||
|
- '.gitea/workflows/ci-recommender.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
recommender:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults: { run: { working-directory: services/recommender } }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v6
|
||||||
|
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
|
||||||
|
- run: uv sync --frozen
|
||||||
|
- run: uv run ruff check .
|
||||||
|
- run: uv run ruff format --check .
|
||||||
|
- run: uv run mypy pipu_rec
|
||||||
|
- run: uv run pytest -q
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза.
|
||||||
|
name: ci / ts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'apps/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'tsconfig.base.json'
|
||||||
|
- '.gitea/workflows/ci-ts.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'apps/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'tsconfig.base.json'
|
||||||
|
- '.gitea/workflows/ci-ts.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ts:
|
||||||
|
name: ts (packages + web)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "22", cache: pnpm }
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
# Локальный кэш turbo: typecheck/test/build пропускаются для пакетов, чьи входы не менялись
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo/cache
|
||||||
|
key: turbo-ts-${{ github.sha }}
|
||||||
|
restore-keys: turbo-ts-
|
||||||
|
- run: pnpm i18n:check
|
||||||
|
- run: pnpm test:scripts
|
||||||
|
- run: pnpm typecheck
|
||||||
|
- run: pnpm test
|
||||||
|
- run: pnpm --filter @pipu/web build
|
||||||
|
# Браузер качается долго (основной CDN Microsoft из раннера недоступен) — кэшируем по lock-файлу.
|
||||||
|
# Системные библиотеки (libnspr4 и т.п.) в образе раннера нет, их ставит --with-deps на каждом прогоне.
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
|
||||||
|
- run: |
|
||||||
|
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
|
||||||
|
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
|
||||||
|
pnpm --filter @pipu/web exec playwright install chromium
|
||||||
|
- run: pnpm --filter @pipu/web test:e2e
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Проверки на каждый push в main и на каждый PR (docs/ci-cd.md).
|
|
||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ci-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
|
||||||
GO_VERSION: "1.24"
|
|
||||||
NODE_VERSION: "22"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
contract:
|
|
||||||
name: contract (сгенерированный код совпадает с openapi.yaml)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
|
|
||||||
run: |
|
|
||||||
(cd services/api && go generate ./...)
|
|
||||||
pnpm generate
|
|
||||||
- name: Проверка, что всё закоммичено
|
|
||||||
# status, а не diff: diff не видит новые неотслеживаемые файлы
|
|
||||||
run: |
|
|
||||||
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
|
|
||||||
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
|
|
||||||
|
|
||||||
api:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults: { run: { working-directory: services/api } }
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
|
|
||||||
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
|
|
||||||
- run: go vet ./...
|
|
||||||
- run: go test -race -coverprofile=coverage.out ./...
|
|
||||||
- run: go build ./cmd/...
|
|
||||||
|
|
||||||
recommender:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults: { run: { working-directory: services/recommender } }
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: astral-sh/setup-uv@v6
|
|
||||||
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
|
|
||||||
- run: uv sync --frozen
|
|
||||||
- run: uv run ruff check .
|
|
||||||
- run: uv run ruff format --check .
|
|
||||||
- run: uv run mypy pipu_rec
|
|
||||||
- run: uv run pytest -q
|
|
||||||
|
|
||||||
ts:
|
|
||||||
name: ts (packages + web)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm i18n:check
|
|
||||||
- run: pnpm test:scripts
|
|
||||||
- run: pnpm typecheck
|
|
||||||
- run: pnpm test
|
|
||||||
- run: pnpm --filter @pipu/web build
|
|
||||||
|
|
||||||
docker:
|
|
||||||
name: docker (образы собираются — их сервер собирает сам при обновлении)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- { name: api, dockerfile: services/api/Dockerfile, target: api }
|
|
||||||
- { name: storage-agent, dockerfile: services/api/Dockerfile, target: storage-agent }
|
|
||||||
- { name: recommender, dockerfile: services/recommender/Dockerfile, target: "" }
|
|
||||||
- { name: web, dockerfile: apps/web/Dockerfile, target: "" }
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ${{ matrix.dockerfile }}
|
|
||||||
target: ${{ matrix.target }}
|
|
||||||
push: false
|
|
||||||
build-args: VERSION=ci
|
|
||||||
|
|
||||||
mobile:
|
|
||||||
name: mobile (Metro-бандл Android)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults: { run: { working-directory: apps/mobile } }
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: npx expo-doctor
|
|
||||||
- run: npx expo export --platform android --output-dir dist
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Сборка всех Docker-образов без push: сервер собирает их сам при обновлении, и здесь ловятся поломки
|
||||||
|
# Dockerfile и зависимостей. Только при изменении Dockerfile, deploy/ и манифестов зависимостей.
|
||||||
|
name: ci / docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- '**/Dockerfile'
|
||||||
|
- '.dockerignore'
|
||||||
|
- 'deploy/**'
|
||||||
|
- 'services/api/go.mod'
|
||||||
|
- 'services/api/go.sum'
|
||||||
|
- 'services/recommender/pyproject.toml'
|
||||||
|
- 'services/recommender/uv.lock'
|
||||||
|
- 'apps/web/package.json'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'tsconfig.base.json'
|
||||||
|
- '.gitea/workflows/docker.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '**/Dockerfile'
|
||||||
|
- '.dockerignore'
|
||||||
|
- 'deploy/**'
|
||||||
|
- 'services/api/go.mod'
|
||||||
|
- 'services/api/go.sum'
|
||||||
|
- 'services/recommender/pyproject.toml'
|
||||||
|
- 'services/recommender/uv.lock'
|
||||||
|
- 'apps/web/package.json'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'tsconfig.base.json'
|
||||||
|
- '.gitea/workflows/docker.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
name: docker (образы собираются — их сервер собирает сам при обновлении)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Обновление сервера, агент и конфигурация
|
||||||
|
run: python3 -m unittest discover -s deploy -p 'test_*.py' -v
|
||||||
|
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
||||||
|
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
||||||
|
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
|
||||||
|
- name: Сборка образов
|
||||||
|
env: { DOCKER_BUILDKIT: "1" }
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
build() { echo "::group::$1"; docker build --build-arg VERSION=ci -t "pipu-ci/$1" "${@:2}" .; echo "::endgroup::"; }
|
||||||
|
build api -f services/api/Dockerfile --target api
|
||||||
|
build storage-agent -f services/api/Dockerfile --target storage-agent
|
||||||
|
build recommender -f services/recommender/Dockerfile
|
||||||
|
build web -f apps/web/Dockerfile
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Ночные проверки, которым нужен поднятый стенд или эмулятор. На каждый push не запускаются.
|
||||||
|
name: nightly / e2e
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
schemathesis:
|
||||||
|
name: schemathesis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: pip install schemathesis
|
||||||
|
- name: Фаззинг API
|
||||||
|
env:
|
||||||
|
PIPU_API_URL: ${{ vars.PIPU_API_URL }}
|
||||||
|
PIPU_API_TOKEN: ${{ secrets.PIPU_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$PIPU_API_URL" ]; then echo "PIPU_API_URL is not set"; exit 0; fi
|
||||||
|
sh tests/contract/schemathesis.sh
|
||||||
|
|
||||||
|
maestro:
|
||||||
|
name: maestro
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Сценарии Android
|
||||||
|
env:
|
||||||
|
PIPU_E2E_SERVER: ${{ vars.PIPU_E2E_SERVER }}
|
||||||
|
PIPU_E2E_USER: ${{ vars.PIPU_E2E_USER }}
|
||||||
|
PIPU_E2E_PASSWORD: ${{ secrets.PIPU_E2E_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$PIPU_E2E_SERVER" ]; then echo "emulator flow is configured in apps/mobile/.maestro"; exit 0; fi
|
||||||
|
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||||
|
~/.maestro/bin/maestro test apps/mobile/.maestro
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): проверки → подписанный APK → Gitea Release.
|
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
|
||||||
|
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
||||||
|
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
||||||
|
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
||||||
|
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
|
||||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||||
#
|
#
|
||||||
# Секреты (Settings → Actions → Secrets):
|
# Секреты (Settings → Actions → Secrets):
|
||||||
# RELEASE_TOKEN — создание релиза и загрузка ассетов (scope: write:repository)
|
# RELEASE_TOKEN (необязательно) — токен для релиза; без него — встроенный токен job (права на запись в репозиторий)
|
||||||
|
# Переменные (Settings → Actions → Variables):
|
||||||
|
# PUBLIC_URL (необязательно) — внешний адрес Gitea для ссылок в тексте релиза; по умолчанию https://gitea.hsrv.site
|
||||||
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD — подпись APK
|
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD — подпись APK
|
||||||
name: release
|
name: release
|
||||||
|
|
||||||
@@ -11,11 +17,12 @@ on:
|
|||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
|
||||||
NODE_VERSION: "22"
|
NODE_VERSION: "22"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
# Тесты здесь не повторяются: тот же коммит уже проверен CI в main (ci-*.yml). Ставьте тег после зелёного CI.
|
||||||
|
release:
|
||||||
|
name: release (версия и заметки)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.v.outputs.PIPU_VERSION }}
|
version: ${{ steps.v.outputs.PIPU_VERSION }}
|
||||||
@@ -23,35 +30,59 @@ jobs:
|
|||||||
prerelease: ${{ steps.v.outputs.PIPU_PRERELEASE }}
|
prerelease: ${{ steps.v.outputs.PIPU_PRERELEASE }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- id: v
|
# вся история и теги: заметки строятся по коммитам с прошлого релиза
|
||||||
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
|
with: { fetch-depth: 0 }
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
|
|
||||||
- uses: astral-sh/setup-uv@v6
|
|
||||||
with: { version: "0.10.2" }
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
with: { node-version: "${{ env.NODE_VERSION }}" }
|
||||||
- run: pnpm install --frozen-lockfile
|
- id: v
|
||||||
- name: Go
|
name: Версия из тега
|
||||||
working-directory: services/api
|
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
|
||||||
run: go vet ./... && go test ./...
|
- name: Gitea Release
|
||||||
- name: Python
|
env:
|
||||||
working-directory: services/recommender
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
run: uv sync --frozen && uv run ruff check . && uv run mypy pipu_rec && uv run pytest -q
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
- name: TypeScript
|
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||||
run: pnpm i18n:check && pnpm test:scripts && pnpm typecheck && pnpm test
|
TAG: ${{ github.ref_name }}
|
||||||
|
VERSION: ${{ steps.v.outputs.PIPU_VERSION }}
|
||||||
|
PRERELEASE: ${{ steps.v.outputs.PIPU_PRERELEASE }}
|
||||||
|
run: node scripts/release.mjs create
|
||||||
|
|
||||||
android-release:
|
android:
|
||||||
name: android + release
|
name: android (APK в релиз)
|
||||||
needs: verify
|
needs: release
|
||||||
|
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
|
||||||
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||||||
|
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
||||||
|
# (container.valid_volumes: ['/opt/ci-cache/**']); иначе раннер их пропускает и всё качается заново.
|
||||||
|
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
container:
|
||||||
|
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||||
|
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
|
||||||
|
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
|
||||||
|
options: --cpus=3 --memory=7g --memory-swap=7g
|
||||||
|
volumes:
|
||||||
|
- /opt/ci-cache/gradle:/root/.gradle
|
||||||
|
- /opt/ci-cache/android:/root/.android
|
||||||
env:
|
env:
|
||||||
PIPU_VERSION: ${{ needs.verify.outputs.version }}
|
PIPU_VERSION: ${{ needs.release.outputs.version }}
|
||||||
PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }}
|
PIPU_VERSION_CODE: ${{ needs.release.outputs.version_code }}
|
||||||
|
# для scripts/release.mjs
|
||||||
|
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 }}
|
||||||
|
VERSION_CODE: ${{ needs.release.outputs.version_code }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with: { fetch-depth: 0 }
|
# при перезапуске одной этой сборки статус «не удалась» сменится на «собирается»
|
||||||
|
- name: Статус в релизе — собирается
|
||||||
|
run: node scripts/release.mjs android building
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with: { distribution: temurin, java-version: "17" }
|
with: { distribution: temurin, java-version: "17" }
|
||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
@@ -64,24 +95,72 @@ jobs:
|
|||||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Свободное место
|
# Диск, память и наполненность постоянных кэшей: пустые ~/.gradle и ~/.android значат, что тома не подключились
|
||||||
run: df -h / /tmp
|
- name: Ресурсы раннера и кэши
|
||||||
|
run: |
|
||||||
|
df -h / /tmp; free -m
|
||||||
|
du -sh ~/.gradle ~/.android 2>/dev/null || true
|
||||||
|
|
||||||
- name: Сборка APK
|
- name: Сборка APK
|
||||||
working-directory: apps/mobile
|
working-directory: apps/mobile
|
||||||
|
env:
|
||||||
|
# CI=1 — Metro без file watcher и с 2 воркерами (metro.config.js); NODE_ENV нужен expo для .env-файлов
|
||||||
|
CI: "1"
|
||||||
|
NODE_ENV: production
|
||||||
|
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
|
||||||
|
CMAKE_BUILD_PARALLEL_LEVEL: "2"
|
||||||
|
NINJAFLAGS: "-j2"
|
||||||
|
# JS-бандл (Metro) собирается внутри Gradle отдельным node — не даём ему разрастись.
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=1536"
|
||||||
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
||||||
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
||||||
|
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
||||||
|
# Kotlin компилируется внутри JVM Gradle (без второго демона, поэтому heap 2.5 ГБ вместо 2), задач — не больше 2.
|
||||||
|
# -x lintVitalRelease: lint-проверки для Google Play, а APK публикуется только в Gitea (−1 мин).
|
||||||
|
# Codegen отдельным вызовом до сборки: CMake приложения читает JNI-код, который генерирует codegen
|
||||||
|
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
|
||||||
|
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
|
||||||
run: |
|
run: |
|
||||||
|
set -o pipefail
|
||||||
npx expo prebuild --platform android --clean --no-install
|
npx expo prebuild --platform android --clean --no-install
|
||||||
cd android && ./gradlew --no-daemon assembleRelease -PreactNativeArchitectures=arm64-v8a
|
cd android
|
||||||
|
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
|
||||||
|
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
||||||
|
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2048m -XX:MaxMetaspaceSize=512m")
|
||||||
|
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
|
||||||
|
if grep -q '^newArchEnabled=true' gradle.properties; then
|
||||||
|
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
||||||
|
fi
|
||||||
|
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||||||
|
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||||||
|
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
||||||
|
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
|
||||||
|
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
|
||||||
|
./gradlew --stop || true
|
||||||
|
find ~/.gradle -name "*.lock" -delete || true
|
||||||
|
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||||||
|
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||||
|
else
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
./gradlew --stop
|
||||||
|
|
||||||
- name: Свободное место после сбоя
|
# Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске
|
||||||
|
# на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86).
|
||||||
|
- name: Проверка APK (ABI, 16 КБ страницы)
|
||||||
|
run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
|
||||||
|
- name: Диагностика сбоя (диск, память, OOM)
|
||||||
if: failure()
|
if: failure()
|
||||||
run: df -h / /tmp; du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
|
run: |
|
||||||
|
df -h / /tmp; free -m || true
|
||||||
|
du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
|
||||||
|
dmesg 2>/dev/null | grep -iE "killed process|out of memory" | tail -5 || true
|
||||||
|
|
||||||
- name: Подпись APK
|
- name: Подпись APK
|
||||||
env:
|
env:
|
||||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
PRERELEASE: ${{ needs.release.outputs.prerelease }}
|
||||||
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
@@ -96,9 +175,11 @@ jobs:
|
|||||||
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
|
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
|
||||||
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
|
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
|
||||||
rm release.keystore
|
rm release.keystore
|
||||||
|
echo "SIGNING=release" >> "$GITHUB_ENV"
|
||||||
elif [ "$PRERELEASE" = "true" ]; then
|
elif [ "$PRERELEASE" = "true" ]; then
|
||||||
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
|
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
|
||||||
cp "$src" "$out"
|
cp "$src" "$out"
|
||||||
|
echo "SIGNING=debug" >> "$GITHUB_ENV"
|
||||||
else
|
else
|
||||||
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
|
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -107,41 +188,153 @@ jobs:
|
|||||||
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
|
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
|
||||||
echo "APK=$out" >> "$GITHUB_ENV"
|
echo "APK=$out" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Changelog
|
- name: APK в релиз
|
||||||
env:
|
run: node scripts/release.mjs android success "$APK"
|
||||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
|
||||||
run: |
|
|
||||||
# для финального релиза — изменения с прошлого финального, а не с последнего rc
|
|
||||||
exclude=()
|
|
||||||
[ "$PRERELEASE" = "true" ] || exclude=(--exclude '*-rc*')
|
|
||||||
prev="$(git describe --tags --abbrev=0 "${exclude[@]}" "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
|
|
||||||
range="${prev:+$prev..}${GITHUB_REF_NAME}"
|
|
||||||
{
|
|
||||||
echo "## Pipu Music ${PIPU_VERSION}"
|
|
||||||
echo
|
|
||||||
for t in feat fix perf refactor docs ci chore; do
|
|
||||||
lines="$(git log --no-merges --format='%s' "$range" | grep -E "^${t}(\(|:|!)" || true)"
|
|
||||||
[ -n "$lines" ] || continue
|
|
||||||
case $t in feat) h="Новое";; fix) h="Исправления";; perf) h="Производительность";; *) h="$t";; esac
|
|
||||||
echo "### $h"; echo "$lines" | sed 's/^/- /'; echo
|
|
||||||
done
|
|
||||||
} > CHANGELOG_RELEASE.md
|
|
||||||
cat CHANGELOG_RELEASE.md
|
|
||||||
|
|
||||||
- name: Gitea Release
|
- name: Статус в релизе — сборка не удалась
|
||||||
env:
|
if: failure()
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
run: node scripts/release.mjs android failure
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
# срабатывает при отмене из интерфейса; если раннер убит целиком, статус обновит следующий запуск
|
||||||
|
- name: Статус в релизе — сборка отменена
|
||||||
|
if: cancelled()
|
||||||
|
run: node scripts/release.mjs android cancelled
|
||||||
|
|
||||||
|
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
|
||||||
|
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
|
||||||
|
desktop:
|
||||||
|
name: desktop (Linux и Windows в релиз)
|
||||||
|
needs: [release, android]
|
||||||
|
if: always() && needs.release.result == 'success'
|
||||||
|
timeout-minutes: 90
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PIPU_VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
XWIN_CACHE_DIR: /opt/ci-cache/xwin
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Статус в релизе — собирается
|
||||||
|
run: |
|
||||||
|
node scripts/release.mjs desktop linux building
|
||||||
|
node scripts/release.mjs desktop windows building
|
||||||
|
- name: Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||||
|
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||||
|
libdbus-1-dev libsecret-1-dev patchelf \
|
||||||
|
nsis lld llvm clang
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
~/.cargo/bin/cargo-xwin
|
||||||
|
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
|
||||||
|
restore-keys: cargo-desktop-
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- name: Сборка deb
|
||||||
|
id: linux
|
||||||
|
working-directory: apps/desktop
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
body="$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "Pipu Music $PIPU_VERSION" \
|
pnpm bundle:linux
|
||||||
--rawfile notes CHANGELOG_RELEASE.md --argjson pre "$PRERELEASE" \
|
bundle=src-tauri/target/release/bundle
|
||||||
'{tag_name:$tag, name:$name, body:$notes, prerelease:$pre, draft:false}')"
|
shopt -s nullglob
|
||||||
id="$(curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
for f in "$bundle"/deb/*.deb; do
|
||||||
-d "$body" "$API/releases" | jq -r .id)"
|
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
for f in "$APK" "$APK.sha256"; do
|
|
||||||
curl -fsS -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" > /dev/null
|
|
||||||
echo "uploaded $(basename "$f")"
|
|
||||||
done
|
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
|
||||||
@@ -20,6 +20,8 @@ __pycache__/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
# env / secrets
|
# env / secrets
|
||||||
|
deploy/config.local
|
||||||
|
deploy/.management.lock
|
||||||
.env
|
.env
|
||||||
*.pem
|
*.pem
|
||||||
*.keystore
|
*.keystore
|
||||||
@@ -27,6 +29,8 @@ __pycache__/
|
|||||||
|
|
||||||
# generated test audio
|
# generated test audio
|
||||||
tests/fixtures/music/out/
|
tests/fixtures/music/out/
|
||||||
|
apps/web/test-results/
|
||||||
|
apps/web/playwright-report/
|
||||||
|
|
||||||
# os / editors
|
# os / editors
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -34,3 +38,11 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
.cache/
|
||||||
|
apps/desktop/src-tauri/target/
|
||||||
|
apps/desktop/src-tauri/gen/
|
||||||
|
apps/desktop/src-tauri/tauri.release.json
|
||||||
|
apps/desktop/release/
|
||||||
|
|
||||||
|
# Intermediate platform icon renders (final assets live under apps/).
|
||||||
|
/artifacts/icon-build/
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Работа с проектом
|
||||||
|
|
||||||
|
- Начинай с ближайшего локального `AGENTS.md` и файлов конкретной возможности. Карта документов: `docs/README.md`; реализация архитектуры: `docs/architecture.md`.
|
||||||
|
- Web/desktop UI: `apps/web/src`; Android: `apps/mobile/src`; общие правила клиентов: `packages/core/src`; Go API: `services/api/internal`; рекомендации: `services/recommender/pipu_rec`.
|
||||||
|
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
|
||||||
|
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
|
||||||
|
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
|
||||||
|
- Локально достаточно запускать проверки затронутой области и непосредственно связанных сценариев. Полный набор тестов и проверок повторно выполняется в CI/CD; не запускай все проверки проекта перед каждой сдачей без необходимости. Расширяй локальную проверку при изменении общих контрактов, межмодульных зависимостей или при обнаружении ошибок. Команды запускаются из корня, если не сказано иначе.
|
||||||
|
- Справочник полных проверок (выбирай локально только относящиеся к изменению): `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
|
||||||
|
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# pipu-music
|
# pipu-music
|
||||||
|
|
||||||
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
|
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
|
||||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||||
|
|
||||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||||
- Документация для разработки: [docs/README.md](docs/README.md)
|
- Документация: [docs/README.md](docs/README.md)
|
||||||
- План: [docs/roadmap.md](docs/roadmap.md)
|
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
|
||||||
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
@@ -16,11 +16,12 @@ 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, скрипты установки и обновления |
|
||||||
| `tests/fixtures/music` | Генератор тестовой библиотеки (нужен ffmpeg) |
|
| `tests/fixtures/music` | Генератор тестовой библиотеки (нужен ffmpeg) |
|
||||||
| `.gitea/workflows` | CI (`ci.yml`) и релиз по тегу (`release.yml`) |
|
| `.gitea/workflows` | CI по областям (`ci-*.yml`, `docker.yml`) и релиз по тегу (`release.yml`) |
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ docker compose -f deploy/compose.dev.yaml up -d
|
|||||||
API (из `services/api`):
|
API (из `services/api`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" go run ./cmd/api
|
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" MEILI_URL="http://localhost:7700" MEILI_MASTER_KEY="dev-master-key-change-me" LIBRARY_HOST_ROOT="$PWD/../../tests/fixtures/music/out" X_ACCEL=false CACHE_DIR="$PWD/.cache" go run ./cmd/api
|
||||||
```
|
```
|
||||||
|
|
||||||
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
|
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
|
||||||
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
||||||
|
pnpm check:boundaries
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -65,6 +67,6 @@ pnpm generate && (cd services/api && go generate ./...)
|
|||||||
|
|
||||||
## Релиз
|
## Релиз
|
||||||
|
|
||||||
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: проверки → подписанный APK → Gitea Release.
|
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
|
||||||
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
|
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
|
||||||
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
|
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты перечислены в шапке `.gitea/workflows/release.yml`.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
|
||||||
|
let version = process.env.PIPU_VERSION ?? '0.0.0';
|
||||||
|
if (!process.env.PIPU_VERSION) {
|
||||||
|
try {
|
||||||
|
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
|
||||||
|
version = tag.replace(/^v/, '');
|
||||||
|
} catch {
|
||||||
|
// local untagged build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conf.version = version;
|
||||||
|
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
|
||||||
|
console.log(`desktop version ${version}`);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@pipu/desktop",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
|
||||||
|
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles deb",
|
||||||
|
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
|
||||||
|
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"icons": "node ../../scripts/generate-icons.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[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"
|
||||||
|
sha2 = "0.10"
|
||||||
|
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||||
|
axum = "0.8"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
url = "2"
|
||||||
|
souvlaki = "0.8.3"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["windows-native"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
opt-level = "s"
|
||||||
|
panic = "abort"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "main",
|
||||||
|
"description": "Only the bundled main window may invoke desktop integration.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "core:event:default"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,160 @@
|
|||||||
|
mod media;
|
||||||
|
mod proxy;
|
||||||
|
mod store;
|
||||||
|
mod update;
|
||||||
|
|
||||||
|
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())
|
||||||
|
.manage(update::Updater::default())
|
||||||
|
.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,
|
||||||
|
update::update_check, update::update_install
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("desktop app failed to start");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
pipu_desktop_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{AppHandle, Emitter, WebviewWindow};
|
||||||
|
|
||||||
|
pub struct Media {
|
||||||
|
controls: Mutex<Option<MediaControls>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Media {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { controls: Mutex::new(None) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let hwnd = {
|
||||||
|
let _ = window;
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut controls = MediaControls::new(PlatformConfig {
|
||||||
|
dbus_name: "app.pipu.music.desktop",
|
||||||
|
display_name: "Pipu Music",
|
||||||
|
hwnd,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let handle = app.clone();
|
||||||
|
controls
|
||||||
|
.attach(move |event| {
|
||||||
|
let name = match event {
|
||||||
|
MediaControlEvent::Play => "play",
|
||||||
|
MediaControlEvent::Pause => "pause",
|
||||||
|
MediaControlEvent::Toggle => "toggle",
|
||||||
|
MediaControlEvent::Next => "next",
|
||||||
|
MediaControlEvent::Previous => "previous",
|
||||||
|
MediaControlEvent::Stop => "stop",
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let _ = handle.emit("media", name);
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
|
||||||
|
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
|
||||||
|
let Some(controls) = slot.as_mut() else { return Ok(()); };
|
||||||
|
controls
|
||||||
|
.set_metadata(MediaMetadata {
|
||||||
|
title: Some(title),
|
||||||
|
artist: Some(artist),
|
||||||
|
album: Some(album),
|
||||||
|
cover_url,
|
||||||
|
duration: None,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
|
||||||
|
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//! 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, x-pipu-transport-error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(_) => {
|
||||||
|
let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response();
|
||||||
|
response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1"));
|
||||||
|
response
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cors(response.headers_mut(), origin.as_deref());
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
|
||||||
|
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
|
||||||
|
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||||
|
let url = format!("{server}/api/{tail}{query}");
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let mut upstream = proxy.client.request(parts.method.clone(), url);
|
||||||
|
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
|
||||||
|
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
|
||||||
|
}
|
||||||
|
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
|
||||||
|
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
|
||||||
|
}
|
||||||
|
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
|
||||||
|
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
|
||||||
|
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
|
||||||
|
}
|
||||||
|
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
|
||||||
|
let body = if json && parts.method != Method::HEAD {
|
||||||
|
// Bound only JSON, never buffer a song or an upload in memory.
|
||||||
|
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||||
|
rewrite_json(&mut value, &server, &proxy.base);
|
||||||
|
headers.remove("content-length");
|
||||||
|
headers.remove("etag");
|
||||||
|
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
|
||||||
|
} else { Body::from_stream(upstream.bytes_stream()) };
|
||||||
|
let mut response = Response::new(body);
|
||||||
|
*response.status_mut() = status;
|
||||||
|
*response.headers_mut() = headers;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn validates_server_and_scopes_media_urls() {
|
||||||
|
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
|
||||||
|
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
|
||||||
|
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
|
||||||
|
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
|
||||||
|
rewrite_json(&mut value, "https://music.example", "http://local/key");
|
||||||
|
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
|
||||||
|
assert_eq!(value["tracks"][0]["title"], "/api/private");
|
||||||
|
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
const SERVICE: &str = "app.pipu.music.desktop";
|
||||||
|
|
||||||
|
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => {
|
||||||
|
let s = s.trim().to_string();
|
||||||
|
Ok((!s.is_empty()).then_some(s))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match server {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_entry() -> Result<keyring::Entry, String> {
|
||||||
|
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_refresh() -> Result<Option<String>, String> {
|
||||||
|
match refresh_entry()?.get_password() {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
|
||||||
|
let entry = refresh_entry()?;
|
||||||
|
match token {
|
||||||
|
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
|
||||||
|
None => match entry.delete_credential() {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match json {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Server-proxied releases: never accept installer paths or commands from the UI.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{io::Write, path::Path, time::Duration};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Build {
|
||||||
|
client: String,
|
||||||
|
version: String,
|
||||||
|
filename: String,
|
||||||
|
size: u64,
|
||||||
|
sha256: Option<String>,
|
||||||
|
download_url: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Catalog { version: String, notes_md: String, clients: Vec<Build> }
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct Release { build: Build, notes: String }
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Updater { release: Mutex<Option<(String, Release)>> }
|
||||||
|
|
||||||
|
fn client() -> &'static str {
|
||||||
|
if cfg!(target_os = "windows") { "windows" }
|
||||||
|
else if cfg!(target_os = "macos") { "macos" } else { "linux" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_code(raw: &str) -> Option<u64> {
|
||||||
|
let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v);
|
||||||
|
let parts: Vec<u64> = base.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;
|
||||||
|
let rc: u64 = rc.parse().ok()?;
|
||||||
|
if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; }
|
||||||
|
Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http() -> Result<reqwest::Client, String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result<Option<Release>, String> {
|
||||||
|
let mut saved = updater.release.lock().await;
|
||||||
|
*saved = None;
|
||||||
|
let server = crate::store::load_server(&app)?.ok_or("No server selected")?;
|
||||||
|
let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await
|
||||||
|
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?
|
||||||
|
.json().await.map_err(|e| e.to_string())?;
|
||||||
|
let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); };
|
||||||
|
let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?;
|
||||||
|
if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); }
|
||||||
|
let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() };
|
||||||
|
let release = Release { build, notes };
|
||||||
|
validate_build(&release.build)?;
|
||||||
|
*saved = Some((server, release.clone()));
|
||||||
|
Ok(Some(release))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_build(build: &Build) -> Result<(), String> {
|
||||||
|
let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
|
||||||
|
let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
|
let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" };
|
||||||
|
let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); }
|
||||||
|
// Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release.
|
||||||
|
if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) {
|
||||||
|
return Err("Invalid installer URL".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> {
|
||||||
|
let saved = updater.release.lock().await;
|
||||||
|
let (server, release) = saved.as_ref().ok_or("Check for an update first")?;
|
||||||
|
if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); }
|
||||||
|
let build = &release.build;
|
||||||
|
validate_build(build)?;
|
||||||
|
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates");
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
let path = dir.join(&build.filename);
|
||||||
|
let part = dir.join(format!("{}.part", uuid::Uuid::new_v4()));
|
||||||
|
let result = async {
|
||||||
|
let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await
|
||||||
|
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?;
|
||||||
|
let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?;
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
let mut bytes = 0u64;
|
||||||
|
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||||
|
bytes += chunk.len() as u64;
|
||||||
|
if bytes > build.size { return Err("Installer exceeds expected size".to_string()); }
|
||||||
|
hash.update(&chunk);
|
||||||
|
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||||
|
let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string());
|
||||||
|
}
|
||||||
|
if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); }
|
||||||
|
file.sync_all().map_err(|e| e.to_string())?;
|
||||||
|
drop(file);
|
||||||
|
if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; }
|
||||||
|
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
|
||||||
|
launch(&path)?;
|
||||||
|
Ok(())
|
||||||
|
}.await;
|
||||||
|
let _ = std::fs::remove_file(&part);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch(path: &Path) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") {
|
||||||
|
let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd
|
||||||
|
} else { std::process::Command::new(path) };
|
||||||
|
command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{ std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; }
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{ std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn release_order() {
|
||||||
|
assert!(version_code("1.7.2") > version_code("1.7.2-rc.3"));
|
||||||
|
assert!(version_code("1.8.0-rc.1") > version_code("1.7.99"));
|
||||||
|
assert_eq!(version_code("1.7.1"), Some(1070199));
|
||||||
|
assert_eq!(version_code("1.7.100"), None);
|
||||||
|
assert_eq!(version_code("1.7.1;cmd"), None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn installer_is_restricted_to_verified_server_asset() {
|
||||||
|
let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" };
|
||||||
|
let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42,
|
||||||
|
sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) };
|
||||||
|
assert!(validate_build(&build).is_ok());
|
||||||
|
build.download_url = "https://other.example/installer.exe".into();
|
||||||
|
assert!(validate_build(&build).is_err());
|
||||||
|
build.download_url = format!("/api/v1/app/download/{}/1.8.0", client());
|
||||||
|
build.filename = "../installer.exe".into();
|
||||||
|
assert!(validate_build(&build).is_err());
|
||||||
|
build.filename = format!("pipu.{extension}");
|
||||||
|
build.sha256 = None;
|
||||||
|
assert!(validate_build(&build).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
appId: app.pipu.music
|
||||||
|
---
|
||||||
|
- runFlow: login.yaml
|
||||||
|
- tapOn: "Коллекция"
|
||||||
|
- assertVisible: "Лайки"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
appId: app.pipu.music
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- assertVisible: "Адрес сервера"
|
||||||
|
- tapOn: "Адрес сервера"
|
||||||
|
- inputText: ${PIPU_E2E_SERVER}
|
||||||
|
- tapOn: "Подключиться"
|
||||||
|
- assertVisible: "Вход"
|
||||||
|
- tapOn: "Логин"
|
||||||
|
- inputText: ${PIPU_E2E_USER}
|
||||||
|
- tapOn: "Пароль"
|
||||||
|
- inputText: ${PIPU_E2E_PASSWORD}
|
||||||
|
- tapOn: "Войти"
|
||||||
|
- assertVisible: "Бегаю"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
appId: app.pipu.music
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- assertVisible: "Адрес сервера|Server address"
|
||||||
|
- stopApp
|
||||||
|
- launchApp
|
||||||
|
- assertVisible: "Адрес сервера|Server address"
|
||||||
|
- openLink: "pipu://album/1"
|
||||||
|
- assertVisible: "Адрес сервера|Server address"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
appId: app.pipu.music
|
||||||
|
---
|
||||||
|
- runFlow: login.yaml
|
||||||
|
- tapOn: "Профиль"
|
||||||
|
- assertVisible: "Проверить обновления"
|
||||||
|
- tapOn: "Проверить обновления"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Android
|
||||||
|
|
||||||
|
- `src/player/controller.ts` — фасад и зеркало native-очереди; `service.ts` — фоновые события. Владельцем воспроизводимой очереди остаётся TrackPlayer.
|
||||||
|
- Общая политика волны — `packages/core/src`; офлайн-адаптация — `src/player/offlineWave.ts`, `src/offline`.
|
||||||
|
- Сессия/настройки — `src/lib`; маршруты — `src/app`. Сохраняй работу при выгруженном UI и изоляцию офлайн-данных по пользователю/серверу.
|
||||||
|
- `pnpm --filter @pipu/mobile typecheck`, `pnpm --filter @pipu/mobile test`.
|
||||||
|
- Изменения native playback требуют проверки на устройстве: background, смена трека, offline→online. Unit-тесты не заменяют эту проверку.
|
||||||
@@ -9,12 +9,20 @@ 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',
|
||||||
newArchEnabled: true,
|
// react-native-track-player 4 не поддерживает New Architecture (React Native Directory) — фоновое
|
||||||
|
// воспроизведение и уведомление важнее; вернём, когда плеер её поддержит (docs/mobile.md).
|
||||||
|
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,
|
||||||
@@ -23,9 +31,17 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
},
|
},
|
||||||
platforms: ['android'],
|
platforms: ['android'],
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// Маршруты — файлы в src/app (docs/mobile.md).
|
||||||
|
['expo-router', { root: './src/app' }],
|
||||||
|
'expo-secure-store',
|
||||||
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
||||||
// Android по умолчанию запрещает незашифрованный HTTP.
|
// Android по умолчанию запрещает незашифрованный HTTP.
|
||||||
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
// Распаковываем .so при установке: SoLoader на x86_64 с ARM-трансляцией ищет
|
||||||
|
// внутри APK lib/x86_64 вместо arm64-v8a и не находит libreactnative.so.
|
||||||
|
['expo-build-properties', { android: { usesCleartextTraffic: true, useLegacyPackaging: true } }],
|
||||||
|
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
|
||||||
|
'./plugins/withArm64Only',
|
||||||
],
|
],
|
||||||
extra: { versionCode },
|
extra: { versionCode },
|
||||||
|
experiments: { typedRoutes: false },
|
||||||
});
|
});
|
||||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -1,5 +1,9 @@
|
|||||||
import { registerRootComponent } from 'expo';
|
// Фоновый сервис плеера регистрируется до запуска UI: кнопки уведомления и гарнитуры
|
||||||
|
// работают, даже когда интерфейс выгружен (docs/mobile.md#воспроизведение).
|
||||||
|
import TrackPlayer from 'react-native-track-player';
|
||||||
|
|
||||||
import { App } from './src/App';
|
import { PlaybackService } from './src/player/service';
|
||||||
|
|
||||||
registerRootComponent(App);
|
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
||||||
|
|
||||||
|
import 'expo-router/entry';
|
||||||
@@ -1,4 +1,21 @@
|
|||||||
// Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths).
|
// Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths).
|
||||||
|
const path = require('node:path');
|
||||||
const { getDefaultConfig } = require('expo/metro-config');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
module.exports = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
// watchFolders включает весь apps/mobile, а без watchman Metro обходит и хэширует каждый файл.
|
||||||
|
// android/ и ios/ после prebuild содержат гигабайты промежуточных файлов Gradle/CMake —
|
||||||
|
// в release-сборке Metro на них «висит». В JS-бандл из этих папок ничего не попадает.
|
||||||
|
const escape = (p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const nativeDirs = ['android', 'ios'].map((d) => new RegExp(`^${escape(path.join(__dirname, d) + path.sep)}.*`));
|
||||||
|
// Gradle собирает нативные модули прямо в node_modules/<пакет>/android/{build,.cxx,.gradle},
|
||||||
|
// а корневой node_modules тоже в watchFolders.
|
||||||
|
const nativeBuildOutputs = /[\\/]android[\\/](build|\.cxx|\.gradle)[\\/].*/;
|
||||||
|
|
||||||
|
config.resolver.blockList = [...[].concat(config.resolver.blockList ?? []), ...nativeDirs, nativeBuildOutputs];
|
||||||
|
|
||||||
|
// В CI раннер делит 8 ГБ RAM с Gradle: по умолчанию Metro запускает воркер на каждое ядро.
|
||||||
|
if (process.env.CI) config.maxWorkers = 2;
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -7,23 +7,53 @@
|
|||||||
"start": "expo start --dev-client",
|
"start": "expo start --dev-client",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"prebuild": "expo prebuild --platform android --clean",
|
"prebuild": "expo prebuild --platform android --clean",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@pipu/api-client": "workspace:*",
|
"@pipu/api-client": "workspace:*",
|
||||||
"@pipu/core": "workspace:*",
|
"@pipu/core": "workspace:*",
|
||||||
"@pipu/design-tokens": "workspace:*",
|
"@pipu/design-tokens": "workspace:*",
|
||||||
"@pipu/i18n": "workspace:*",
|
"@pipu/i18n": "workspace:*",
|
||||||
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
|
"@shopify/react-native-skia": "2.0.0-next.4",
|
||||||
|
"@tanstack/react-query": "^5.104.4",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
"expo-build-properties": "~0.14.8",
|
"expo-build-properties": "~0.14.8",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
|
"expo-file-system": "~18.1.11",
|
||||||
|
"expo-font": "~13.3.2",
|
||||||
|
"expo-intent-launcher": "~12.1.5",
|
||||||
|
"expo-linking": "~7.1.7",
|
||||||
|
"expo-router": "~5.1.11",
|
||||||
|
"expo-secure-store": "~14.2.4",
|
||||||
|
"expo-sqlite": "~15.2.14",
|
||||||
"expo-status-bar": "~2.2.3",
|
"expo-status-bar": "~2.2.3",
|
||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
|
"js-sha256": "^1.0.0",
|
||||||
|
"query-string": "^7.1.3",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-i18next": "^15.5.3",
|
"react-i18next": "^15.5.3",
|
||||||
"react-native": "0.79.6"
|
"react-native": "0.79.6",
|
||||||
|
"react-native-reanimated": "~3.17.5",
|
||||||
|
"react-native-safe-area-context": "5.4.0",
|
||||||
|
"react-native-screens": "~4.11.1",
|
||||||
|
"react-native-track-player": "^4.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.0.10"
|
"@types/node": "^22.20.5",
|
||||||
|
"@types/react": "~19.0.10",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
|
},
|
||||||
|
"expo": {
|
||||||
|
"doctor": {
|
||||||
|
"reactNativeDirectoryCheck": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native-track-player"
|
||||||
|
],
|
||||||
|
"listUnknownPackages": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI
|
||||||
|
// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят
|
||||||
|
// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог
|
||||||
|
// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so».
|
||||||
|
const { withAppBuildGradle } = require('expo/config-plugins');
|
||||||
|
|
||||||
|
const MARKER = '// pipu: arm64-v8a only';
|
||||||
|
|
||||||
|
module.exports = function withArm64Only(config) {
|
||||||
|
return withAppBuildGradle(config, (cfg) => {
|
||||||
|
if (cfg.modResults.contents.includes(MARKER)) return cfg;
|
||||||
|
cfg.modResults.contents = cfg.modResults.contents.replace(
|
||||||
|
/defaultConfig\s*\{/,
|
||||||
|
(m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`,
|
||||||
|
);
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { createI18n, pickLocale } from '@pipu/i18n';
|
|
||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import type { i18n as I18n } from 'i18next';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { I18nextProvider } from 'react-i18next';
|
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
|
||||||
|
|
||||||
import { ConnectScreen } from './ConnectScreen';
|
|
||||||
import { colors } from './theme';
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
const [i18n, setI18n] = useState<I18n | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
||||||
void createI18n(pickLocale([deviceLocale])).then(setI18n);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!i18n) {
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
|
|
||||||
<ActivityIndicator color={colors.accent} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<I18nextProvider i18n={i18n}>
|
|
||||||
<StatusBar style="light" />
|
|
||||||
<ConnectScreen />
|
|
||||||
</I18nextProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { createApiClient } from '@pipu/api-client';
|
|
||||||
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
|
|
||||||
import Constants from 'expo-constants';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
||||||
|
|
||||||
import { colors, radius } from './theme';
|
|
||||||
|
|
||||||
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
|
|
||||||
|
|
||||||
type State = { kind: 'idle' } | { kind: 'checking' } | { kind: 'invalid' } | ({ kind: 'done' } & ServerCheck);
|
|
||||||
|
|
||||||
/** Экран подключения к серверу (docs/mobile.md). В M0 — только проверка /server-info. */
|
|
||||||
export function ConnectScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [address, setAddress] = useState('');
|
|
||||||
const [state, setState] = useState<State>({ kind: 'idle' });
|
|
||||||
|
|
||||||
const checking = state.kind === 'checking';
|
|
||||||
|
|
||||||
async function onCheck() {
|
|
||||||
if (checking) return;
|
|
||||||
const baseUrl = normalizeServerUrl(address);
|
|
||||||
if (!baseUrl) {
|
|
||||||
setState({ kind: 'invalid' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState({ kind: 'checking' });
|
|
||||||
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
|
|
||||||
setState({ kind: 'done', ...res });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.screen}>
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.title}>{t('app.name')}</Text>
|
|
||||||
<Text style={styles.muted}>{t('app.tagline')}</Text>
|
|
||||||
|
|
||||||
<Text style={styles.label}>{t('server.address')}</Text>
|
|
||||||
<TextInput
|
|
||||||
value={address}
|
|
||||||
onChangeText={setAddress}
|
|
||||||
onSubmitEditing={onCheck}
|
|
||||||
editable={!checking}
|
|
||||||
placeholder="https://music.home.lan"
|
|
||||||
placeholderTextColor={colors.text3}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="url"
|
|
||||||
style={styles.input}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{state.kind === 'checking' && (
|
|
||||||
<View style={[styles.status, { backgroundColor: colors.surface2 }]}>
|
|
||||||
<ActivityIndicator color={colors.text2} />
|
|
||||||
<Text style={{ color: colors.text2 }}>{t('server.checking')}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{state.kind === 'invalid' && <Status ok={false} text={t('server.invalid_address')} />}
|
|
||||||
{state.kind === 'done' && state.status === 'ok' && (
|
|
||||||
<Status ok text={t('server.online', { version: state.info.version })} />
|
|
||||||
)}
|
|
||||||
{state.kind === 'done' && state.status === 'unsupported_app' && (
|
|
||||||
<Status ok={false} text={t('server.unsupported_app')} />
|
|
||||||
)}
|
|
||||||
{state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />}
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={onCheck}
|
|
||||||
disabled={checking}
|
|
||||||
accessibilityState={{ disabled: checking, busy: checking }}
|
|
||||||
style={({ pressed }) => [styles.button, (pressed || checking) && { opacity: 0.6 }]}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>{t('server.check')}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Status({ ok, text }: { ok: boolean; text: string }) {
|
|
||||||
return (
|
|
||||||
<View style={[styles.status, { backgroundColor: ok ? colors['ok-soft'] : colors['danger-soft'] }]}>
|
|
||||||
<Text style={{ color: ok ? colors.ok : colors.danger, fontWeight: '500' }}>{text}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
screen: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: 16 },
|
|
||||||
card: {
|
|
||||||
gap: 12,
|
|
||||||
padding: 24,
|
|
||||||
borderRadius: radius.xl,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.line2,
|
|
||||||
},
|
|
||||||
title: { color: colors.text, fontSize: 26, fontWeight: '600' },
|
|
||||||
muted: { color: colors.text2, lineHeight: 21 },
|
|
||||||
label: { color: colors.text2, fontSize: 13, fontWeight: '500', marginTop: 8 },
|
|
||||||
input: {
|
|
||||||
height: 48,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
backgroundColor: colors.surface2,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.line2,
|
|
||||||
color: colors.text,
|
|
||||||
fontSize: 15,
|
|
||||||
},
|
|
||||||
status: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12, borderRadius: 10 },
|
|
||||||
button: {
|
|
||||||
height: 48,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
backgroundColor: colors.accent,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useNetInfo } from '@react-native-community/netinfo';
|
||||||
|
import { Tabs, useRouter } from 'expo-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Cover, Icon, type IconName } from '../../ui/kit';
|
||||||
|
import { NativeChatProvider, useSocialUnread } from '../../features/social/Social';
|
||||||
|
import { ListeningBanner } from '../../features/social/Listening';
|
||||||
|
import { UpdateDialog } from '../../ui/UpdateDialog';
|
||||||
|
|
||||||
|
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unread = useSocialUnread();
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
index: t('nav.home'),
|
||||||
|
search: t('mobile.search'),
|
||||||
|
collection: t('nav.collection'),
|
||||||
|
profile: t('mobile.profile'),
|
||||||
|
friends: t('social.people'),
|
||||||
|
};
|
||||||
|
const net = useNetInfo();
|
||||||
|
return (
|
||||||
|
<NativeChatProvider>
|
||||||
|
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
|
||||||
|
tabBar={({ state, navigation }) => (
|
||||||
|
<View>
|
||||||
|
<ListeningBanner />
|
||||||
|
<MiniPlayer />
|
||||||
|
<TabBar
|
||||||
|
routes={state.routes.map((r) => r.name)}
|
||||||
|
active={state.index}
|
||||||
|
labels={labels}
|
||||||
|
badges={{ friends: unread }}
|
||||||
|
onPress={(name) => navigation.navigate(name)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Tabs.Screen name="index" />
|
||||||
|
<Tabs.Screen name="search" />
|
||||||
|
<Tabs.Screen name="collection" />
|
||||||
|
<Tabs.Screen name="friends" />
|
||||||
|
<Tabs.Screen name="profile" />
|
||||||
|
</Tabs>
|
||||||
|
<UpdateDialog />
|
||||||
|
</NativeChatProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabBar({
|
||||||
|
routes,
|
||||||
|
active,
|
||||||
|
labels,
|
||||||
|
badges,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
routes: string[];
|
||||||
|
active: number;
|
||||||
|
labels: Record<string, string>;
|
||||||
|
badges: Record<string, number>;
|
||||||
|
onPress: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
accessibilityRole="tablist"
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
backgroundColor: colors.bg2,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{routes.map((name, i) => {
|
||||||
|
const on = i === active;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={name}
|
||||||
|
onPress={() => onPress(name)}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{ selected: on }}
|
||||||
|
accessibilityLabel={`${labels[name]}${badges[name] ? ` · ${badges[name]}` : ''}`}
|
||||||
|
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||||
|
{!!badges[name] && <Text style={{ position: 'absolute', top: -5, left: 14, minWidth: 16, paddingHorizontal: 4, borderRadius: 9, backgroundColor: colors['accent-fill'], color: '#fff', fontSize: 10, textAlign: 'center' }}>{badges[name]! > 99 ? '99+' : badges[name]}</Text>}
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
|
||||||
|
{labels[name]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Мини-плеер над навигацией; нажатие — полноэкранный плеер. */
|
||||||
|
function MiniPlayer() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const track = st.track;
|
||||||
|
if (!track) return null;
|
||||||
|
const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push('/player')}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('mobile.open_player')}
|
||||||
|
style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }}
|
||||||
|
>
|
||||||
|
<View style={{ height: 2, backgroundColor: colors.surface3 }}>
|
||||||
|
<View style={{ height: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 8 }}>
|
||||||
|
<Cover album={track.album} size={42} />
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 12 }}>
|
||||||
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}>
|
||||||
|
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={32} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||||
|
<Icon name="skip-next" size={28} color={colors.text2} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нет сети: доступны «Скачанное» и офлайн-волна. */
|
||||||
|
function OfflineBanner({ text }: { text: string }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return (
|
||||||
|
<View accessibilityRole="alert" style={{ paddingTop: insets.top + 6, paddingBottom: 6, paddingHorizontal: 16, backgroundColor: colors.surface3, flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||||
|
<Icon name="cloud-off" size={16} color={colors.text2} />
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 12 }}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { unwrap, type Track } from '@pipu/core';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession } from '../../lib/session';
|
||||||
|
import { downloads } from '../../offline/downloads';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { DownloadButton, useDownloads } from '../../ui/downloads';
|
||||||
|
import { Button, H1, Muted, Screen, Segmented, TrackRow } from '../../ui/kit';
|
||||||
|
|
||||||
|
type Tab = 'history' | 'likes' | 'dislikes' | 'downloads';
|
||||||
|
|
||||||
|
export default function CollectionScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const current = usePlayerState().track;
|
||||||
|
const [tab, setTab] = useState<Tab>('likes');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: ['m-collection', tab],
|
||||||
|
enabled: tab !== 'downloads',
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const query = { query: { cursor: pageParam, limit: 100 } };
|
||||||
|
if (tab === 'history') {
|
||||||
|
const page = unwrap(await session.client.GET('/api/v1/me/history', { params: query }));
|
||||||
|
return { items: page.items.map((i) => ({ track: i.track, at: i.played_at })), next: page.next_cursor };
|
||||||
|
}
|
||||||
|
const path = tab === 'likes' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
|
||||||
|
const page = unwrap(await session.client.GET(path, { params: query }));
|
||||||
|
return { items: page.items.map((i) => ({ track: i.track, at: i.created_at })), next: page.next_cursor };
|
||||||
|
},
|
||||||
|
getNextPageParam: (p) => p.next ?? undefined,
|
||||||
|
});
|
||||||
|
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
|
/** Все лайки целиком, а не только загруженные страницы списка. */
|
||||||
|
const downloadAllLikes = async () => {
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = unwrap(await session.client.GET('/api/v1/me/likes', { params: { query: { cursor, limit: 100 } } }));
|
||||||
|
await downloads.enqueue(page.items.map((i) => i.track));
|
||||||
|
cursor = page.next_cursor ?? undefined;
|
||||||
|
} while (cursor);
|
||||||
|
};
|
||||||
|
const tracks: Track[] = items.map((i) => i.track);
|
||||||
|
const time = new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
const context = tab === 'history' ? ({ type: 'history' } as const) : ({ type: 'collection', id: tab } as const);
|
||||||
|
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
||||||
|
|
||||||
|
const tabs = (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
|
||||||
|
<Segmented<Tab>
|
||||||
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
options={[
|
||||||
|
{ value: 'likes', label: t('collection.likes') },
|
||||||
|
{ value: 'history', label: t('collection.history') },
|
||||||
|
{ value: 'downloads', label: t('mobile.dl_title') },
|
||||||
|
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(it, i) => `${it.track.id}-${i}`}
|
||||||
|
onEndReached={() => list.hasNextPage && void list.fetchNextPage()}
|
||||||
|
onRefresh={() => void list.refetch()}
|
||||||
|
refreshing={list.isRefetching}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ padding: 16, gap: 14 }}>
|
||||||
|
<H1>{t('collection.title')}</H1>
|
||||||
|
{tabs}
|
||||||
|
{tab === 'likes' && tracks.length > 0 && (
|
||||||
|
<Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(tracks, 0, context)} />
|
||||||
|
)}
|
||||||
|
{tab === 'likes' && tracks.length > 0 && (
|
||||||
|
<Button title={t('mobile.dl_all_likes')} variant="secondary" icon="download" onPress={() => void downloadAllLikes()} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={list.isSuccess ? <Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t(empty[tab])}</Text> : null}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item.track}
|
||||||
|
current={current?.id === item.track.id}
|
||||||
|
meta={tab === 'history' ? time.format(new Date(item.at)) : undefined}
|
||||||
|
onPress={() => void player.playList(tracks, index, context)}
|
||||||
|
onLike={tab === 'dislikes' ? undefined : () => void player.react(item.track, item.track.reaction === 'like' ? null : 'like').then(() => list.refetch())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
/** Коллекция → Скачанное: работает без сети, данные — из SQLite. */
|
||||||
|
function DownloadsTab({ tabs }: { tabs: React.ReactNode }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dl = useDownloads();
|
||||||
|
const current = usePlayerState().track;
|
||||||
|
const [usage, setUsage] = useState<{ used: number; free: number } | null>(null);
|
||||||
|
const items = [...dl.items.values()];
|
||||||
|
const done = items.filter((i) => i.status === 'done').map((i) => i.track);
|
||||||
|
const pending = items.filter((i) => i.status === 'queued' || i.status === 'downloading').length;
|
||||||
|
const failed = items.some((i) => i.status === 'error');
|
||||||
|
const context = { type: 'collection', id: 'downloads' } as const;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void downloads.usage().then(setUsage);
|
||||||
|
}, [done.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(it) => it.track.id}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ padding: 16, gap: 14 }}>
|
||||||
|
<H1>{t('collection.title')}</H1>
|
||||||
|
{tabs}
|
||||||
|
{usage && <Muted>{t('mobile.dl_usage', { used: (usage.used / MB).toFixed(0), free: (usage.free / MB / 1024).toFixed(1) })}</Muted>}
|
||||||
|
{pending > 0 && (
|
||||||
|
<Muted>{t(dl.allowed ? 'mobile.dl_pending' : dl.wifiOnly ? 'mobile.dl_waiting_wifi' : 'mobile.dl_waiting_network', { count: pending })}</Muted>
|
||||||
|
)}
|
||||||
|
{failed && <Button title={t('mobile.dl_retry')} variant="secondary" icon="refresh" onPress={() => void downloads.retry()} />}
|
||||||
|
{done.length > 0 && <Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(done, 0, context)} />}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={<Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t('mobile.dl_empty')}</Text>}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item.track}
|
||||||
|
current={current?.id === item.track.id}
|
||||||
|
meta={item.status === 'downloading' ? `${Math.round(item.progress * 100)}%` : undefined}
|
||||||
|
onPress={() => {
|
||||||
|
if (item.status === 'done') void player.playList(done, done.indexOf(item.track), context);
|
||||||
|
}}
|
||||||
|
trailing={<DownloadButton track={item.track} size={20} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { SocialScreen } from '../../features/social/Social';
|
||||||
|
export default function FriendsTab() { return <SocialScreen root />; }
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../../lib/session';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
|
||||||
|
import { OnboardingGate } from '../../ui/Onboarding';
|
||||||
|
import { WaveOrb } from '../../ui/wave/WaveOrb';
|
||||||
|
|
||||||
|
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
|
||||||
|
function iconFor(name: string): IconName {
|
||||||
|
const map: Record<string, string> = { rainy: 'umbrella', sentiment_satisfied: 'sentiment-satisfied' };
|
||||||
|
return (map[name] ?? name.replace(/_/g, '-')) as IconName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главная: кнопка «Моя волна» (Skia, вид orb / liquid из профиля) и выбор занятия / настроения.
|
||||||
|
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
|
||||||
|
*/
|
||||||
|
export default function HomeScreen() {
|
||||||
|
return <OnboardingGate><WaveHome /></OnboardingGate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaveHome() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tr = t as unknown as (k: string) => string;
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
|
||||||
|
const [mood, setMood] = useState<string | null>(st.wave?.mood ?? null);
|
||||||
|
const options = useQuery({
|
||||||
|
queryKey: ['wave-options'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const waveOn = st.wave !== null;
|
||||||
|
const playing = waveOn && st.playing;
|
||||||
|
const run = (fn: () => Promise<void>) => {
|
||||||
|
setError(null);
|
||||||
|
fn().catch(setError);
|
||||||
|
};
|
||||||
|
const onWave = () => (waveOn ? player.toggle() : run(() => player.startWave(activity, mood)));
|
||||||
|
const choose = (kind: 'activity' | 'mood', key: string) => {
|
||||||
|
const a = kind === 'activity' ? (activity === key ? null : key) : activity;
|
||||||
|
const m = kind === 'mood' ? (mood === key ? null : key) : mood;
|
||||||
|
setActivity(a);
|
||||||
|
setMood(m);
|
||||||
|
if (waveOn) run(() => player.setWaveContext(a, m));
|
||||||
|
};
|
||||||
|
const ctx = [activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') || t('wave.for_you');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ alignItems: 'center', paddingVertical: 32, gap: 24, paddingHorizontal: 16 }}>
|
||||||
|
<WaveOrb
|
||||||
|
variant={user.ui_prefs.wave_variant ?? 'orb'}
|
||||||
|
size={260}
|
||||||
|
playing={playing}
|
||||||
|
bpm={st.track?.features?.bpm ?? 100}
|
||||||
|
energy={st.track?.features?.energy ?? 0.6}
|
||||||
|
colors={waveOn ? st.track?.album.cover_colors : undefined}
|
||||||
|
onPress={onWave}
|
||||||
|
label={t(playing ? 'wave.pause' : 'wave.start')}
|
||||||
|
>
|
||||||
|
<Icon name={playing ? 'pause' : 'play-arrow'} size={44} color="#fff" />
|
||||||
|
<Text style={{ color: '#fff', fontSize: 22, fontWeight: '700' }}>{t('wave.title')}</Text>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 13 }}>{ctx}</Text>
|
||||||
|
</WaveOrb>
|
||||||
|
{st.wave?.fallback && <Text style={{ color: colors.text2, fontSize: 13 }}>{t('wave.fallback')}</Text>}
|
||||||
|
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
|
||||||
|
{options.data && (
|
||||||
|
<>
|
||||||
|
<Chips title={t('wave.activity_label')} options={options.data.activities} value={activity} label={(k) => tr(`wave.activities.${k}`)} onPick={(k) => choose('activity', k)} />
|
||||||
|
<Chips title={t('wave.mood_label')} options={options.data.moods} value={mood} label={(k) => tr(`wave.moods.${k}`)} onPick={(k) => choose('mood', k)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chips({
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
options: { key: string; icon: string }[];
|
||||||
|
value: string | null;
|
||||||
|
label: (k: string) => string;
|
||||||
|
onPick: (k: string) => void;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={{ alignItems: 'center', gap: 10, width: '100%' }}>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 11, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase' }}>{title}</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const on = value === o.key;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={o.key}
|
||||||
|
onPress={() => onPick(o.key)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: on }}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
height: 38,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: on ? colors['accent-fill'] : colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: on ? colors.accent : colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={iconFor(o.icon)} size={18} color={on ? '#fff' : colors.text2} />
|
||||||
|
<Text style={{ color: on ? '#fff' : colors.text, fontWeight: '500' }}>{label(o.key)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, ScrollView, Switch, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { playerCache } from '../../lib/prefs';
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||||
|
import { updater } from '../../lib/update';
|
||||||
|
import { downloads } from '../../offline/downloads';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { radius, useColors } from '../../theme';
|
||||||
|
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
||||||
|
import { useDownloads } from '../../ui/downloads';
|
||||||
|
import { RecommendationSettings } from '../../ui/Onboarding';
|
||||||
|
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
|
||||||
|
import { useUpdateState } from '../../ui/UpdateDialog';
|
||||||
|
|
||||||
|
type UiPrefs = components['schemas']['UiPrefs'];
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const { server, disconnect } = useSessionCtx();
|
||||||
|
const staff = user.role !== 'user';
|
||||||
|
const update = useUpdateState();
|
||||||
|
const dl = useDownloads();
|
||||||
|
const quality = useQualityPrefs();
|
||||||
|
const [sheet, setSheet] = useState<'quality' | 'password' | null>(null);
|
||||||
|
const [cache, setCache] = useState<{ bytes: number; pending: boolean } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.all([playerCache.size(), playerCache.clearPending()]).then(([bytes, pending]) => setCache({ bytes, pending }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Оптимистично, как в вебе: настройка применяется сразу, сервер подтверждает.
|
||||||
|
const setPrefs = async (prefs: UiPrefs) => {
|
||||||
|
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, ...prefs } });
|
||||||
|
try {
|
||||||
|
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: prefs } })));
|
||||||
|
} catch {
|
||||||
|
session.setUser(user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const clearCache = async () => {
|
||||||
|
await playerCache.requestClear();
|
||||||
|
setCache((c) => (c ? { ...c, pending: true } : c));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLocale = async (l: Locale) => {
|
||||||
|
await i18n.changeLanguage(l);
|
||||||
|
try {
|
||||||
|
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
|
||||||
|
} catch {
|
||||||
|
// язык уже сменился локально
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await player.reset();
|
||||||
|
await session.logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 16, gap: 16 }}>
|
||||||
|
<H1>{user.display_name ?? user.username}</H1>
|
||||||
|
<Muted>
|
||||||
|
@{user.username} · {t(`roles.${user.role}`)}
|
||||||
|
</Muted>
|
||||||
|
|
||||||
|
<RecommendationSettings />
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Row label={t('language.label')}>
|
||||||
|
<Segmented<Locale>
|
||||||
|
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
|
||||||
|
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
|
||||||
|
onChange={(l) => void setLocale(l)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Row label={t('settings.theme')}>
|
||||||
|
<Segmented<NonNullable<UiPrefs['theme']>>
|
||||||
|
value={user.ui_prefs.theme ?? 'system'}
|
||||||
|
options={[
|
||||||
|
{ value: 'dark', label: t('settings.theme_dark') },
|
||||||
|
{ value: 'light', label: t('settings.theme_light') },
|
||||||
|
{ value: 'system', label: t('settings.theme_system') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => void setPrefs({ theme: v })}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('settings.wave_variant')}>
|
||||||
|
<Segmented<NonNullable<UiPrefs['wave_variant']>>
|
||||||
|
value={user.ui_prefs.wave_variant ?? 'orb'}
|
||||||
|
options={[
|
||||||
|
{ value: 'orb', label: t('settings.wave_orb') },
|
||||||
|
{ value: 'liquid', label: t('settings.wave_liquid') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => void setPrefs({ wave_variant: v })}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('mobile.player_view')}>
|
||||||
|
<Segmented<NonNullable<UiPrefs['mobile_player']>>
|
||||||
|
value={user.ui_prefs.mobile_player ?? 'cover'}
|
||||||
|
options={[
|
||||||
|
{ value: 'cover', label: t('mobile.player_cover') },
|
||||||
|
{ value: 'wave', label: t('mobile.player_wave') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => void setPrefs({ mobile_player: v })}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('settings.normalize')}>
|
||||||
|
<Switch value={user.ui_prefs.normalize !== false} onValueChange={(v) => void setPrefs({ normalize: v })} />
|
||||||
|
</Row>
|
||||||
|
<Row label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}>
|
||||||
|
<Segmented
|
||||||
|
value={String([0, 2, 4, 6].includes(user.ui_prefs.crossfade_s ?? 0) ? (user.ui_prefs.crossfade_s ?? 0) : 0)}
|
||||||
|
options={['0', '2', '4', '6'].map((v) => ({ value: v, label: v }))}
|
||||||
|
onChange={(v) => void setPrefs({ crossfade_s: Number(v) })}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Pressable onPress={() => setSheet('quality')} accessibilityRole="button" style={{ paddingVertical: 4 }}>
|
||||||
|
<Row label={t('mobile.quality')}>
|
||||||
|
<Text style={{ color: colors.text2 }}>
|
||||||
|
{t(`mobile.q_${quality.wifi}`)} · {t(`mobile.q_${quality.cellular}`)}
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
</Pressable>
|
||||||
|
<Row label={t('mobile.cache')}>
|
||||||
|
{cache?.pending ? (
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('mobile.cache_pending')}</Text>
|
||||||
|
) : (
|
||||||
|
<Pressable onPress={() => void clearCache()} accessibilityRole="button" hitSlop={8}>
|
||||||
|
<Text style={{ color: colors.accent2, fontWeight: '500' }}>
|
||||||
|
{t('mobile.cache_clear', { size: ((cache?.bytes ?? 0) / MB).toFixed(0) })}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
<Row label={t('mobile.dl_wifi_only')}>
|
||||||
|
<Switch value={dl.wifiOnly} onValueChange={(v) => void downloads.setWifiOnly(v)} />
|
||||||
|
</Row>
|
||||||
|
<Row label={t('mobile.dl_auto_likes')}>
|
||||||
|
<Switch value={dl.autoLikes} onValueChange={(v) => void downloads.setAutoLikes(v)} />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Row label={t('mobile.server')}>
|
||||||
|
<Text style={{ color: colors.text2 }} numberOfLines={1}>
|
||||||
|
{server?.replace(/^https?:\/\//, '')}
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
{staff && server && (
|
||||||
|
<Pressable onPress={() => void Linking.openURL(`${server}/admin/users`)} accessibilityRole="link" style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }}>
|
||||||
|
<Icon name="open-in-new" size={20} color={colors.accent2} />
|
||||||
|
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.open_admin')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Button title={t('settings.change_password')} variant="secondary" icon="lock" onPress={() => setSheet('password')} />
|
||||||
|
<Button
|
||||||
|
title={t(update.status === 'checking' ? 'mobile.update_checking' : 'mobile.update_check')}
|
||||||
|
variant="secondary"
|
||||||
|
icon="system-update"
|
||||||
|
busy={update.status === 'checking'}
|
||||||
|
onPress={() => void updater.check(session, true)}
|
||||||
|
/>
|
||||||
|
{update.status === 'latest' && <Muted>{t('mobile.update_latest')}</Muted>}
|
||||||
|
|
||||||
|
<Button title={t('profile.logout')} variant="danger" icon="logout" onPress={() => void logout()} />
|
||||||
|
<Button title={t('mobile.change_server')} variant="ghost" onPress={() => void logout().then(disconnect)} />
|
||||||
|
<Text style={{ color: colors.text3, textAlign: 'center', fontSize: 12 }}>
|
||||||
|
{t('mobile.version', { version: Constants.expoConfig?.version ?? '0.0.0' })}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
<QualitySheet visible={sheet === 'quality'} onClose={() => setSheet(null)} />
|
||||||
|
<PasswordSheet visible={sheet === 'password'} onClose={() => setSheet(null)} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ children }: { children: React.ReactNode }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<Text style={{ color: colors.text }}>{label}</Text>
|
||||||
|
<View style={{ flexShrink: 1 }}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession } from '../../lib/session';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { radius, useColors } from '../../theme';
|
||||||
|
import { Cover, Icon, Screen, TrackRow, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
const RECENT_KEY = 'pipu.recent_searches';
|
||||||
|
|
||||||
|
/** Поиск: подсказки по мере ввода (debounce 250 мс), недавние запросы — локально. */
|
||||||
|
export default function SearchScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const current = usePlayerState().track;
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [debounced, setDebounced] = useState('');
|
||||||
|
const [recent, setRecent] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void SecureStore.getItemAsync(RECENT_KEY).then((v) => setRecent(v ? (JSON.parse(v) as string[]) : []));
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setDebounced(q.trim()), 250);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const remember = (query: string) => {
|
||||||
|
const next = [query, ...recent.filter((r) => r !== query)].slice(0, 10);
|
||||||
|
setRecent(next);
|
||||||
|
void SecureStore.setItemAsync(RECENT_KEY, JSON.stringify(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = useQuery({
|
||||||
|
queryKey: ['m-search', debounced],
|
||||||
|
enabled: debounced !== '',
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: async ({ signal }) =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/search', { params: { query: { q: debounced } }, signal })),
|
||||||
|
});
|
||||||
|
const data = debounced ? res.data : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<View style={{ padding: 16, gap: 8 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, height: 46, paddingHorizontal: 12, borderRadius: radius.md, backgroundColor: colors.surface2 }}>
|
||||||
|
<Icon name="search" color={colors.text3} />
|
||||||
|
<TextInput
|
||||||
|
value={q}
|
||||||
|
onChangeText={setQ}
|
||||||
|
onSubmitEditing={() => q.trim() && remember(q.trim())}
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
placeholderTextColor={colors.text3}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
accessibilityLabel={t('search.label')}
|
||||||
|
style={{ flex: 1, color: colors.text, fontSize: 16 }}
|
||||||
|
returnKeyType="search"
|
||||||
|
/>
|
||||||
|
{!!q && (
|
||||||
|
<Pressable onPress={() => setQ('')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||||
|
<Icon name="close" color={colors.text3} size={20} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{data?.corrected_query && <Text style={{ color: colors.warn, fontSize: 13 }}>{t('search.corrected', { query: data.corrected_query })}</Text>}
|
||||||
|
{res.error && <Text style={{ color: colors.danger }}>{errorText(res.error)}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!debounced ? (
|
||||||
|
<View style={{ paddingHorizontal: 16, gap: 4 }}>
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12, fontWeight: '700', textTransform: 'uppercase' }}>{t('mobile.recent_searches')}</Text>
|
||||||
|
<Pressable onPress={() => { setRecent([]); void SecureStore.deleteItemAsync(RECENT_KEY); }} accessibilityRole="button">
|
||||||
|
<Text style={{ color: colors.accent2 }}>{t('mobile.clear_recent')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{recent.map((r) => (
|
||||||
|
<Pressable key={r} onPress={() => setQ(r)} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10 }}>
|
||||||
|
<Icon name="history" size={20} color={colors.text3} />
|
||||||
|
<Text style={{ color: colors.text }}>{r}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={data?.tracks ?? []}
|
||||||
|
keyExtractor={(tr) => tr.id}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
{data?.artists.map((a) => (
|
||||||
|
<Pressable key={a.id} onPress={() => { remember(debounced); router.push(`/artist/${a.id}`); }} style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||||
|
<Cover album={{ id: a.id }} size={44} round />
|
||||||
|
<View>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '600' }}>{a.name}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('search.artist_kind')}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
{data?.albums.map((al) => (
|
||||||
|
<Pressable key={al.id} onPress={() => { remember(debounced); router.push(`/album/${al.id}`); }} style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||||
|
<Cover album={al} size={44} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>{al.title}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{[al.artist.name, al.year].filter(Boolean).join(' · ')}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
res.isSuccess && !data?.artists.length && !data?.albums.length ? (
|
||||||
|
<Text style={{ color: colors.text3, textAlign: 'center', marginTop: 24 }}>{t('search.nothing')}</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item}
|
||||||
|
current={current?.id === item.id}
|
||||||
|
onPress={() => {
|
||||||
|
remember(debounced);
|
||||||
|
void player.playList(data?.tracks ?? [], index, { type: 'search', id: debounced });
|
||||||
|
}}
|
||||||
|
onLike={() => void player.react(item, item.reaction === 'like' ? null : 'like').then(() => res.refetch())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import type { i18n as I18n } from 'i18next';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { I18nextProvider, useTranslation } from 'react-i18next';
|
||||||
|
import { ActivityIndicator, AppState, useColorScheme, View } from 'react-native';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { SessionProvider, useAuthState, useSessionCtx } from '../lib/session';
|
||||||
|
import { downloads } from '../offline/downloads';
|
||||||
|
import { player } from '../player/controller';
|
||||||
|
import { ThemeContext, useColors } from '../theme';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const [i18n, setI18n] = useState<I18n | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||||
|
void createI18n(pickLocale([deviceLocale])).then(setI18n);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!i18n) return <Splash />;
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionProvider>
|
||||||
|
<Gate />
|
||||||
|
</SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Splash() {
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
|
||||||
|
<ActivityIndicator color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Куда пускать: без сервера — подключение, без входа — логин, с обязательной сменой пароля — setup,
|
||||||
|
* иначе — вкладки. Те же правила, что у веба (docs/auth-and-roles.md).
|
||||||
|
*/
|
||||||
|
function Gate() {
|
||||||
|
const { server, session, ready } = useSessionCtx();
|
||||||
|
const auth = useAuthState();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const user = auth.status === 'signed_in' ? auth.user : null;
|
||||||
|
const system = useColorScheme();
|
||||||
|
// Тема из профиля; «system» и экраны до входа — по настройке телефона.
|
||||||
|
const pref = user?.ui_prefs.theme ?? 'system';
|
||||||
|
const theme = pref === 'system' ? (system === 'light' ? 'light' : 'dark') : pref;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
player.attach(user ? session : null, server ?? '', user?.id ?? null);
|
||||||
|
void downloads.attach(user ? session : null, server ?? '', user?.id ?? null);
|
||||||
|
if (user) void player.restore();
|
||||||
|
}, [user?.id, session, server]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && isLocale(user.locale) && user.locale !== i18n.language) void i18n.changeLanguage(user.locale);
|
||||||
|
}, [user?.locale, i18n]);
|
||||||
|
|
||||||
|
// Свёрнутое приложение досылает события и позицию.
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = AppState.addEventListener('change', (st) => st !== 'active' && player.flush());
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!ready) return <Splash />;
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={theme}>
|
||||||
|
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
|
||||||
|
<Screens />
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Screens() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { server, session } = useSessionCtx();
|
||||||
|
const auth = useAuthState();
|
||||||
|
const connected = !!server && !!session;
|
||||||
|
const user = connected && auth.status === 'signed_in' ? auth.user : null;
|
||||||
|
const needsSetup = !!user && (user.must_change_credentials || user.must_change_password);
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
|
||||||
|
{/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
|
||||||
|
<Stack.Protected guard={!connected}>
|
||||||
|
<Stack.Screen name="connect" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={connected && !user}>
|
||||||
|
<Stack.Screen name="login" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={needsSetup}>
|
||||||
|
<Stack.Screen name="setup" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={!!user && !needsSetup}>
|
||||||
|
<Stack.Screen name="(tabs)" />
|
||||||
|
<Stack.Screen name="album/[id]" />
|
||||||
|
<Stack.Screen name="artist/[id]" />
|
||||||
|
<Stack.Screen name="playlists" />
|
||||||
|
<Stack.Screen name="social" />
|
||||||
|
<Stack.Screen name="social-track" />
|
||||||
|
<Stack.Screen name="social-playlist" />
|
||||||
|
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||||
|
</Stack.Protected>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession } from '../../lib/session';
|
||||||
|
import { downloads } from '../../offline/downloads';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Button, Cover, Icon, Screen, TrackRow, formatDuration, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
export default function AlbumScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const session = useSession();
|
||||||
|
const current = usePlayerState().track;
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const album = useQuery({
|
||||||
|
queryKey: ['album', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
const a = album.data;
|
||||||
|
const ctx = { type: 'album' as const, id };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<FlatList
|
||||||
|
data={a?.tracks ?? []}
|
||||||
|
keyExtractor={(tr) => tr.id}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ padding: 16, gap: 12, alignItems: 'center' }}>
|
||||||
|
<Pressable onPress={() => router.back()} style={{ alignSelf: 'flex-start' }} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||||
|
<Icon name="arrow-back" color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
{album.error ? <Text style={{ color: colors.danger }}>{errorText(album.error)}</Text> : null}
|
||||||
|
{a && (
|
||||||
|
<>
|
||||||
|
<Cover album={a} size={220} />
|
||||||
|
<Text style={{ color: colors.text, fontSize: 22, fontWeight: '700', textAlign: 'center' }}>{a.title}</Text>
|
||||||
|
<Pressable onPress={() => router.push(`/artist/${a.artist.id}`)}>
|
||||||
|
<Text style={{ color: colors.text2 }}>
|
||||||
|
{a.artist.name}
|
||||||
|
{a.year ? ` · ${a.year}` : ''} · {formatDuration(a.duration_ms)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Button title={t('player.play')} icon="play-arrow" onPress={() => void player.playList(a.tracks, 0, ctx)} />
|
||||||
|
<Button title={t('mobile.dl_album')} variant="secondary" icon="download" onPress={() => void downloads.enqueue(a.tracks)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item}
|
||||||
|
numbered={item.track_no ?? index + 1}
|
||||||
|
current={current?.id === item.id}
|
||||||
|
onPress={() => void player.playList(a?.tracks ?? [], index, ctx)}
|
||||||
|
onLike={() => void player.react(item, item.reaction === 'like' ? null : 'like').then(() => album.refetch())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession } from '../../lib/session';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Cover, Icon, Screen } from '../../ui/kit';
|
||||||
|
|
||||||
|
export default function ArtistScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const session = useSession();
|
||||||
|
const artist = useQuery({
|
||||||
|
queryKey: ['artist', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
const albums = useQuery({
|
||||||
|
queryKey: ['artist-albums', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<FlatList
|
||||||
|
data={albums.data ?? []}
|
||||||
|
keyExtractor={(a) => a.id}
|
||||||
|
numColumns={2}
|
||||||
|
columnWrapperStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
contentContainerStyle={{ gap: 16, paddingBottom: 24 }}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ padding: 16, gap: 10, alignItems: 'center' }}>
|
||||||
|
<Pressable onPress={() => router.back()} style={{ alignSelf: 'flex-start' }} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||||
|
<Icon name="arrow-back" color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Cover album={{ id }} size={140} round />
|
||||||
|
<Text style={{ color: colors.text, fontSize: 24, fontWeight: '700' }}>{artist.data?.name ?? '…'}</Text>
|
||||||
|
{artist.data && (
|
||||||
|
<Text style={{ color: colors.text2 }}>
|
||||||
|
{t('catalog.album_count', { count: artist.data.album_count })} · {t('catalog.track_count', { count: artist.data.track_count })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable onPress={() => router.push(`/album/${item.id}`)} style={{ flex: 1, gap: 6 }} accessibilityRole="button">
|
||||||
|
<Cover album={item} size={160} />
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>{item.title}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{item.year ?? ''}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { createApiClient } from '@pipu/api-client';
|
||||||
|
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSessionCtx } from '../lib/session';
|
||||||
|
import { forgetServer, loadServers } from '../lib/storage';
|
||||||
|
import { radius, useColors } from '../theme';
|
||||||
|
import { Button, ErrorBox, Field, H1, Icon, Muted, Screen } from '../ui/kit';
|
||||||
|
|
||||||
|
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
|
||||||
|
|
||||||
|
/** Подключение: адрес → проверка /server-info (версия, совместимость) → вход. */
|
||||||
|
export default function ConnectScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { connect } = useSessionCtx();
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [result, setResult] = useState<ServerCheck | 'invalid' | null>(null);
|
||||||
|
const servers = useQuery({ queryKey: ['saved-servers'], queryFn: loadServers });
|
||||||
|
|
||||||
|
const onConnect = async (raw = address) => {
|
||||||
|
const baseUrl = normalizeServerUrl(raw);
|
||||||
|
if (!baseUrl) return setResult('invalid');
|
||||||
|
setChecking(true);
|
||||||
|
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
|
||||||
|
setChecking(false);
|
||||||
|
setResult(res);
|
||||||
|
if (res.status === 'ok') await connect(baseUrl, res.info.version);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
|
||||||
|
<H1>{t('app.name')}</H1>
|
||||||
|
<Muted>{t('app.tagline')}</Muted>
|
||||||
|
<Field
|
||||||
|
label={t('server.address')}
|
||||||
|
value={address}
|
||||||
|
onChangeText={setAddress}
|
||||||
|
onSubmitEditing={() => void onConnect()}
|
||||||
|
placeholder="https://music.home.lan"
|
||||||
|
keyboardType="url"
|
||||||
|
editable={!checking}
|
||||||
|
/>
|
||||||
|
{result === 'invalid' && <ErrorBox>{t('server.invalid_address')}</ErrorBox>}
|
||||||
|
{result !== null && result !== 'invalid' && result.status === 'unsupported_app' && (
|
||||||
|
<ErrorBox>{t('server.unsupported_app')}</ErrorBox>
|
||||||
|
)}
|
||||||
|
{result !== null && result !== 'invalid' && result.status === 'offline' && <ErrorBox>{t('server.offline')}</ErrorBox>}
|
||||||
|
<Button title={t('mobile.connect')} onPress={() => void onConnect()} busy={checking} disabled={!address.trim()} />
|
||||||
|
|
||||||
|
{!!servers.data?.length && (
|
||||||
|
<View style={{ gap: 8, marginTop: 16 }}>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12, fontWeight: '700', textTransform: 'uppercase' }}>
|
||||||
|
{t('mobile.servers')}
|
||||||
|
</Text>
|
||||||
|
{servers.data.map((sv) => (
|
||||||
|
<View key={sv.url} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, padding: 12, borderRadius: radius.md, backgroundColor: colors.surface }}>
|
||||||
|
<Pressable style={{ flex: 1 }} onPress={() => void onConnect(sv.url)} accessibilityRole="button">
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '600' }}>{sv.url.replace(/^https?:\/\//, '')}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>pipu {sv.version}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void forgetServer(sv.url).then(() => servers.refetch())}
|
||||||
|
hitSlop={10}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('mobile.forget_server')}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={20} color={colors.text3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ScrollView } from 'react-native';
|
||||||
|
|
||||||
|
import { useSessionCtx } from '../lib/session';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
|
const { server, session, disconnect } = useSessionCtx();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!session) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await session.login(username.trim(), password); // переход сделает Gate
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
setPassword('');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
|
||||||
|
<H1>{t('login.title')}</H1>
|
||||||
|
<Muted>{server?.replace(/^https?:\/\//, '')}</Muted>
|
||||||
|
<Field label={t('login.username')} value={username} onChangeText={setUsername} autoComplete="username" />
|
||||||
|
<Field
|
||||||
|
label={t('login.password')}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secure
|
||||||
|
autoComplete="password"
|
||||||
|
onSubmitEditing={() => void submit()}
|
||||||
|
/>
|
||||||
|
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
|
||||||
|
<Button title={t('login.submit')} onPress={() => void submit()} busy={busy} disabled={!username.trim() || !password} />
|
||||||
|
<Button title={t('common:mobile.change_server')} variant="ghost" onPress={() => void disconnect()} />
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||||
|
import { MusicShareSheet } from '../features/social/Social';
|
||||||
|
import { ListeningBanner } from '../features/social/Listening';
|
||||||
|
import { trackShareUrl } from '@pipu/core';
|
||||||
|
|
||||||
|
import { useSessionCtx, useUser } from '../lib/session';
|
||||||
|
import { player } from '../player/controller';
|
||||||
|
import { usePlayerState } from '../player/hooks';
|
||||||
|
import { useColors } from '../theme';
|
||||||
|
import { DownloadButton } from '../ui/downloads';
|
||||||
|
import { Cover, Icon, Screen, formatDuration } from '../ui/kit';
|
||||||
|
import { WaveOrb } from '../ui/wave/WaveOrb';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полноэкранный плеер: вид `cover` (крупная обложка) или `wave` (визуализация волны в цветах обложки) — по профилю;
|
||||||
|
* прогресс с перемоткой, лайк / дизлайк, скачать, управление.
|
||||||
|
*/
|
||||||
|
export default function PlayerScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const locked = st.listening?.locked === true;
|
||||||
|
const prefs = useUser().ui_prefs;
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [barWidth, setBarWidth] = useState(1);
|
||||||
|
const track = st.track;
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<Pressable onPress={() => router.back()} style={{ padding: 16 }} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
||||||
|
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const duration = st.durationMs || track.duration_ms;
|
||||||
|
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||||
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
const share = () => setSharing(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
|
||||||
|
<ListeningBanner />
|
||||||
|
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
||||||
|
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||||
|
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
|
||||||
|
<Icon name="share" size={24} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<DownloadButton track={track} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ alignItems: 'center' }}>
|
||||||
|
{prefs.mobile_player === 'wave' ? (
|
||||||
|
<WaveOrb
|
||||||
|
variant={prefs.wave_variant ?? 'orb'}
|
||||||
|
size={280}
|
||||||
|
playing={st.playing}
|
||||||
|
bpm={track.features?.bpm ?? 100}
|
||||||
|
energy={track.features?.energy ?? 0.6}
|
||||||
|
colors={track.album.cover_colors}
|
||||||
|
>
|
||||||
|
<Cover album={track.album} size={96} round />
|
||||||
|
</WaveOrb>
|
||||||
|
) : (
|
||||||
|
<Cover album={track.album} size={300} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<Text numberOfLines={2} style={{ color: colors.text, fontSize: 22, fontWeight: '700' }}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
||||||
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
|
||||||
|
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
|
||||||
|
<Icon name="playlist-add" size={24} color={colors.text2} />
|
||||||
|
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<Pressable
|
||||||
|
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
|
||||||
|
disabled={locked} onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
|
||||||
|
accessibilityRole="adjustable"
|
||||||
|
accessibilityLabel={t('player.progress')}
|
||||||
|
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
|
||||||
|
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
||||||
|
onAccessibilityAction={(e) => player.seek(st.positionMs + (e.nativeEvent.actionName === 'increment' ? 10_000 : -10_000))}
|
||||||
|
style={{ height: 24, justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
<View style={{ height: 4, borderRadius: 2, backgroundColor: colors.surface3 }}>
|
||||||
|
<View style={{ height: 4, borderRadius: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(st.positionMs)}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(duration)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
|
||||||
|
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable disabled={locked} onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
||||||
|
<Icon name="skip-previous" size={40} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
disabled={locked} onPress={() => player.toggle()}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
|
||||||
|
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable disabled={locked} onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||||
|
<Icon name="skip-next" size={40} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
|
||||||
|
<Icon name={track.reaction === 'like' ? 'favorite' : 'favorite-border'} size={28} color={track.reaction === 'like' ? colors.accent2 : colors.text2} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { MusicShareSheet } from '../features/social/Social';
|
||||||
|
import { playlistShareUrl, unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||||
|
import { player } from '../player/controller';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
|
type Playlist = components['schemas']['Playlist'];
|
||||||
|
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
|
||||||
|
|
||||||
|
export default function PlaylistsScreen() {
|
||||||
|
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
|
||||||
|
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function List({ track }: { track?: string }) {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [create, setCreate] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||||
|
const add = useMutation({ mutationFn: async (id: string) => {
|
||||||
|
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||||
|
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
|
||||||
|
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||||
|
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||||
|
return <Screen><FlatList
|
||||||
|
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
|
||||||
|
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
|
||||||
|
contentContainerStyle={{ padding: 16, gap: 12 }}
|
||||||
|
ListHeaderComponent={<View style={{ gap: 12 }}>
|
||||||
|
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
|
||||||
|
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
|
||||||
|
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
|
||||||
|
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
|
||||||
|
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
|
</View>}
|
||||||
|
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
|
||||||
|
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
|
||||||
|
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
|
||||||
|
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
|
||||||
|
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
|
||||||
|
}} />}</Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
|
||||||
|
const session = useSession();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [original] = useState(playlist);
|
||||||
|
const [name, setName] = useState(playlist?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||||
|
const save = useMutation({ mutationFn: async () => {
|
||||||
|
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
|
||||||
|
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||||
|
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||||
|
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
|
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||||
|
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
|
||||||
|
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
|
||||||
|
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
|
||||||
|
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||||
|
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
|
||||||
|
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
|
||||||
|
</ScrollView></Screen></Modal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ id }: { id: string }) {
|
||||||
|
const [shareToken, setShareToken] = useState<string | null>(null);
|
||||||
|
const session = useSession();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [edit, setEdit] = useState(false);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const [playError, setPlayError] = useState<unknown>();
|
||||||
|
const key = ['m-playlists', user.id, id];
|
||||||
|
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
|
const p = query.data;
|
||||||
|
const sharing = useMutation({ mutationFn: async () => {
|
||||||
|
if (!server || !p) throw new Error('Server unavailable');
|
||||||
|
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
|
||||||
|
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
|
||||||
|
setShareToken(token);
|
||||||
|
} });
|
||||||
|
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
|
||||||
|
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
|
||||||
|
|
||||||
|
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
||||||
|
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
|
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
|
||||||
|
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||||
|
const play = (shuffle: boolean) => {
|
||||||
|
const tracks = p?.tracks.filter(t => t.available) ?? [];
|
||||||
|
if (shuffle) for (let i=tracks.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [tracks[i],tracks[j]]=[tracks[j]!,tracks[i]!]; }
|
||||||
|
if (tracks.length) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
|
||||||
|
};
|
||||||
|
const move = (index: number, delta: number) => {
|
||||||
|
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
||||||
|
};
|
||||||
|
return <Screen>{shareToken && server && p && <MusicShareSheet attachment={{ kind: 'playlist', id: shareToken, title: p.name }} url={playlistShareUrl(server, shareToken)} close={() => setShareToken(null)} />}<FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||||
|
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||||
|
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
|
||||||
|
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
|
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
||||||
|
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
||||||
|
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
||||||
|
<Button title={t('playlists.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
|
||||||
|
<Muted>{t('playlists.share_hint')}</Muted>
|
||||||
|
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
|
||||||
|
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
||||||
|
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
||||||
|
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
||||||
|
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
|
||||||
|
])} />
|
||||||
|
</>}
|
||||||
|
</View>}
|
||||||
|
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
|
||||||
|
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
|
||||||
|
{manage && <View style={{padding:12,gap:6}}>
|
||||||
|
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
|
||||||
|
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
|
||||||
|
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
|
||||||
|
</View>}
|
||||||
|
</View>}
|
||||||
|
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { ApiError, unwrap } from '@pipu/core';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ScrollView } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../lib/session';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
|
/** Обязательный шаг: новые логин и пароль админа после первого запуска или замена временного пароля. */
|
||||||
|
export default function SetupScreen() {
|
||||||
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const admin = user.must_change_credentials;
|
||||||
|
const min = user.role === 'user' ? 8 : 12;
|
||||||
|
const [username, setUsername] = useState(user.username);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const mismatch = repeat !== '' && repeat !== password;
|
||||||
|
const fieldErr = (f: string) => (error instanceof ApiError && error.fieldCode(f) ? errorText(error) : undefined);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = admin
|
||||||
|
? await session.client.POST('/api/v1/me/bootstrap-credentials', { body: { new_username: username.trim(), new_password: password } })
|
||||||
|
: await session.client.POST('/api/v1/me/password', { body: { new_password: password } });
|
||||||
|
await session.apply(unwrap(res));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
|
||||||
|
<H1>{t(admin ? 'setup.admin_title' : 'setup.password_title')}</H1>
|
||||||
|
<Muted>{t(admin ? 'setup.admin_text' : 'setup.password_text')}</Muted>
|
||||||
|
{admin && <Field label={t('setup.new_username')} value={username} onChangeText={setUsername} error={fieldErr('new_username')} />}
|
||||||
|
<Field
|
||||||
|
label={t('setup.new_password')}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secure
|
||||||
|
hint={t('setup.min_length', { min })}
|
||||||
|
error={fieldErr('new_password')}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('setup.repeat_password')}
|
||||||
|
value={repeat}
|
||||||
|
onChangeText={setRepeat}
|
||||||
|
secure
|
||||||
|
error={mismatch ? t('common:form.passwords_mismatch') : undefined}
|
||||||
|
/>
|
||||||
|
<ErrorBox>{error && !fieldErr('new_password') && !fieldErr('new_username') ? errorText(error) : null}</ErrorBox>
|
||||||
|
<Button
|
||||||
|
title={t('setup.submit')}
|
||||||
|
onPress={() => void submit()}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!password || password !== repeat || (admin && !username.trim())}
|
||||||
|
/>
|
||||||
|
<Button title={t('setup.logout')} variant="ghost" onPress={() => void session.logout()} />
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { SharedMusic } from '../features/social/SharedMusic';
|
||||||
|
export default function SharedPlaylistScreen() { return <SharedMusic kind="playlist" />; }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { SharedMusic } from '../features/social/SharedMusic';
|
||||||
|
export default function SharedTrackScreen() { return <SharedMusic kind="track" />; }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SocialScreen as default } from '../features/social/Social';
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { CHAT_REACTIONS, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AccessibilityInfo, Animated, KeyboardAvoidingView, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
import { eventId } from '../../player/eventId';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||||
|
import { readDraft, saveDraft } from './drafts';
|
||||||
|
|
||||||
|
function Bubble({ children, mine, animate }: { children: ReactNode; mine: boolean; animate: boolean }) {
|
||||||
|
const colors = useColors(); const opacity = useRef(new Animated.Value(1)).current;
|
||||||
|
useEffect(() => { if (!animate) return; opacity.setValue(0); const animation = Animated.timing(opacity,{toValue:1,duration:180,useNativeDriver:true});animation.start();return()=>animation.stop(); },[animate,opacity]);
|
||||||
|
return <Animated.View style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '90%', padding: 14, borderRadius: 16, backgroundColor: mine ? colors['accent-soft'] : colors.surface2, gap: 8, opacity, transform:[{translateY:opacity.interpolate({inputRange:[0,1],outputRange:[6,0]})}] }}>{children}</Animated.View>;
|
||||||
|
}
|
||||||
|
export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; server: string; active: boolean }) {
|
||||||
|
const colors=useColors();const {t,i18n}=useTranslation();const router=useRouter();const cache=useQueryClient();const errorText=useErrorText();
|
||||||
|
const [text,setText]=useState('');const [ready,setReady]=useState(false);const touched=useRef(false);const [search,setSearch]=useState('');const [q,setQ]=useState('');
|
||||||
|
const [reply,setReply]=useState<DirectMessage|null>(null);const [editing,setEditing]=useState<DirectMessage|null>(null);const [menu,setMenu]=useState<DirectMessage|null>(null);const [emojis,setEmojis]=useState(false);const [reduced,setReduced]=useState(true);
|
||||||
|
const beforeEdit=useRef('');const pending=useRef<{id:string;text:string;reply_to?:string}|null>(null);
|
||||||
|
useEffect(()=>{let live=true;void readDraft(server,userId,person.id).then(value=>{if(live){if(!touched.current)setText(value);setReady(true);}});return()=>{live=false;};},[server,userId,person.id]);
|
||||||
|
useEffect(()=>{if(!ready||editing)return;const timer=setTimeout(()=>{void saveDraft(server,userId,person.id,text).catch(()=>undefined);},300);return()=>clearTimeout(timer);},[text,ready,editing,server,userId,person.id]);
|
||||||
|
useEffect(()=>{void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);const sub=AccessibilityInfo.addEventListener('reduceMotionChanged',setReduced);return()=>sub.remove();},[]);
|
||||||
|
useEffect(()=>{const timer=setTimeout(()=>setQ(search.trim()),250);return()=>clearTimeout(timer);},[search]);
|
||||||
|
useEffect(()=>{const timer=setTimeout(()=>{void api.typing(person.id,active&&touched.current&&!!text.trim()&&!editing).catch(()=>undefined);},400);return()=>clearTimeout(timer);},[text,active,editing,api,person.id]);
|
||||||
|
useEffect(()=>()=>{void api.typing(person.id,false).catch(()=>undefined);},[api,person.id]);
|
||||||
|
const meta=useQuery({queryKey:[...queryKey,'typing',person.id],queryFn:()=>api.chatMeta(person.id),enabled:active,refetchInterval:active?2000:false});
|
||||||
|
const messages=useInfiniteQuery({queryKey:[...queryKey,'chat',person.id,q],initialPageParam:undefined as number|undefined,queryFn:({pageParam})=>api.messages(person.id,pageParam,q),getNextPageParam:last=>last.length===50?last[0]?.seq:undefined,enabled:active,refetchInterval:active?3000:false});
|
||||||
|
const list=[...(messages.data?.pages??[])].reverse().flat();const last=list.at(-1)?.seq;const unread=list.some(m=>m.sender_id!==userId&&!m.read);
|
||||||
|
useEffect(()=>{if(last&&unread&&active&&!q)void api.action({action:'read_chat',target_id:person.id,up_to_seq:last}).then(()=>cache.invalidateQueries({queryKey})).catch(()=>undefined);},[last,unread,active,q,api,person.id,messages.dataUpdatedAt]);
|
||||||
|
const refresh=()=>cache.invalidateQueries({queryKey});
|
||||||
|
const update=useMutation({mutationFn:(input:{id:string;body:ChatMessageUpdate})=>api.updateMessage(person.id,input.id,input.body),onSuccess:refresh});
|
||||||
|
const send=useMutation({mutationFn:async()=>{if(editing){await api.updateMessage(person.id,editing.id,{action:'edit',text});return;}if(!pending.current||pending.current.text!==text||pending.current.reply_to!==reply?.id)pending.current={id:eventId(Date.now()),text,...(reply?{reply_to:reply.id}:{})};await api.send(person.id,{...pending.current,attachment_kind:'none'});},onSuccess:async()=>{setText(editing?beforeEdit.current:'');setEditing(null);setReply(null);pending.current=null;await refresh();}});
|
||||||
|
const scroll=useRef<ScrollView>(null);const [bottom,setBottom]=useState(true);useEffect(()=>setBottom(true),[last]);
|
||||||
|
const cancelEdit=()=>{setText(beforeEdit.current);setEditing(null);};
|
||||||
|
const mutate=(m:DirectMessage,body:ChatMessageUpdate)=>{if(!update.isPending)update.mutate({id:m.id,body});setMenu(null);};
|
||||||
|
return <KeyboardAvoidingView behavior="padding" style={{flex:1}}>
|
||||||
|
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Muted>{t(person.online ? 'social.online' : 'social.offline')}</Muted><Field label={t('social.chat_search')} value={search} onChangeText={setSearch} maxLength={100}/><ErrorBox>{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}</ErrorBox></View>
|
||||||
|
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:14,gap:12}} onContentSizeChange={()=>{if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}>
|
||||||
|
{messages.hasNextPage&&<Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={()=>{setBottom(false);void messages.fetchNextPage();}}/>}
|
||||||
|
{!list.length&&<Muted>{t(messages.isPending?'social.loading':q?'social.chat_no_results':'social.first_message')}</Muted>}
|
||||||
|
{list.map((m,i)=><View key={m.id} style={{gap:8}}>
|
||||||
|
{(i===0||new Date(list[i-1]!.created_at).toDateString()!==new Date(m.created_at).toDateString())&&<Text style={{color:colors.text3,fontSize:11,textAlign:'center'}}>{new Date(m.created_at).toLocaleDateString(i18n.language,{day:'numeric',month:'long'})}</Text>}
|
||||||
|
<Bubble mine={m.sender_id===userId} animate={!reduced}>
|
||||||
|
<Pressable disabled={m.deleted} onLongPress={()=>setMenu(m)} accessibilityLabel={t('social.message_actions')}>
|
||||||
|
{m.reply_to&&<View style={{borderLeftWidth:2,borderLeftColor:colors.accent,paddingLeft:8,marginBottom:8}}><Muted>{m.reply_preview||t('social.message_deleted')}</Muted></View>}
|
||||||
|
<Text selectable style={{color:m.deleted?colors.text3:colors.text,fontSize:15,fontStyle:m.deleted?'italic':'normal'}}>{m.deleted?t('social.message_deleted'):m.text}</Text>
|
||||||
|
</Pressable>
|
||||||
|
{!m.deleted&&m.attachment_id&&m.attachment_kind!=='none'&&<Button title={t(m.attachment_kind==='track'?'social.listen_track':'social.listen_playlist')} variant="secondary" onPress={()=>router.push({pathname:m.attachment_kind==='track'?'/social-track':'/social-playlist',params:{id:m.attachment_id}})}/>}
|
||||||
|
{!m.deleted&&!!m.reactions?.length&&<View style={{flexDirection:'row',gap:6,flexWrap:'wrap'}}>{m.reactions.map(r=><Pressable key={r.emoji} accessibilityRole="button" accessibilityState={{selected:r.mine}} onPress={()=>mutate(m,{action:'react',emoji:r.emoji as typeof CHAT_REACTIONS[number]})} style={{padding:6,borderRadius:12,backgroundColor:r.mine?colors['accent-soft']:colors.surface3}}><Text style={{color:colors.text}}>{r.emoji} {r.count}</Text></Pressable>)}</View>}
|
||||||
|
<View style={{flexDirection:'row',gap:8,alignItems:'center'}}><Text style={{color:colors.text3,fontSize:10,flex:1}}>{new Date(m.created_at).toLocaleTimeString(i18n.language,{hour:'2-digit',minute:'2-digit'})}{m.edited_at&&!m.deleted?` · ${t('social.edited')}`:''}{m.sender_id===userId?` · ${t(m.read?'social.read':'social.sent')}`:''}</Text>{!m.deleted&&<Pressable accessibilityRole="button" accessibilityLabel={t('social.message_actions')} hitSlop={10} onPress={()=>setMenu(m)}><Text style={{color:colors.text3}}>•••</Text></Pressable>}</View>
|
||||||
|
</Bubble>
|
||||||
|
</View>)}
|
||||||
|
</ScrollView>
|
||||||
|
<Text accessibilityLiveRegion="polite" style={{minHeight:18,paddingHorizontal:14,color:colors.text3,fontSize:11}}>{active&&meta.data?.typing?`${person.display_name} ${t('social.typing')}`:''}</Text>
|
||||||
|
{(reply||editing)&&<View style={{padding:10,backgroundColor:colors['accent-soft'],gap:4}}><Muted>{t(editing?'social.editing':'social.replying')}</Muted><Text numberOfLines={2} style={{color:colors.text2}}>{(editing??reply)?.text}</Text><Button title={t(editing?'social.cancel_edit':'social.cancel_reply')} variant="ghost" onPress={()=>editing?cancelEdit():setReply(null)}/></View>}
|
||||||
|
<View style={{padding:12,gap:8,borderTopWidth:1,borderTopColor:colors.line}}><Field label={t('social.message')} value={text} onChangeText={value=>{touched.current=true;setText(value);}} maxLength={4000} multiline editable={!send.isPending}/><View style={{flexDirection:'row',gap:8}}><Button title="☺" variant="ghost" onPress={()=>setEmojis(v=>!v)}/><View style={{flex:1}}><Button title={t(editing?'playlists.save':'social.send')} busy={send.isPending} disabled={!text.trim()} onPress={()=>send.mutate()}/></View></View>{emojis&&<View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>{setText(v=>(v+emoji).slice(0,4000));setEmojis(false);}}/>)}</View>}</View>
|
||||||
|
<Modal transparent visible={!!menu} animationType={reduced?'none':'fade'} onRequestClose={()=>setMenu(null)}><View style={{flex:1,justifyContent:'flex-end',backgroundColor:colors.overlay}}><Screen style={{flex:0,maxHeight:'80%',borderTopLeftRadius:24,borderTopRightRadius:24}}><ScrollView contentContainerStyle={{padding:20,gap:10}}><Button title={t('form.close')} variant="ghost" onPress={()=>setMenu(null)}/>{menu&&<><Button title={t('social.reply')} variant="secondary" onPress={()=>{if(editing)cancelEdit();setReply(menu);setMenu(null);}}/><View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>mutate(menu,{action:'react',emoji})}/>)}</View>{menu.sender_id===userId&&<><Button title={t('social.edit_message')} variant="secondary" onPress={()=>{beforeEdit.current=text;setEditing(menu);setText(menu.text);setReply(null);setMenu(null);}}/><Button title={t('social.delete_message')} variant="danger" onPress={()=>mutate(menu,{action:'delete'})}/></>}</>}</ScrollView></Screen></View></Modal>
|
||||||
|
</KeyboardAvoidingView>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal, ScrollView, Switch, Text, View } from 'react-native';
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { eventId } from '../../player/eventId';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
function useListening() {
|
||||||
|
const session = useSession(); const { server } = useSessionCtx(); const user = useUser();
|
||||||
|
const api = useMemo(() => socialApi(session), [session]); const key = ['m-listening', server, user.id] as const;
|
||||||
|
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
|
||||||
|
return { api, key, query, user };
|
||||||
|
}
|
||||||
|
export function ListeningPanel() {
|
||||||
|
const { api, key, query, user } = useListening(); const { t } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText(); const colors = useColors();
|
||||||
|
const [expanded, setExpanded] = useState(false); const prefs = query.data?.preferences;
|
||||||
|
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
const action = useMutation({ mutationFn: (a: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: eventId(Date.now()), action: a.action, target_id: a.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
return <View style={{ gap: 12 }}><Button title={t('social.together')} variant="secondary" onPress={() => setExpanded(v => !v)} />
|
||||||
|
{expanded && <View style={{ padding: 16, borderRadius: 16, gap: 14, backgroundColor: colors.surface }}><Muted>{t('social.stream_prefs_hint')}</Muted>
|
||||||
|
{(['requests', 'auto_accept'] as const).map(field => <View key={field} style={{ gap: 8 }}><Muted>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</Muted><View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} disabled={!prefs || save.isPending} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onPress={() => prefs && save.mutate({ ...prefs, [field]: v })} />)}</View></View>)}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}><Text style={{ color: colors.text, flex: 1 }}>{t('social.stream_autoplay')}</Text><Switch accessibilityLabel={t('social.stream_autoplay')} value={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onValueChange={autoplay => prefs && save.mutate({ ...prefs, autoplay })} /></View>
|
||||||
|
</View>}
|
||||||
|
{query.data?.requests.map(r => <View key={r.id} style={{ padding: 16, borderRadius: 16, gap: 10, backgroundColor: colors.surface }}><Text style={{ color: colors.text }}>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</Text>{r.recipient_id === user.id ? <><Button title={t('social.accept')} disabled={action.isPending} onPress={() => action.mutate({ action: 'accept', target: r.id })} /><Button title={t('social.decline')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'decline', target: r.id })} /></> : <Button title={t('form.cancel')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'cancel', target: r.id })} />}</View>)}
|
||||||
|
<ErrorBox>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</ErrorBox>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||||
|
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||||
|
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
|
||||||
|
return <><Button title={t('social.together')} disabled={!person.online} variant="ghost" onPress={() => setOpen(true)} />{!person.online && <Muted>{t('social.together_offline')}</Muted>}{open && person.online && <TogetherSheet person={person} close={() => setOpen(false)} />}</>;
|
||||||
|
}
|
||||||
|
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||||
|
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
|
||||||
|
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
|
||||||
|
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
|
||||||
|
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
|
||||||
|
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
|
||||||
|
<Muted>{t('social.stream_hint')}</Muted><ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||||
|
{action.isSuccess ? <Muted>{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</Muted> : <Button title={t('social.send')} busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onPress={() => action.mutate()} />}
|
||||||
|
</ScrollView></Screen></Modal>;
|
||||||
|
}
|
||||||
|
export function ListeningBanner() {
|
||||||
|
const state = usePlayerState(); const session = useSession(); const user = useUser(); const colors = useColors(); const { t } = useTranslation(); const errorText = useErrorText(); const room = state.listening?.room;
|
||||||
|
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: eventId(Date.now()), action: room.owner_id === user.id ? 'end' : 'leave', target_id: room.id }) });
|
||||||
|
if (!room) return null;
|
||||||
|
return <View style={{ padding: 12, gap: 6, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.ok }}><Text style={{ color: colors.text, fontWeight: '600' }}>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</Text>
|
||||||
|
<Muted>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</Muted>
|
||||||
|
<Muted>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</Muted>
|
||||||
|
{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button title={t('social.stream_start_audio')} variant="secondary" onPress={() => player.beginListeningAudio()} />}
|
||||||
|
<Button title={t(room.owner_id === user.id ? 'social.stream_end' : 'social.stream_leave')} variant="ghost" busy={action.isPending} onPress={() => action.mutate()} />
|
||||||
|
<ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, View } from 'react-native';
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { Button, ErrorBox, H1, Muted, Screen, TrackRow, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
|
||||||
|
const { id, owner } = useLocalSearchParams<{ id: string; owner?: string }>();
|
||||||
|
const session = useSession();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [playError, setPlayError] = useState<unknown>();
|
||||||
|
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id, owner], refetchInterval: owner ? 15_000 : false, queryFn: async () => {
|
||||||
|
if (owner) return unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId: owner } } }));
|
||||||
|
if (kind === 'track') {
|
||||||
|
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
|
||||||
|
return { id: track.id, name: track.title, tracks: [track] };
|
||||||
|
}
|
||||||
|
return unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }));
|
||||||
|
} });
|
||||||
|
const visible = query.isError ? undefined : query.data;
|
||||||
|
return <Screen><FlatList data={visible?.tracks ?? []} keyExtractor={x => x.id}
|
||||||
|
ListHeaderComponent={<View style={{ padding: 20, gap: 16 }}>
|
||||||
|
<Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<H1>{visible?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
|
||||||
|
{query.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||||
|
<ErrorBox>{query.error || playError ? errorText(query.error ?? playError) : null}</ErrorBox>
|
||||||
|
</View>}
|
||||||
|
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(visible?.tracks ?? [], index, { type: kind === 'track' ? 'queue' : 'collection', id: visible?.id, label: visible?.name }).catch(setPlayError)} />}
|
||||||
|
/></Screen>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||||
|
import { eventId } from '../../player/eventId';
|
||||||
|
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||||
|
import { ChatConversation } from './ChatConversation';
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useFocusEffect, useRouter } from 'expo-router';
|
||||||
|
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
function useSocial() {
|
||||||
|
const session = useSession();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const user = useUser();
|
||||||
|
const api = useMemo(() => socialApi(session), [session]);
|
||||||
|
const [active, setActive] = useState(AppState.currentState === 'active');
|
||||||
|
useEffect(() => { const sub = AppState.addEventListener('change', s => setActive(s === 'active')); return () => sub.remove(); }, []);
|
||||||
|
const key = ['m-social', server, user.id] as const;
|
||||||
|
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, enabled: active, refetchInterval: active ? 15_000 : false });
|
||||||
|
return { api, key, inbox, user, active };
|
||||||
|
}
|
||||||
|
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||||
|
|
||||||
|
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
|
||||||
|
export function NativeChatProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
|
||||||
|
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
|
||||||
|
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
|
||||||
|
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
|
||||||
|
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
|
||||||
|
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
|
||||||
|
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
|
||||||
|
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
|
||||||
|
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
|
||||||
|
</Screen>
|
||||||
|
</View></View>
|
||||||
|
</Modal>
|
||||||
|
</ChatDockContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FriendRecommendations() {
|
||||||
|
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
|
||||||
|
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
|
||||||
|
}
|
||||||
|
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { inbox, api, key } = useSocial();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [sent, setSent] = useState<string[]>([]);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const ids = useRef(new Map<string, string>());
|
||||||
|
const send = useMutation({ mutationFn: async (peer: string) => {
|
||||||
|
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
|
||||||
|
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
|
||||||
|
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
|
||||||
|
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
|
||||||
|
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
|
||||||
|
} });
|
||||||
|
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 24, gap: 16 }}>
|
||||||
|
<Button title={t('form.cancel')} variant="ghost" onPress={close} />
|
||||||
|
<H1>{t('player.share')}</H1><Muted>{attachment.title}</Muted>
|
||||||
|
<Button title={t(copied ? 'social.copied' : 'social.copy')} disabled={send.isPending} onPress={() => send.mutate('copy')} />
|
||||||
|
<Button title={t('social.external')} variant="secondary" disabled={send.isPending} onPress={() => send.mutate('external')} />
|
||||||
|
<H1>{t('social.send_friend')}</H1>
|
||||||
|
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||||
|
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <Muted>{t('social.no_friends_share')}</Muted>}
|
||||||
|
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} title={`${p.display_name} · ${t(sent.includes(p.id) ? 'social.sent' : 'social.send')}`} variant="secondary" disabled={send.isPending || sent.includes(p.id)} onPress={() => send.mutate(p.id)} />)}
|
||||||
|
<ErrorBox>{send.error || inbox.error ? errorText(send.error ?? inbox.error) : null}</ErrorBox>
|
||||||
|
</ScrollView></Screen></Modal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||||
|
const dock = useContext(ChatDockContext);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const router = useRouter();
|
||||||
|
const { api, inbox, key } = useSocial();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [peer, setPeer] = useState('');
|
||||||
|
const [notifications, setNotifications] = useState(false);
|
||||||
|
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
|
||||||
|
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
|
||||||
|
const act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
|
||||||
|
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
|
||||||
|
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
|
||||||
|
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||||
|
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
|
||||||
|
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
|
||||||
|
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||||
|
<Muted>{t('social.activity_visibility')}</Muted>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
|
||||||
|
<Muted>{t('social.activity_privacy_hint')}</Muted>
|
||||||
|
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
|
||||||
|
</View>
|
||||||
|
<ListeningPanel />
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
|
||||||
|
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
|
||||||
|
</View>
|
||||||
|
<ErrorBox>{inbox.error || results.error || act.error ? errorText(inbox.error ?? results.error ?? act.error) : null}</ErrorBox>
|
||||||
|
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||||
|
{notifications ? <>
|
||||||
|
{inbox.data?.notifications.length === 0 && <Muted>{t('social.no_notifications')}</Muted>}
|
||||||
|
{inbox.data?.notifications.map(n => <View key={n.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10, borderWidth: 1, borderColor: n.read ? colors.line : colors.accent }}>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '600' }}>{n.actor_name}</Text><Muted>{t(`social.kind_${n.kind}`)}</Muted><Muted>{new Date(n.created_at).toLocaleString()}</Muted>
|
||||||
|
<Button title={t('social.open')} variant="secondary" onPress={() => { setNotifications(false); setSearch(''); setQ(''); setPeer(n.actor_id); }} />
|
||||||
|
{!n.read && <Button title={t('social.mark_read')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'read_notification', target_id: n.id })} />}
|
||||||
|
</View>)}
|
||||||
|
</> : <>
|
||||||
|
<Field label={t('social.search')} value={search} onChangeText={setSearch} maxLength={80} />
|
||||||
|
{q.length >= 2 && results.isFetching && <Muted>{t('social.loading')}</Muted>}
|
||||||
|
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
|
||||||
|
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
|
||||||
|
{p.relationship === 'friend' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t(p.online ? 'social.online' : 'social.offline')}</Text>}
|
||||||
|
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
|
||||||
|
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
|
||||||
|
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
|
||||||
|
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
|
||||||
|
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
|
||||||
|
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
|
||||||
|
</View>}
|
||||||
|
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
|
||||||
|
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
|
||||||
|
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
|
||||||
|
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
|
||||||
|
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
|
||||||
|
<Button title={t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: p.relationship === 'blocked' ? 'unblock' : 'block', target_id: p.id })} />
|
||||||
|
</View>)}
|
||||||
|
</>}
|
||||||
|
</ScrollView></Screen>;
|
||||||
|
}
|
||||||
|
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
|
||||||
|
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
|
||||||
|
const [focused,setFocused]=useState(false);
|
||||||
|
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
|
||||||
|
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import { sha256 } from 'js-sha256';
|
||||||
|
import { chatDraftKey } from '@pipu/core';
|
||||||
|
|
||||||
|
const directory = `${FileSystem.documentDirectory}chat-drafts/`;
|
||||||
|
const path = (server: string, user: string, peer: string) => `${directory}${sha256(chatDraftKey(server,user,peer))}.txt`;
|
||||||
|
export async function readDraft(server: string, user: string, peer: string) {
|
||||||
|
try { return await FileSystem.readAsStringAsync(path(server,user,peer)); } catch { return ''; }
|
||||||
|
}
|
||||||
|
export async function saveDraft(server: string, user: string, peer: string, text: string) {
|
||||||
|
if (!text) { await FileSystem.deleteAsync(path(server,user,peer), { idempotent: true }); return; }
|
||||||
|
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||||
|
await FileSystem.writeAsStringAsync(path(server,user,peer),text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
export type Quality = 'original' | 'high' | 'medium' | 'low';
|
||||||
|
export const QUALITIES: readonly Quality[] = ['original', 'high', 'medium', 'low'];
|
||||||
|
|
||||||
|
/** Качество звука по типу сети и для загрузок (настройка устройства, на сервер не уходит). */
|
||||||
|
export interface QualityPrefs {
|
||||||
|
wifi: Quality;
|
||||||
|
cellular: Quality;
|
||||||
|
download: Quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUALITY_KEY = 'pipu.quality';
|
||||||
|
const CLEAR_CACHE_KEY = 'pipu.cache.clear';
|
||||||
|
const DEFAULT_QUALITY: QualityPrefs = { wifi: 'high', cellular: 'medium', download: 'high' };
|
||||||
|
|
||||||
|
let quality: QualityPrefs = DEFAULT_QUALITY;
|
||||||
|
let loaded: Promise<void> | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export const qualityPrefs = {
|
||||||
|
get: () => quality,
|
||||||
|
subscribe(fn: () => void) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
},
|
||||||
|
async load(): Promise<QualityPrefs> {
|
||||||
|
loaded ??= SecureStore.getItemAsync(QUALITY_KEY)
|
||||||
|
.then((raw) => {
|
||||||
|
if (raw) quality = { ...DEFAULT_QUALITY, ...(JSON.parse(raw) as Partial<QualityPrefs>) };
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
await loaded;
|
||||||
|
return quality;
|
||||||
|
},
|
||||||
|
async set(patch: Partial<QualityPrefs>) {
|
||||||
|
quality = { ...quality, ...patch };
|
||||||
|
for (const fn of listeners) fn();
|
||||||
|
await SecureStore.setItemAsync(QUALITY_KEY, JSON.stringify(quality));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кэш недавних стримов ExoPlayer (KotlinAudio кладёт его в `cacheDir/TrackPlayer`). Пока сервис плеера жив,
|
||||||
|
* SimpleCache держит индекс в памяти — поэтому очистка откладывается до следующего холодного запуска.
|
||||||
|
*/
|
||||||
|
const PLAYER_CACHE = `${FileSystem.cacheDirectory}TrackPlayer`;
|
||||||
|
|
||||||
|
export const playerCache = {
|
||||||
|
async size(): Promise<number> {
|
||||||
|
const info = await FileSystem.getInfoAsync(PLAYER_CACHE, { size: true }).catch(() => null);
|
||||||
|
return info?.exists ? (info.size ?? 0) : 0;
|
||||||
|
},
|
||||||
|
async requestClear() {
|
||||||
|
await SecureStore.setItemAsync(CLEAR_CACHE_KEY, '1');
|
||||||
|
},
|
||||||
|
async clearPending(): Promise<boolean> {
|
||||||
|
return (await SecureStore.getItemAsync(CLEAR_CACHE_KEY)) === '1';
|
||||||
|
},
|
||||||
|
/** Вызывается до setupPlayer, когда сервис ещё не запущен. */
|
||||||
|
async clearIfRequested() {
|
||||||
|
if (!(await this.clearPending())) return;
|
||||||
|
await FileSystem.deleteAsync(PLAYER_CACHE, { idempotent: true }).catch(() => undefined);
|
||||||
|
await SecureStore.deleteItemAsync(CLEAR_CACHE_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type ReleaseNeed = 'current' | 'optional' | 'required';
|
||||||
|
|
||||||
|
/** Нужно ли обновление: обязательное, если установленная версия ниже min_supported. */
|
||||||
|
export function releaseNeed(installed: number, versionCode: number, minSupported: number): ReleaseNeed {
|
||||||
|
if (versionCode <= installed) return 'current';
|
||||||
|
return installed < minSupported ? 'required' : 'optional';
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { AuthSession, type AuthState } from '@pipu/core';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { currentServer, refreshStoreFor, rememberServer, setCurrentServer } from './storage';
|
||||||
|
|
||||||
|
interface SessionCtx {
|
||||||
|
/** Адрес сервера; null — сервер не выбран (экран подключения). */
|
||||||
|
server: string | null;
|
||||||
|
session: AuthSession | null;
|
||||||
|
ready: boolean;
|
||||||
|
connect: (url: string, version: string) => Promise<void>;
|
||||||
|
disconnect: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<SessionCtx | null>(null);
|
||||||
|
|
||||||
|
function deviceName(): string {
|
||||||
|
return Constants.deviceName ?? 'Android';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSession(url: string) {
|
||||||
|
return new AuthSession({ baseUrl: url, clientType: 'android', deviceName: deviceName(), refreshStore: refreshStoreFor(url) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущий сервер и сессия на нём; при запуске восстанавливает вход по сохранённому refresh-токену. */
|
||||||
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [server, setServer] = useState<string | null>(null);
|
||||||
|
const [session, setSession] = useState<AuthSession | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
const url = await currentServer();
|
||||||
|
if (url) {
|
||||||
|
const s = makeSession(url);
|
||||||
|
await s.restore();
|
||||||
|
setServer(url);
|
||||||
|
setSession(s);
|
||||||
|
}
|
||||||
|
setReady(true);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(async (url: string, version: string) => {
|
||||||
|
await rememberServer({ url, version });
|
||||||
|
await setCurrentServer(url);
|
||||||
|
const s = makeSession(url);
|
||||||
|
await s.restore(); // на этом сервере уже могли войти раньше
|
||||||
|
setServer(url);
|
||||||
|
setSession(s);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disconnect = useCallback(async () => {
|
||||||
|
await setCurrentServer(null);
|
||||||
|
setServer(null);
|
||||||
|
setSession(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ server, session, ready, connect, disconnect }), [server, session, ready, connect, disconnect]);
|
||||||
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionCtx(): SessionCtx {
|
||||||
|
const c = useContext(Ctx);
|
||||||
|
if (!c) throw new Error('useSessionCtx outside SessionProvider');
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сессия вошедшего пользователя (экраны внутри приложения). */
|
||||||
|
export function useSession(): AuthSession {
|
||||||
|
const { session } = useSessionCtx();
|
||||||
|
if (!session) throw new Error('no session');
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNKNOWN: AuthState = { status: 'unknown' };
|
||||||
|
|
||||||
|
export function useAuthState(): AuthState {
|
||||||
|
const { session } = useSessionCtx();
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(fn) => session?.subscribe(fn) ?? (() => undefined),
|
||||||
|
() => session?.state ?? UNKNOWN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser() {
|
||||||
|
const state = useAuthState();
|
||||||
|
if (state.status !== 'signed_in') throw new Error('not signed in');
|
||||||
|
return state.user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
// Всё храним в SecureStore (Android Keystore): список серверов маленький, а refresh-токен — секрет.
|
||||||
|
const SERVERS_KEY = 'pipu.servers';
|
||||||
|
const CURRENT_KEY = 'pipu.server';
|
||||||
|
|
||||||
|
export interface SavedServer {
|
||||||
|
url: string;
|
||||||
|
version: string;
|
||||||
|
/** Последний вошедший логин — подставляется в форму входа. */
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключи SecureStore — только [A-Za-z0-9._-]: адрес сервера превращаем в безопасный суффикс. */
|
||||||
|
function keyFor(url: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (const ch of url) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||||
|
return `pipu.rt.${h.toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadServers(): Promise<SavedServer[]> {
|
||||||
|
try {
|
||||||
|
const raw = await SecureStore.getItemAsync(SERVERS_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as SavedServer[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сохраняет сервер наверх списка (последний использованный — первый). */
|
||||||
|
export async function rememberServer(server: SavedServer): Promise<void> {
|
||||||
|
const list = (await loadServers()).filter((s) => s.url !== server.url);
|
||||||
|
await SecureStore.setItemAsync(SERVERS_KEY, JSON.stringify([server, ...list].slice(0, 10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgetServer(url: string): Promise<void> {
|
||||||
|
const list = (await loadServers()).filter((s) => s.url !== url);
|
||||||
|
await SecureStore.setItemAsync(SERVERS_KEY, JSON.stringify(list));
|
||||||
|
await refreshStoreFor(url).clear();
|
||||||
|
if ((await currentServer()) === url) await SecureStore.deleteItemAsync(CURRENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function currentServer(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync(CURRENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCurrentServer(url: string | null): Promise<void> {
|
||||||
|
if (url) await SecureStore.setItemAsync(CURRENT_KEY, url);
|
||||||
|
else await SecureStore.deleteItemAsync(CURRENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh-токен сервера (у каждого сервера — свой) и последний пользователь — для запуска без сети. */
|
||||||
|
export function refreshStoreFor(url: string): RefreshStore {
|
||||||
|
const key = keyFor(url);
|
||||||
|
const userKey = key.replace('pipu.rt.', 'pipu.user.');
|
||||||
|
return {
|
||||||
|
get: () => SecureStore.getItemAsync(key),
|
||||||
|
set: (token) => SecureStore.setItemAsync(key, token),
|
||||||
|
clear: async () => {
|
||||||
|
await SecureStore.deleteItemAsync(key);
|
||||||
|
await SecureStore.deleteItemAsync(userKey);
|
||||||
|
},
|
||||||
|
getUser: async () => {
|
||||||
|
try {
|
||||||
|
const raw = await SecureStore.getItemAsync(userKey);
|
||||||
|
return raw ? (JSON.parse(raw) as AuthUser) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setUser: (user) => SecureStore.setItemAsync(userKey, JSON.stringify(user)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Короткий стабильный хэш строки — для имён файлов и ключей. */
|
||||||
|
export function shortHash(s: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||||
|
return h.toString(36);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { unwrap, type AuthSession } from '@pipu/core';
|
||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import * as IntentLauncher from 'expo-intent-launcher';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { sha256 } from 'js-sha256';
|
||||||
|
|
||||||
|
import { releaseNeed } from './releaseNeed';
|
||||||
|
|
||||||
|
export type AndroidRelease = components['schemas']['AndroidRelease'];
|
||||||
|
|
||||||
|
/** Состояние самообновления (docs/mobile.md#самообновление). */
|
||||||
|
export type UpdateState =
|
||||||
|
| { status: 'idle' }
|
||||||
|
| { status: 'checking' }
|
||||||
|
| { status: 'latest' }
|
||||||
|
| {
|
||||||
|
status: 'available';
|
||||||
|
release: AndroidRelease;
|
||||||
|
required: boolean;
|
||||||
|
dismissed: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'downloading';
|
||||||
|
release: AndroidRelease;
|
||||||
|
required: boolean;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
| { status: 'verifying'; release: AndroidRelease; required: boolean }
|
||||||
|
| {
|
||||||
|
status: 'ready';
|
||||||
|
release: AndroidRelease;
|
||||||
|
required: boolean;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'error';
|
||||||
|
release: AndroidRelease;
|
||||||
|
required: boolean;
|
||||||
|
error: 'bad_checksum' | 'failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const CHECKED_KEY = 'pipu.update.checked';
|
||||||
|
const HASH_CHUNK = 1 << 20;
|
||||||
|
const APK_MIME = 'application/vnd.android.package-archive';
|
||||||
|
|
||||||
|
export function installedVersionCode(): number {
|
||||||
|
return Number((Constants.expoConfig?.extra as { versionCode?: number } | undefined)?.versionCode ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 файла кусками по 1 МБ: целиком APK в память JS не читаем. */
|
||||||
|
async function fileSha256(uri: string, size: number): Promise<string> {
|
||||||
|
const hash = sha256.create();
|
||||||
|
for (let pos = 0; pos < size; pos += HASH_CHUNK) {
|
||||||
|
const b64 = await FileSystem.readAsStringAsync(uri, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
position: pos,
|
||||||
|
length: Math.min(HASH_CHUNK, size - pos),
|
||||||
|
});
|
||||||
|
const bin = atob(b64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
hash.update(bytes);
|
||||||
|
}
|
||||||
|
return hash.hex();
|
||||||
|
}
|
||||||
|
|
||||||
|
class Updater {
|
||||||
|
private current: UpdateState = { status: 'idle' };
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private download: FileSystem.DownloadResumable | null = null;
|
||||||
|
|
||||||
|
get state(): UpdateState {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
private set(s: UpdateState) {
|
||||||
|
this.current = s;
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверка: автоматически — не чаще раза в 6 часов, вручную (профиль) — всегда. */
|
||||||
|
async check(session: AuthSession, manual = false): Promise<void> {
|
||||||
|
if (['checking', 'downloading', 'verifying'].includes(this.current.status)) return;
|
||||||
|
if (!manual) {
|
||||||
|
const last = Number((await SecureStore.getItemAsync(CHECKED_KEY)) ?? 0);
|
||||||
|
if (Date.now() - last < CHECK_EVERY_MS) return;
|
||||||
|
}
|
||||||
|
if (manual) this.set({ status: 'checking' });
|
||||||
|
try {
|
||||||
|
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
|
||||||
|
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
|
||||||
|
const installed = installedVersionCode();
|
||||||
|
const need = releaseNeed(installed, release.version_code, release.min_supported);
|
||||||
|
if (need !== 'current') {
|
||||||
|
this.set({
|
||||||
|
status: 'available',
|
||||||
|
release,
|
||||||
|
required: need === 'required',
|
||||||
|
dismissed: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.set(manual ? { status: 'latest' } : { status: 'idle' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Нет релиза или сервер без Gitea — молча; при ручной проверке показываем «последняя версия».
|
||||||
|
this.set(manual ? { status: 'latest' } : { status: 'idle' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
const s = this.current;
|
||||||
|
if (s.status === 'available' && !s.required) this.set({ ...s, dismissed: true });
|
||||||
|
else if (s.status === 'latest') this.set({ status: 'idle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачивает APK с сервера (с Bearer), сверяет SHA-256 и запускает установщик. */
|
||||||
|
async downloadAndInstall(session: AuthSession, server: string): Promise<void> {
|
||||||
|
const s = this.current;
|
||||||
|
if (s.status !== 'available' && s.status !== 'error' && s.status !== 'ready') return;
|
||||||
|
const { release, required } = s;
|
||||||
|
if (s.status === 'ready') return this.install(s.file);
|
||||||
|
|
||||||
|
// Любой запрос через client обновит access-токен, если он истёк.
|
||||||
|
await session.client.GET('/api/v1/app/android/latest').catch(() => undefined);
|
||||||
|
const file = `${FileSystem.cacheDirectory}pipu-music-${release.version}.apk`;
|
||||||
|
this.set({ status: 'downloading', release, required, progress: 0 });
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(file, { idempotent: true });
|
||||||
|
this.download = FileSystem.createDownloadResumable(
|
||||||
|
server + release.download_url,
|
||||||
|
file,
|
||||||
|
{ headers: { Authorization: `Bearer ${session.accessToken ?? ''}` } },
|
||||||
|
(p) => {
|
||||||
|
const total = p.totalBytesExpectedToWrite > 0 ? p.totalBytesExpectedToWrite : release.size;
|
||||||
|
this.set({
|
||||||
|
status: 'downloading',
|
||||||
|
release,
|
||||||
|
required,
|
||||||
|
progress: total ? p.totalBytesWritten / total : 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const res = await this.download.downloadAsync();
|
||||||
|
this.download = null;
|
||||||
|
if (!res || res.status !== 200) throw new Error(`status ${res?.status}`);
|
||||||
|
|
||||||
|
this.set({ status: 'verifying', release, required });
|
||||||
|
const info = await FileSystem.getInfoAsync(file);
|
||||||
|
const size = info.exists ? info.size : 0;
|
||||||
|
if ((await fileSha256(file, size)) !== release.sha256.toLowerCase()) {
|
||||||
|
await FileSystem.deleteAsync(file, { idempotent: true });
|
||||||
|
this.set({ status: 'error', release, required, error: 'bad_checksum' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.set({ status: 'ready', release, required, file });
|
||||||
|
await this.install(file);
|
||||||
|
} catch {
|
||||||
|
this.download = null;
|
||||||
|
this.set({ status: 'error', release, required, error: 'failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async install(file: string) {
|
||||||
|
const uri = await FileSystem.getContentUriAsync(file);
|
||||||
|
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||||
|
data: uri,
|
||||||
|
type: APK_MIME,
|
||||||
|
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Экран «Установка неизвестных приложений» для нашего пакета. */
|
||||||
|
async openInstallSettings() {
|
||||||
|
const pkg = Constants.expoConfig?.android?.package ?? 'app.pipu.music';
|
||||||
|
await IntentLauncher.startActivityAsync('android.settings.MANAGE_UNKNOWN_APP_SOURCES', { data: `package:${pkg}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updater = new Updater();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as SQLite from 'expo-sqlite';
|
||||||
|
|
||||||
|
let db: Promise<SQLite.SQLiteDatabase> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальная база офлайн-режима (docs/mobile.md#офлайн). `owner` — хэш «сервер + пользователь»:
|
||||||
|
* скачанное, неотправленные события и реакции одного аккаунта не видны другому.
|
||||||
|
*/
|
||||||
|
export function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||||
|
db ??= (async () => {
|
||||||
|
const d = await SQLite.openDatabaseAsync('pipu.db');
|
||||||
|
await d.execAsync(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS downloads (
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
track TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
added_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (owner, track_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_reactions (
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
reaction TEXT,
|
||||||
|
PRIMARY KEY (owner, track_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
return d;
|
||||||
|
})();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
|
||||||
|
import { unwrap, type AuthSession, type Track } from '@pipu/core';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
import { qualityPrefs } from '../lib/prefs';
|
||||||
|
import { shortHash } from '../lib/storage';
|
||||||
|
import { getDb } from './db';
|
||||||
|
|
||||||
|
export type DownloadStatus = 'queued' | 'downloading' | 'done' | 'error';
|
||||||
|
|
||||||
|
export interface DownloadItem {
|
||||||
|
track: Track;
|
||||||
|
status: DownloadStatus;
|
||||||
|
progress: number;
|
||||||
|
bytes: number;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadsState {
|
||||||
|
items: ReadonlyMap<string, DownloadItem>;
|
||||||
|
wifiOnly: boolean;
|
||||||
|
autoLikes: boolean;
|
||||||
|
/** Сеть разрешает загрузку прямо сейчас (с учётом «только Wi-Fi»). */
|
||||||
|
allowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARALLEL = 2;
|
||||||
|
const WIFI_KEY = 'pipu.dl.wifi';
|
||||||
|
const AUTO_LIKES_KEY = 'pipu.dl.autolikes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузки для офлайна: файлы — в documentDirectory/offline/<owner>/, метаданные (DTO трека) — в SQLite.
|
||||||
|
* Не более двух загрузок параллельно; по умолчанию только по Wi-Fi; прерванные продолжаются при запуске.
|
||||||
|
*/
|
||||||
|
class Downloads {
|
||||||
|
private session: AuthSession | null = null;
|
||||||
|
private server = '';
|
||||||
|
private owner = '';
|
||||||
|
private active = new Map<string, FileSystem.DownloadResumable | null>();
|
||||||
|
private net: NetInfoState | null = null;
|
||||||
|
private unsubscribeNet: (() => void) | null = null;
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
private current: DownloadsState = { items: new Map(), wifiOnly: true, autoLikes: false, allowed: false };
|
||||||
|
|
||||||
|
get state(): DownloadsState {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
private set(patch: Partial<DownloadsState>) {
|
||||||
|
this.current = { ...this.current, ...patch };
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setItem(id: string, patch: Partial<DownloadItem> | null) {
|
||||||
|
const items = new Map(this.current.items);
|
||||||
|
if (patch === null) items.delete(id);
|
||||||
|
else {
|
||||||
|
const prev = items.get(id);
|
||||||
|
if (!prev && !patch.track) return;
|
||||||
|
items.set(id, { ...(prev as DownloadItem), ...patch });
|
||||||
|
}
|
||||||
|
this.set({ items });
|
||||||
|
}
|
||||||
|
|
||||||
|
private get dir() {
|
||||||
|
return `${FileSystem.documentDirectory}offline/${this.owner}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private file(trackId: string) {
|
||||||
|
return `${this.dir}${trackId}.opus`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cover(albumId: string) {
|
||||||
|
return `${this.dir}covers/${albumId}.webp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Привязка к пользователю: загружает список скачанного и продолжает незавершённые загрузки. */
|
||||||
|
async attach(session: AuthSession | null, server: string, userId: string | null) {
|
||||||
|
const owner = session && userId ? shortHash(`${server}|${userId}`) : '';
|
||||||
|
if (owner === this.owner && session === this.session) return;
|
||||||
|
for (const d of this.active.values()) void d?.pauseAsync().catch(() => undefined);
|
||||||
|
this.active.clear();
|
||||||
|
this.session = session;
|
||||||
|
this.server = server;
|
||||||
|
this.owner = owner;
|
||||||
|
if (!owner) {
|
||||||
|
this.set({ items: new Map() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [wifi, autoLikes] = await Promise.all([SecureStore.getItemAsync(WIFI_KEY), SecureStore.getItemAsync(AUTO_LIKES_KEY)]);
|
||||||
|
await FileSystem.makeDirectoryAsync(`${this.dir}covers/`, { intermediates: true }).catch(() => undefined);
|
||||||
|
const db = await getDb();
|
||||||
|
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'downloading'`, owner);
|
||||||
|
const rows = await db.getAllAsync<{ track_id: string; track: string; status: DownloadStatus; bytes: number; added_at: number }>(
|
||||||
|
'SELECT track_id, track, status, bytes, added_at FROM downloads WHERE owner = ? ORDER BY added_at',
|
||||||
|
owner,
|
||||||
|
);
|
||||||
|
const items = new Map<string, DownloadItem>();
|
||||||
|
for (const r of rows) {
|
||||||
|
items.set(r.track_id, { track: JSON.parse(r.track) as Track, status: r.status, progress: r.status === 'done' ? 1 : 0, bytes: r.bytes, addedAt: r.added_at });
|
||||||
|
}
|
||||||
|
this.set({ items, wifiOnly: wifi !== '0', autoLikes: autoLikes === '1' });
|
||||||
|
|
||||||
|
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||||
|
this.net = st;
|
||||||
|
this.set({ allowed: this.networkAllowed() });
|
||||||
|
this.pump();
|
||||||
|
});
|
||||||
|
this.net = await NetInfo.fetch();
|
||||||
|
this.set({ allowed: this.networkAllowed() });
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
private networkAllowed(): boolean {
|
||||||
|
if (!this.net?.isConnected) return false;
|
||||||
|
return !this.current.wifiOnly || this.net.type === 'wifi' || this.net.type === 'ethernet';
|
||||||
|
}
|
||||||
|
|
||||||
|
async setWifiOnly(v: boolean) {
|
||||||
|
await SecureStore.setItemAsync(WIFI_KEY, v ? '1' : '0');
|
||||||
|
this.set({ wifiOnly: v });
|
||||||
|
this.set({ allowed: this.networkAllowed() });
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAutoLikes(v: boolean) {
|
||||||
|
await SecureStore.setItemAsync(AUTO_LIKES_KEY, v ? '1' : '0');
|
||||||
|
this.set({ autoLikes: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
isDownloaded(trackId: string): boolean {
|
||||||
|
return this.current.items.get(trackId)?.status === 'done';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Локальный файл трека, если он скачан. */
|
||||||
|
localTrack(trackId: string): string | undefined {
|
||||||
|
return this.isDownloaded(trackId) ? this.file(trackId) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
localCover(track: Track): string | undefined {
|
||||||
|
return this.isDownloaded(track.id) && track.album.cover_url ? this.cover(track.album.id) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обложка альбома на устройстве, если из него что-то скачано. */
|
||||||
|
localAlbumCover(albumId: string): string | undefined {
|
||||||
|
for (const it of this.current.items.values()) {
|
||||||
|
if (it.status === 'done' && it.track.album.id === albumId && it.track.album.cover_url) return this.cover(albumId);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачанные треки в порядке добавления. */
|
||||||
|
downloaded(): Track[] {
|
||||||
|
return [...this.current.items.values()].filter((i) => i.status === 'done').map((i) => i.track);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueue(tracks: Track[]) {
|
||||||
|
if (!this.owner) return;
|
||||||
|
const db = await getDb();
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [n, t] of tracks.entries()) {
|
||||||
|
if (!t.available || this.current.items.has(t.id)) continue;
|
||||||
|
const addedAt = now + n;
|
||||||
|
await db.runAsync(
|
||||||
|
`INSERT OR IGNORE INTO downloads (owner, track_id, track, status, added_at) VALUES (?, ?, ?, 'queued', ?)`,
|
||||||
|
this.owner,
|
||||||
|
t.id,
|
||||||
|
JSON.stringify(t),
|
||||||
|
addedAt,
|
||||||
|
);
|
||||||
|
this.setItem(t.id, { track: t, status: 'queued', progress: 0, bytes: 0, addedAt });
|
||||||
|
}
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(trackIds: string[]) {
|
||||||
|
const db = await getDb();
|
||||||
|
for (const id of trackIds) {
|
||||||
|
const d = this.active.get(id);
|
||||||
|
this.active.delete(id);
|
||||||
|
await d?.pauseAsync().catch(() => undefined);
|
||||||
|
await FileSystem.deleteAsync(this.file(id), { idempotent: true });
|
||||||
|
await FileSystem.deleteAsync(`${this.file(id)}.part`, { idempotent: true });
|
||||||
|
await db.runAsync('DELETE FROM downloads WHERE owner = ? AND track_id = ?', this.owner, id);
|
||||||
|
this.setItem(id, null);
|
||||||
|
}
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реакция в сохранённом DTO: офлайн-волна и списки скачанного видят её без сети. */
|
||||||
|
async setReaction(trackId: string, reaction: Track['reaction']) {
|
||||||
|
const it = this.current.items.get(trackId);
|
||||||
|
if (!it || it.track.reaction === reaction) return;
|
||||||
|
const track = { ...it.track, reaction };
|
||||||
|
this.setItem(trackId, { track });
|
||||||
|
const db = await getDb();
|
||||||
|
await db.runAsync('UPDATE downloads SET track = ? WHERE owner = ? AND track_id = ?', JSON.stringify(track), this.owner, trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Повторить загрузки с ошибкой. */
|
||||||
|
async retry() {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'error'`, this.owner);
|
||||||
|
for (const [id, it] of this.current.items) if (it.status === 'error') this.setItem(id, { status: 'queued', progress: 0 });
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Занято на устройстве и свободно в телефоне (байты). */
|
||||||
|
async usage(): Promise<{ used: number; free: number }> {
|
||||||
|
let used = 0;
|
||||||
|
for (const it of this.current.items.values()) used += it.bytes;
|
||||||
|
const free = await FileSystem.getFreeDiskStorageAsync().catch(() => 0);
|
||||||
|
return { used, free };
|
||||||
|
}
|
||||||
|
|
||||||
|
private pump() {
|
||||||
|
if (!this.owner || !this.current.allowed) return;
|
||||||
|
for (const [id, it] of this.current.items) {
|
||||||
|
if (this.active.size >= PARALLEL) return;
|
||||||
|
if (it.status !== 'queued' || this.active.has(id)) continue;
|
||||||
|
this.active.set(id, null);
|
||||||
|
void this.run(id).finally(() => {
|
||||||
|
this.active.delete(id);
|
||||||
|
this.pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(id: string) {
|
||||||
|
const session = this.session;
|
||||||
|
const owner = this.owner;
|
||||||
|
if (!session) return;
|
||||||
|
const db = await getDb();
|
||||||
|
const status = async (s: DownloadStatus, patch: Partial<DownloadItem> = {}, track?: Track) => {
|
||||||
|
if (this.owner !== owner) return;
|
||||||
|
await db.runAsync(
|
||||||
|
'UPDATE downloads SET status = ?, bytes = ?, track = COALESCE(?, track) WHERE owner = ? AND track_id = ?',
|
||||||
|
s,
|
||||||
|
patch.bytes ?? 0,
|
||||||
|
track ? JSON.stringify(track) : null,
|
||||||
|
owner,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
this.setItem(id, { status: s, ...patch, ...(track ? { track } : {}) });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await status('downloading', { progress: 0 });
|
||||||
|
// Подписанные ссылки живут 12 ч — перед загрузкой берём свежий DTO.
|
||||||
|
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: [id] } }));
|
||||||
|
const track = res.items[0];
|
||||||
|
if (!track?.available || !track.stream_url) {
|
||||||
|
await status('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const part = `${this.file(id)}.part`;
|
||||||
|
const q = (await qualityPrefs.load()).download;
|
||||||
|
const dl = FileSystem.createDownloadResumable(`${this.abs(track.stream_url)}&q=${q}`, part, {}, (p) => {
|
||||||
|
if (p.totalBytesExpectedToWrite > 0) this.setItem(id, { progress: p.totalBytesWritten / p.totalBytesExpectedToWrite });
|
||||||
|
});
|
||||||
|
this.active.set(id, dl);
|
||||||
|
const out = await dl.downloadAsync();
|
||||||
|
if (this.active.get(id) !== dl) return; // отменено (удалили или сменился пользователь)
|
||||||
|
if (!out || out.status !== 200) throw new Error(`status ${out?.status}`);
|
||||||
|
await FileSystem.moveAsync({ from: part, to: this.file(id) });
|
||||||
|
|
||||||
|
if (track.album.cover_url) {
|
||||||
|
const cover = this.cover(track.album.id);
|
||||||
|
const info = await FileSystem.getInfoAsync(cover);
|
||||||
|
if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
|
||||||
|
}
|
||||||
|
const info = await FileSystem.getInfoAsync(this.file(id));
|
||||||
|
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
||||||
|
} catch {
|
||||||
|
await status('error', { progress: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private abs(path: string) {
|
||||||
|
return /^https?:/.test(path) ? path : this.server + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Размер нужен только обложкам 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();
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { ApiError, unwrap, type AuthSession, type PlayEvent } from '@pipu/core';
|
||||||
|
|
||||||
|
import { shortHash } from '../lib/storage';
|
||||||
|
import { getDb } from './db';
|
||||||
|
|
||||||
|
const BATCH = 500;
|
||||||
|
|
||||||
|
type Reaction = 'like' | 'dislike' | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправка событий прослушивания и реакций с запасом на офлайн: что не ушло из-за сети — в SQLite,
|
||||||
|
* досылается при появлении сети (`POST /events` идемпотентен по id события, реакция — PUT/DELETE,
|
||||||
|
* для трека хранится только последняя).
|
||||||
|
*/
|
||||||
|
export class PersistentEvents {
|
||||||
|
private readonly owner: string;
|
||||||
|
private draining = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly session: AuthSession,
|
||||||
|
server: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
this.owner = shortHash(`${server}|${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(events: PlayEvent[]) {
|
||||||
|
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sink для EventBuffer: при сетевой ошибке сохраняет пачку и «успешно» завершается. */
|
||||||
|
send = async (events: PlayEvent[]) => {
|
||||||
|
try {
|
||||||
|
await this.post(events);
|
||||||
|
void this.drain();
|
||||||
|
} catch (e) {
|
||||||
|
if (!isNetwork(e)) throw e;
|
||||||
|
const db = await getDb();
|
||||||
|
await db.runAsync('INSERT INTO pending_events (owner, body) VALUES (?, ?)', this.owner, JSON.stringify(events));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private async putReaction(trackId: string, reaction: Reaction) {
|
||||||
|
const params = { params: { path: { id: trackId } } };
|
||||||
|
if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } }));
|
||||||
|
else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реакция на трек. Без сети — откладывается и возвращает `queued`; ошибку сервера пробрасывает. */
|
||||||
|
async react(trackId: string, reaction: Reaction): Promise<'sent' | 'queued'> {
|
||||||
|
const db = await getDb();
|
||||||
|
try {
|
||||||
|
await this.putReaction(trackId, reaction);
|
||||||
|
} catch (e) {
|
||||||
|
if (!isNetwork(e)) throw e;
|
||||||
|
await db.runAsync(
|
||||||
|
'INSERT OR REPLACE INTO pending_reactions (owner, track_id, reaction) VALUES (?, ?, ?)',
|
||||||
|
this.owner,
|
||||||
|
trackId,
|
||||||
|
reaction,
|
||||||
|
);
|
||||||
|
return 'queued';
|
||||||
|
}
|
||||||
|
// Более свежая реакция ушла — отложенная устарела.
|
||||||
|
await db.runAsync('DELETE FROM pending_reactions WHERE owner = ? AND track_id = ?', this.owner, trackId);
|
||||||
|
void this.drain();
|
||||||
|
return 'sent';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Досылает отложенные реакции и пачки событий; останавливается на первой сетевой ошибке. */
|
||||||
|
async drain() {
|
||||||
|
if (this.draining) return;
|
||||||
|
this.draining = true;
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const reactions = await db.getAllAsync<{ track_id: string; reaction: Reaction }>(
|
||||||
|
'SELECT track_id, reaction FROM pending_reactions WHERE owner = ?',
|
||||||
|
this.owner,
|
||||||
|
);
|
||||||
|
for (const r of reactions) {
|
||||||
|
try {
|
||||||
|
await this.putReaction(r.track_id, r.reaction);
|
||||||
|
} catch (e) {
|
||||||
|
if (isNetwork(e)) return;
|
||||||
|
// трек удалён из библиотеки и т. п. — отбрасываем
|
||||||
|
}
|
||||||
|
await db.runAsync(
|
||||||
|
'DELETE FROM pending_reactions WHERE owner = ? AND track_id = ? AND reaction IS ?',
|
||||||
|
this.owner,
|
||||||
|
r.track_id,
|
||||||
|
r.reaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
const rows = await db.getAllAsync<{ id: number; body: string }>(
|
||||||
|
'SELECT id, body FROM pending_events WHERE owner = ? ORDER BY id LIMIT 20',
|
||||||
|
this.owner,
|
||||||
|
);
|
||||||
|
if (!rows.length) return;
|
||||||
|
for (const r of rows) {
|
||||||
|
const events = JSON.parse(r.body) as PlayEvent[];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < events.length; i += BATCH) await this.post(events.slice(i, i + BATCH));
|
||||||
|
} catch (e) {
|
||||||
|
if (isNetwork(e)) return;
|
||||||
|
// Сервер отверг пачку (4xx) — повтор не поможет, отбрасываем.
|
||||||
|
}
|
||||||
|
await db.runAsync('DELETE FROM pending_events WHERE id = ?', r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.draining = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetwork(e: unknown): boolean {
|
||||||
|
if (e instanceof ApiError) return e.code === 'common.network' || e.status >= 500;
|
||||||
|
return true; // fetch бросает TypeError без ответа сервера
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import {
|
||||||
|
EventBuffer,
|
||||||
|
createActivityPublisher,
|
||||||
|
type ActivityPublisher,
|
||||||
|
ListeningCoordinator,
|
||||||
|
type ListeningRoom,
|
||||||
|
WaveCoordinator,
|
||||||
|
EARLY_SKIP_MS,
|
||||||
|
type WaveInfo,
|
||||||
|
type WaveOutcome,
|
||||||
|
type WaveFeedback,
|
||||||
|
PlayAttempt,
|
||||||
|
normalizeGain,
|
||||||
|
unwrap,
|
||||||
|
type AuthSession,
|
||||||
|
type PlayContext,
|
||||||
|
type Track,
|
||||||
|
} from '@pipu/core';
|
||||||
|
import TrackPlayer, {
|
||||||
|
AppKilledPlaybackBehavior,
|
||||||
|
Capability,
|
||||||
|
Event,
|
||||||
|
State,
|
||||||
|
type Track as RntpTrack,
|
||||||
|
} from 'react-native-track-player';
|
||||||
|
|
||||||
|
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||||
|
import { downloads, sizedCover } from '../offline/downloads';
|
||||||
|
import { PersistentEvents } from '../offline/events';
|
||||||
|
import { offlineWave } from './offlineWave';
|
||||||
|
import { eventId } from './eventId';
|
||||||
|
|
||||||
|
export { offlineWave };
|
||||||
|
|
||||||
|
export type { WaveInfo } from '@pipu/core';
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
listening?: import('@pipu/core').ListeningClientState;
|
||||||
|
ready: boolean;
|
||||||
|
track: Track | null;
|
||||||
|
queue: Track[];
|
||||||
|
index: number;
|
||||||
|
playing: boolean;
|
||||||
|
buffering: boolean;
|
||||||
|
positionMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
context: PlayContext | null;
|
||||||
|
wave: WaveInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAVE_EVERY_MS = 10_000;
|
||||||
|
|
||||||
|
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
||||||
|
|
||||||
|
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
|
||||||
|
export function absolute(server: string, path: string | null | undefined): string | undefined {
|
||||||
|
if (!path) return undefined;
|
||||||
|
return /^https?:/.test(path) ? path : server + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Качество по сети (docs/playback.md#качество): Wi-Fi — high, сотовая — medium. */
|
||||||
|
/** Качество стрима по типу сети — из настроек профиля. */
|
||||||
|
async function quality(): Promise<Quality> {
|
||||||
|
const prefs = await qualityPrefs.load();
|
||||||
|
try {
|
||||||
|
const net = await NetInfo.fetch();
|
||||||
|
return net.type === 'cellular' ? prefs.cellular : prefs.wifi;
|
||||||
|
} catch {
|
||||||
|
return prefs.wifi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плеер Android. Очередь живёт в ExoPlayer (react-native-track-player) — играет и с выгруженным UI;
|
||||||
|
* здесь — зеркало очереди с DTO треков, учёт прослушиваний (PlayAttempt из @pipu/core), события,
|
||||||
|
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
|
||||||
|
*/
|
||||||
|
class Controller {
|
||||||
|
private activity: ActivityPublisher | null = null;
|
||||||
|
private listening: ListeningCoordinator | null = null;
|
||||||
|
private interrupted = false;
|
||||||
|
private remoteRoom: string | null = null;
|
||||||
|
private queueVersion = 0;
|
||||||
|
private session: AuthSession | null = null;
|
||||||
|
private server = '';
|
||||||
|
private events: EventBuffer | null = null;
|
||||||
|
private persistent: PersistentEvents | null = null;
|
||||||
|
private unsubscribeNet: (() => void) | null = null;
|
||||||
|
private attempt: PlayAttempt | null = null;
|
||||||
|
private lastPositionMs = 0;
|
||||||
|
private lastSave = 0;
|
||||||
|
private readonly wavePolicy = new WaveCoordinator({
|
||||||
|
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
|
||||||
|
await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', {
|
||||||
|
params: { path: { id } }, body: { feedback },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
||||||
|
private setupDone: Promise<void> | null = null;
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
private state: PlayerState = {
|
||||||
|
ready: false, track: null, queue: [], index: 0, playing: false, buffering: false, positionMs: 0, durationMs: 0,
|
||||||
|
context: null, wave: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private update(patch: Partial<PlayerState>) {
|
||||||
|
this.state = { ...this.state, ...patch };
|
||||||
|
this.activity?.update(this.state);
|
||||||
|
this.listening?.update();
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
|
||||||
|
attach(session: AuthSession | null, server: string, userId: string | null) {
|
||||||
|
if (this.session === session) return;
|
||||||
|
this.activity?.dispose();
|
||||||
|
this.listening?.dispose();
|
||||||
|
this.listening = null;
|
||||||
|
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
|
||||||
|
if (session && this.activity) {
|
||||||
|
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
|
||||||
|
read: this.getState,
|
||||||
|
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
|
||||||
|
apply: (room, position, current) => this.syncListening(room, position, current),
|
||||||
|
changed: listening => this.update({ listening }),
|
||||||
|
});
|
||||||
|
this.listening.start();
|
||||||
|
}
|
||||||
|
void this.events?.flush();
|
||||||
|
this.session = session;
|
||||||
|
this.server = server;
|
||||||
|
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
||||||
|
const sink = this.persistent;
|
||||||
|
this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
|
||||||
|
void sink?.drain();
|
||||||
|
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||||
|
if (st.isConnected) void this.persistent?.drain();
|
||||||
|
});
|
||||||
|
if (!session) void this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(): Promise<void> {
|
||||||
|
this.setupDone ??= (async () => {
|
||||||
|
// Очистка кэша из профиля — только пока сервис плеера не запущен (иначе SimpleCache держит файлы).
|
||||||
|
if (!(await TrackPlayer.isServiceRunning().catch(() => true))) await playerCache.clearIfRequested();
|
||||||
|
try {
|
||||||
|
// 500 МБ кэша ExoPlayer для недавних стримов (значение — в КБ).
|
||||||
|
await TrackPlayer.setupPlayer({ maxCacheSize: 500 * 1024, autoHandleInterruptions: true });
|
||||||
|
} catch {
|
||||||
|
// уже настроен (повторный запуск JS при живом сервисе)
|
||||||
|
}
|
||||||
|
await TrackPlayer.updateOptions({
|
||||||
|
capabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext, Capability.SkipToPrevious, Capability.SeekTo],
|
||||||
|
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
|
||||||
|
progressUpdateEventInterval: 1,
|
||||||
|
android: { appKilledPlaybackBehavior: AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification },
|
||||||
|
});
|
||||||
|
this.bind();
|
||||||
|
this.update({ ready: true });
|
||||||
|
})();
|
||||||
|
return this.setupDone;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bind() {
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => {
|
||||||
|
this.update({ playing: state === State.Playing, buffering: state === State.Buffering || state === State.Loading });
|
||||||
|
if (state === State.Paused) this.save();
|
||||||
|
});
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ position, duration }) => {
|
||||||
|
const ms = Math.round(position * 1000);
|
||||||
|
this.attempt?.tick(ms);
|
||||||
|
this.lastPositionMs = ms;
|
||||||
|
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
|
||||||
|
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
|
||||||
|
const cf = Math.min(6, Math.max(0, this.userPrefs()?.crossfade_s ?? 0));
|
||||||
|
const left = duration - position;
|
||||||
|
const fade = cf > 0 && left < cf ? Math.max(0, left / cf) : 1;
|
||||||
|
void this.applyVolume(this.state.track, fade);
|
||||||
|
});
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
|
||||||
|
this.finishAttempt(lastPosition);
|
||||||
|
if (index === undefined || index === null) return;
|
||||||
|
const track = this.state.queue[index];
|
||||||
|
if (!track) return;
|
||||||
|
this.attempt = new PlayAttempt(track, 0);
|
||||||
|
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
|
||||||
|
void this.applyVolume(track, 1);
|
||||||
|
this.events?.push({
|
||||||
|
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
|
||||||
|
context_id: this.state.context?.id,
|
||||||
|
});
|
||||||
|
this.save();
|
||||||
|
void this.refillWave();
|
||||||
|
});
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => {
|
||||||
|
this.finishAttempt(this.lastPositionMs / 1000, true);
|
||||||
|
void this.events?.flush();
|
||||||
|
});
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackError, () => {
|
||||||
|
// Ссылка могла устареть или трек пропал — переходим дальше, ExoPlayer не застрянет на ошибке.
|
||||||
|
void TrackPlayer.skipToNext().catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Завершение попытки: доиграно (позиция у конца) или пропущено. */
|
||||||
|
private finishAttempt(lastPositionS: number, ended = false) {
|
||||||
|
const a = this.attempt;
|
||||||
|
if (!a) return;
|
||||||
|
this.attempt = null;
|
||||||
|
const pos = Math.round(lastPositionS * 1000);
|
||||||
|
if (this.neutralSwitch) {
|
||||||
|
this.neutralSwitch = false;
|
||||||
|
if (a.playedMs >= EARLY_SKIP_MS) {
|
||||||
|
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events?.flush();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kind = ended ? 'play_end' : a.outcome(pos);
|
||||||
|
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events?.flush();
|
||||||
|
if (kind === 'play_end') this.noteWave(a.track.id, 'complete');
|
||||||
|
else {
|
||||||
|
const early = a.playedMs < EARLY_SKIP_MS;
|
||||||
|
this.noteWave(a.track.id, early ? 'skip_early' : 'skip');
|
||||||
|
if (early) void this.refillWave(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private userPrefs() {
|
||||||
|
const st = this.session?.state;
|
||||||
|
return st?.status === 'signed_in' ? st.user.ui_prefs : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Громкость 0..1: нормализация вниз к −14 LUFS и затухание кроссфейда в конце трека. */
|
||||||
|
private async applyVolume(track: Track | null, fade: number) {
|
||||||
|
const gain = Math.min(1, normalizeGain(track?.features?.loudness_lufs, this.userPrefs()?.normalize !== false) * fade);
|
||||||
|
await TrackPlayer.setVolume(gain).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
||||||
|
const q = await quality();
|
||||||
|
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||||
|
return tracks.map((t) => {
|
||||||
|
const cover = absolute(this.server, t.album.cover_url);
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artists.map((a) => a.name).join(', '),
|
||||||
|
album: t.album.title,
|
||||||
|
artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
|
||||||
|
duration: t.duration_ms / 1000,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async playList(tracks: Track[], index: number, context: PlayContext) {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
|
const version = ++this.queueVersion;
|
||||||
|
const current = () => version === this.queueVersion && !this.listening?.locked;
|
||||||
|
const list = tracks.filter(playable);
|
||||||
|
const target = tracks[index];
|
||||||
|
if (!target || !list.length) return;
|
||||||
|
await this.setup();
|
||||||
|
if (!current()) return;
|
||||||
|
const native = await this.toRntp(list);
|
||||||
|
if (!current()) return;
|
||||||
|
this.leaveWave();
|
||||||
|
this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
|
const start = Math.max(list.indexOf(target), 0);
|
||||||
|
this.update({ queue: list, context, index: start, track: list[start] ?? null });
|
||||||
|
await TrackPlayer.reset();
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.skip(start);
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
|
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
|
void TrackPlayer.skipToNext().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prev() {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
|
const { position } = await TrackPlayer.getProgress();
|
||||||
|
if (this.listening?.locked) return;
|
||||||
|
if (position > 3) await TrackPlayer.seekTo(0);
|
||||||
|
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(ms: number) {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
|
const t = this.state.track;
|
||||||
|
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
|
||||||
|
this.attempt?.seek(ms);
|
||||||
|
this.lastPositionMs = ms;
|
||||||
|
void TrackPlayer.seekTo(ms / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реакция на трек: сразу в UI; без сети запоминается и уходит на сервер при появлении связи. */
|
||||||
|
async react(track: Track, reaction: 'like' | 'dislike' | null) {
|
||||||
|
if (!this.persistent) return;
|
||||||
|
const prev = track.reaction;
|
||||||
|
const apply = (r: Track['reaction']) => {
|
||||||
|
this.update({
|
||||||
|
queue: this.state.queue.map((t) => (t.id === track.id ? { ...t, reaction: r } : t)),
|
||||||
|
track: this.state.track?.id === track.id ? { ...this.state.track, reaction: r } : this.state.track,
|
||||||
|
});
|
||||||
|
void downloads.setReaction(track.id, r);
|
||||||
|
};
|
||||||
|
apply(reaction);
|
||||||
|
this.events?.push({ type: reaction ?? (prev === 'like' ? 'unlike' : 'undislike'), track_id: track.id });
|
||||||
|
try {
|
||||||
|
await this.persistent.react(track.id, reaction);
|
||||||
|
} catch (e) {
|
||||||
|
apply(prev);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (reaction === 'like' && downloads.state.autoLikes) void downloads.enqueue([{ ...track, reaction }]);
|
||||||
|
if (reaction) {
|
||||||
|
this.noteWave(track.id, reaction);
|
||||||
|
void this.refillWave(true);
|
||||||
|
}
|
||||||
|
if (reaction === 'dislike' && this.state.track?.id === track.id) this.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- «Моя волна» ---
|
||||||
|
|
||||||
|
async startWave(activity: string | null, mood: string | null) {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
|
if (!this.session) return;
|
||||||
|
await this.setup();
|
||||||
|
this.leaveWave();
|
||||||
|
const version = this.wavePolicy.version;
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
||||||
|
} catch (e) {
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
|
// Нет связи с сервером — офлайн-волна из скачанного.
|
||||||
|
const local = offlineWave(downloads.downloaded());
|
||||||
|
if (!local.length) throw e;
|
||||||
|
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
||||||
|
}
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
|
const tracks = res.tracks.filter(playable);
|
||||||
|
if (!tracks.length) return;
|
||||||
|
this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
|
this.wavePolicy.activate(res.session_id);
|
||||||
|
this.update({
|
||||||
|
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
|
||||||
|
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
|
||||||
|
});
|
||||||
|
await TrackPlayer.reset();
|
||||||
|
await TrackPlayer.add(await this.toRntp(tracks));
|
||||||
|
await TrackPlayer.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setWaveContext(activity: string | null, mood: string | null) {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
|
const wave = this.state.wave;
|
||||||
|
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||||
|
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||||
|
const res = unwrap(
|
||||||
|
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||||
|
params: { path: { id: wave.sessionId } },
|
||||||
|
body: { activity, mood },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
|
await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version));
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
|
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||||
|
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||||
|
if (res.tracks.some(playable)) {
|
||||||
|
this.neutralSwitch = true;
|
||||||
|
await TrackPlayer.skipToNext().catch(() => {
|
||||||
|
this.neutralSwitch = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private leaveWave() {
|
||||||
|
const wave = this.state.wave;
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
|
if (!wave) return;
|
||||||
|
this.update({ wave: null });
|
||||||
|
void this.session?.client
|
||||||
|
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private noteWave(trackId: string, outcome: WaveOutcome) {
|
||||||
|
this.wavePolicy.note(trackId, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
|
||||||
|
private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
|
||||||
|
const keep = this.state.queue.slice(0, this.state.index + 1);
|
||||||
|
const add = tracks.filter(playable);
|
||||||
|
const native = await this.toRntp(add);
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.removeUpcomingTracks();
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (current()) this.update({ queue: [...keep, ...add] });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refillWave(refresh = false) {
|
||||||
|
if (!this.session) return;
|
||||||
|
const remaining = this.state.queue.length - this.state.index - 1;
|
||||||
|
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
|
||||||
|
if (refresh) await this.replaceUpcoming(res.tracks, current);
|
||||||
|
else {
|
||||||
|
const add = res.tracks.filter(playable);
|
||||||
|
const native = await this.toRntp(add);
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (!current()) return;
|
||||||
|
this.update({ queue: [...this.state.queue, ...add] });
|
||||||
|
}
|
||||||
|
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- состояние ---
|
||||||
|
|
||||||
|
private save() {
|
||||||
|
const t = this.state.track;
|
||||||
|
if (!t || !this.session) return;
|
||||||
|
this.lastSave = Date.now();
|
||||||
|
void this.session.client
|
||||||
|
.PUT('/api/v1/me/playback-state', {
|
||||||
|
body: {
|
||||||
|
track_id: t.id,
|
||||||
|
position_ms: this.lastPositionMs,
|
||||||
|
queue: this.state.queue.slice(0, 500).map((x) => x.id),
|
||||||
|
queue_index: Math.min(this.state.index, 499),
|
||||||
|
context: this.state.context ? { type: this.state.context.type, id: this.state.context.id } : undefined,
|
||||||
|
device_id: 'android',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
|
||||||
|
async restore() {
|
||||||
|
if (!this.session || this.state.track || this.listening?.locked) return;
|
||||||
|
const session = this.session;
|
||||||
|
const initial = this.state;
|
||||||
|
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
|
||||||
|
await this.setup();
|
||||||
|
if (!current()) return;
|
||||||
|
try {
|
||||||
|
const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
|
||||||
|
if (!current()) return;
|
||||||
|
if (!st.track_id || !st.queue.length) return;
|
||||||
|
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
||||||
|
if (!current()) return;
|
||||||
|
const tracks = res.items.filter(playable);
|
||||||
|
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
|
||||||
|
if (!tracks.length) return;
|
||||||
|
const native = await this.toRntp(tracks);
|
||||||
|
if (!current()) return;
|
||||||
|
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
|
||||||
|
await TrackPlayer.reset();
|
||||||
|
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||||
|
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
|
||||||
|
} catch {
|
||||||
|
// офлайн или нет сохранённого состояния
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
this.queueVersion++;
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
|
this.attempt = null;
|
||||||
|
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||||
|
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
this.save();
|
||||||
|
void this.events?.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
|
||||||
|
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
|
||||||
|
setInterrupted(paused: boolean) { this.interrupted = paused; }
|
||||||
|
beginListeningAudio() { this.listening?.beginAudio(); }
|
||||||
|
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
||||||
|
if (!room.track_id) { await TrackPlayer.pause(); return; }
|
||||||
|
await this.setup();
|
||||||
|
if (!current()) return;
|
||||||
|
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
||||||
|
const track = unwrap(await this.session!.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||||
|
const native = await this.toRntp([track]);
|
||||||
|
if (!current()) return;
|
||||||
|
this.remoteRoom = room.id;
|
||||||
|
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
|
await TrackPlayer.pause();
|
||||||
|
if (!current()) return;
|
||||||
|
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
|
||||||
|
await TrackPlayer.reset();
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
}
|
||||||
|
if (!current()) return;
|
||||||
|
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
|
||||||
|
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
|
||||||
|
if (!current()) { await TrackPlayer.pause(); return; }
|
||||||
|
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
|
||||||
|
else if (this.state.playing) await TrackPlayer.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const player = new Controller();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { uuidv7 } from '@pipu/core';
|
||||||
|
|
||||||
|
/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */
|
||||||
|
export function eventId(now: number): string {
|
||||||
|
return uuidv7(now, () => {
|
||||||
|
const hex = globalThis.expo.uuidv4().replace(/-/g, '');
|
||||||
|
return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import { player, type PlayerState } from './controller';
|
||||||
|
|
||||||
|
export function usePlayerState(): PlayerState {
|
||||||
|
return useSyncExternalStore(player.subscribe, player.getState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { player };
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Track } from '@pipu/core';
|
||||||
|
|
||||||
|
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
||||||
|
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
|
||||||
|
return tracks
|
||||||
|
.filter((t) => t.reaction !== 'dislike')
|
||||||
|
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
|
||||||
|
.sort((a, b) => a.key - b.key)
|
||||||
|
.map((x) => x.t);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import TrackPlayer, { Event } from 'react-native-track-player';
|
||||||
|
|
||||||
|
import { player } from './controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фоновый сервис: кнопки уведомления, экрана блокировки и гарнитуры. Работает и без UI —
|
||||||
|
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
|
||||||
|
*/
|
||||||
|
export async function PlaybackService(): Promise<void> {
|
||||||
|
TrackPlayer.addEventListener(Event.RemotePlay, () => player.play());
|
||||||
|
TrackPlayer.addEventListener(Event.RemotePause, () => player.pause());
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteStop, () => player.pause());
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
|
||||||
|
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
|
||||||
|
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
|
||||||
|
player.setInterrupted(paused || permanent);
|
||||||
|
if (permanent) void TrackPlayer.pause();
|
||||||
|
else if (paused) void TrackPlayer.pause();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import { tokens } from '@pipu/design-tokens';
|
import { tokens, type ThemeName } from '@pipu/design-tokens';
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
// Пока только тёмная тема; переключение тем — вместе с профилем (M5).
|
export type Colors = Record<keyof typeof tokens.color.dark, string>;
|
||||||
export const colors = tokens.color.dark;
|
|
||||||
|
/** Активная тема (dark / light); выбирается в профиле (`ui_prefs.theme`, «system» — по настройке телефона). */
|
||||||
|
export const ThemeContext = createContext<ThemeName>('dark');
|
||||||
|
|
||||||
|
export function useThemeName(): ThemeName {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColors(): Colors {
|
||||||
|
return tokens.color[useThemeName()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Цвета до входа и загрузки настроек (сплэш) — тёмная тема. */
|
||||||
|
export const darkColors: Colors = tokens.color.dark;
|
||||||
export const radius = tokens.radius;
|
export const radius = tokens.radius;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FriendRecommendations } from '../features/social/Social';
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||||
|
import { useColors } from '../theme';
|
||||||
|
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit';
|
||||||
|
|
||||||
|
type Options = components['schemas']['Onboarding'];
|
||||||
|
type Choice = components['schemas']['OnboardingChoice'];
|
||||||
|
|
||||||
|
function useOnboarding() {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
return useQuery({ queryKey: ['onboarding', server, user.id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||||
|
staleTime: 0, refetchInterval: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
|
const query = useOnboarding();
|
||||||
|
const client = useQueryClient();
|
||||||
|
if (query.isPending) return <Screen><ActivityIndicator /></Screen>;
|
||||||
|
if (!query.data?.required) return <>{children}</>;
|
||||||
|
return <Screen><ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<Onboarding options={query.data} onDone={() => void client.invalidateQueries({ queryKey: ['onboarding'] })} />
|
||||||
|
</ScrollView></Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecommendationSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const query = useOnboarding();
|
||||||
|
const client = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const errorText = useErrorText();
|
||||||
|
return <View>
|
||||||
|
<FriendRecommendations />
|
||||||
|
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
|
||||||
|
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
|
||||||
|
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
|
||||||
|
<ErrorBox>{query.error ? errorText(query.error) : null}</ErrorBox>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [choice, setChoice] = useState<Choice>(options.choice ?? { artist_ids: [], genres: [], gender: null, age_group: null });
|
||||||
|
const save = useMutation({ mutationFn: async (skip: boolean) => unwrap(await session.client.PUT('/api/v1/me/onboarding', {
|
||||||
|
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : choice,
|
||||||
|
})), onSuccess: onDone });
|
||||||
|
const toggle = (key: 'artist_ids' | 'genres', value: string) => setChoice((old) => ({ ...old,
|
||||||
|
[key]: old[key].includes(value) ? old[key].filter((x) => x !== value) : [...old[key], value],
|
||||||
|
}));
|
||||||
|
return <View style={{ gap: 20 }}>
|
||||||
|
<H1>{t('onboarding.title')}</H1><Muted>{t('onboarding.subtitle')}</Muted>
|
||||||
|
<Choices title={t('onboarding.gender')} items={[{ value: '', label: t('onboarding.unspecified') }, ...GENDERS.map((g) => ({ value: g, label: t(`onboarding.genders.${g}`) }))]}
|
||||||
|
selected={[choice.gender ?? '']} onPick={(v) => setChoice((old) => ({ ...old, gender: (v || null) as Choice['gender'] }))} />
|
||||||
|
<Choices title={t('onboarding.age_group')} items={[{ value: '', label: t('onboarding.unspecified') }, ...AGE_GROUPS.map((g) => ({ value: g, label: t(`onboarding.ages.${g}`) }))]}
|
||||||
|
selected={[choice.age_group ?? '']} onPick={(v) => setChoice((old) => ({ ...old, age_group: (v || null) as Choice['age_group'] }))} />
|
||||||
|
<Muted>{t('onboarding.age_hint')}</Muted>
|
||||||
|
<Choices title={t('onboarding.artists')} items={options.artists.map((a) => ({ value: a.id, label: a.name }))} selected={choice.artist_ids} onPick={(v) => toggle('artist_ids', v)} />
|
||||||
|
<Choices title={t('onboarding.genres')} items={options.genres.map((g) => ({ value: g.name, label: g.name }))} selected={choice.genres} onPick={(v) => toggle('genres', v)} />
|
||||||
|
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||||
|
<Button title={t(editing ? 'settings.save' : 'onboarding.done')} busy={save.isPending} onPress={() => save.mutate(false)} />
|
||||||
|
<Button title={t(editing ? 'form.cancel' : 'onboarding.skip')} variant="ghost" disabled={save.isPending} onPress={() => editing ? onDone() : save.mutate(true)} />
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Choices({ title, items, selected, onPick }: { title: string; items: { value: string; label: string }[]; selected: string[]; onPick: (value: string) => void }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return <View style={{ gap: 10 }}><Text style={{ color: colors.text, fontWeight: '600' }}>{title}</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>{items.map((item) => {
|
||||||
|
const on = selected.includes(item.value);
|
||||||
|
return <Pressable key={item.value} accessibilityRole="button" accessibilityState={{ selected: on }} onPress={() => onPick(item.value)}
|
||||||
|
style={{ padding: 12, borderRadius: 20, backgroundColor: on ? colors['accent-fill'] : colors.surface2 }}>
|
||||||
|
<Text style={{ color: on ? '#fff' : colors.text }}>{item.label}</Text>
|
||||||
|
</Pressable>;
|
||||||
|
})}</View>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
|
import { releaseNotesText } from '@pipu/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession, useSessionCtx } from '../lib/session';
|
||||||
|
import { updater } from '../lib/update';
|
||||||
|
import { radius, useColors } from '../theme';
|
||||||
|
import { Button } from './kit';
|
||||||
|
|
||||||
|
export function useUpdateState() {
|
||||||
|
return useSyncExternalStore(updater.subscribe, () => updater.state);
|
||||||
|
}
|
||||||
|
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
||||||
|
export function UpdateDialog() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const st = useUpdateState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void updater.check(session);
|
||||||
|
const sub = AppState.addEventListener('change', (s) => {
|
||||||
|
if (s === 'active') void updater.check(session);
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
if (st.status === 'idle' || st.status === 'checking' || st.status === 'latest') return null;
|
||||||
|
if (st.status === 'available' && st.dismissed) return null;
|
||||||
|
|
||||||
|
const { release, required } = st;
|
||||||
|
const busy = st.status === 'downloading' || st.status === 'verifying';
|
||||||
|
const notes = releaseNotesText(release.notes_md);
|
||||||
|
const action = () => void updater.downloadAndInstall(session, server ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal transparent animationType="fade" visible onRequestClose={() => updater.dismiss()}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: 20,
|
||||||
|
gap: 12,
|
||||||
|
maxHeight: '80%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }}>
|
||||||
|
{t('mobile.update_title', { version: release.version })}
|
||||||
|
</Text>
|
||||||
|
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
|
||||||
|
{notes !== '' && (
|
||||||
|
<ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
|
||||||
|
<Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>
|
||||||
|
{t('mobile.update_size', {
|
||||||
|
size: (release.size / 1048576).toFixed(1),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{st.status === 'downloading' && (
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: colors.surface3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
width: `${Math.round(st.progress * 100)}%`,
|
||||||
|
backgroundColor: colors.accent2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 12 }}>
|
||||||
|
{t('mobile.update_downloading', {
|
||||||
|
percent: Math.round(st.progress * 100),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{st.status === 'verifying' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_verifying')}</Text>}
|
||||||
|
{st.status === 'error' && (
|
||||||
|
<Text style={{ color: colors.danger }}>
|
||||||
|
{t(st.error === 'bad_checksum' ? 'mobile.update_bad_checksum' : 'mobile.update_failed')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{st.status === 'ready' && (
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_allow')}</Text>
|
||||||
|
<Pressable onPress={() => void updater.openInstallSettings()} accessibilityRole="link">
|
||||||
|
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.update_open_settings')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button title={t('mobile.update_install')} icon="system-update" busy={busy} onPress={action} />
|
||||||
|
{!required && !busy && <Button title={t('mobile.update_later')} variant="ghost" onPress={() => updater.dismiss()} />}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Track } from '@pipu/core';
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ActivityIndicator, Pressable } from 'react-native';
|
||||||
|
|
||||||
|
import { downloads, type DownloadItem, type DownloadsState } from '../offline/downloads';
|
||||||
|
import { useColors } from '../theme';
|
||||||
|
import { Icon } from './kit';
|
||||||
|
|
||||||
|
export function useDownloads(): DownloadsState {
|
||||||
|
return useSyncExternalStore(downloads.subscribe, () => downloads.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownloadItem(trackId: string): DownloadItem | undefined {
|
||||||
|
return useSyncExternalStore(downloads.subscribe, () => downloads.state.items.get(trackId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кнопка «скачать / скачано» у трека: повторное нажатие удаляет загрузку. */
|
||||||
|
export function DownloadButton({ track, size = 26 }: { track: Track; size?: number }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const item = useDownloadItem(track.id);
|
||||||
|
const busy = item?.status === 'queued' || item?.status === 'downloading';
|
||||||
|
const onPress = () => void (item ? downloads.remove([track.id]) : downloads.enqueue([track]));
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
hitSlop={10}
|
||||||
|
disabled={!track.available}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t(item ? 'mobile.dl_remove' : 'mobile.dl_download')}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.accent2} />
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
name={item?.status === 'done' ? 'download-done' : item?.status === 'error' ? 'error-outline' : 'download'}
|
||||||
|
size={size}
|
||||||
|
color={item?.status === 'done' ? colors.accent2 : item?.status === 'error' ? colors.danger : colors.text2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { ApiError, type Track } from '@pipu/core';
|
||||||
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
|
import { useState, useSyncExternalStore } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
type StyleProp,
|
||||||
|
type TextInputProps,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { useSessionCtx } from '../lib/session';
|
||||||
|
import { downloads, sizedCover } from '../offline/downloads';
|
||||||
|
import { absolute } from '../player/controller';
|
||||||
|
import { radius, useColors, type Colors } from '../theme';
|
||||||
|
|
||||||
|
export type IconName = ComponentProps<typeof MaterialIcons>['name'];
|
||||||
|
|
||||||
|
export function Icon({ name, size = 22, color }: { name: IconName; size?: number; color?: string }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return <MaterialIcons name={name} size={size} color={color ?? colors.text2} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Screen({ children, scroll = false, style }: { children: ReactNode; scroll?: boolean; style?: StyleProp<ViewStyle> }) {
|
||||||
|
const s = useStyles();
|
||||||
|
void scroll;
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[s.screen, style]} edges={['top', 'left', 'right']}>
|
||||||
|
{children}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function H1({ children }: { children: ReactNode }) {
|
||||||
|
const s = useStyles();
|
||||||
|
return <Text style={s.h1}>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Muted({ children, style }: { children: ReactNode; style?: StyleProp<ViewStyle> }) {
|
||||||
|
const s = useStyles();
|
||||||
|
return <Text style={[s.muted, style as object]}>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
secure,
|
||||||
|
...rest
|
||||||
|
}: TextInputProps & { label: string; error?: string; hint?: string; secure?: boolean }) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<Text style={s.label}>{label}</Text>
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
placeholderTextColor={colors.text3}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry={secure && !shown}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
style={[s.input, error ? { borderColor: colors.danger } : null, secure ? { paddingRight: 48 } : null]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{secure && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShown((v) => !v)}
|
||||||
|
style={s.reveal}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={shown ? t('form.hide_password') : t('form.show_password')}
|
||||||
|
>
|
||||||
|
<Icon name={shown ? 'visibility-off' : 'visibility'} size={20} color={colors.text3} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{(error ?? hint) && <Text style={{ color: error ? colors.danger : colors.text3, fontSize: 12 }}>{error ?? hint}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
title,
|
||||||
|
onPress,
|
||||||
|
variant = 'primary',
|
||||||
|
busy,
|
||||||
|
disabled,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onPress: () => void;
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
busy?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: IconName;
|
||||||
|
}) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
|
||||||
|
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: disabled || busy, busy }}
|
||||||
|
style={({ pressed }) => [s.button, { backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.75 : 1 }]}
|
||||||
|
>
|
||||||
|
{busy ? <ActivityIndicator color={fg} /> : icon ? <Icon name={icon} size={20} color={fg} /> : null}
|
||||||
|
<Text style={{ color: fg, fontWeight: '600', fontSize: 15 }}>{title}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBox({ children }: { children?: ReactNode }) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
if (!children) return null;
|
||||||
|
return (
|
||||||
|
<View style={s.error} accessibilityRole="alert">
|
||||||
|
<Text style={{ color: colors.danger, fontWeight: '500' }}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст ошибки API: перевод errors:<code>. */
|
||||||
|
export function useErrorText(): (err: unknown) => string {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
|
||||||
|
return (err) => {
|
||||||
|
const code = err instanceof ApiError ? err.code : 'common.internal';
|
||||||
|
const key = `errors:${code}`;
|
||||||
|
return i18n.exists(key) ? tr(key, err instanceof ApiError ? err.params : {}) : tr('errors:common.internal');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Cover({
|
||||||
|
album,
|
||||||
|
size,
|
||||||
|
round,
|
||||||
|
}: {
|
||||||
|
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
||||||
|
size: number;
|
||||||
|
round?: boolean;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const remote = server ? absolute(server, album.cover_url) : undefined;
|
||||||
|
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
||||||
|
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
||||||
|
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
|
||||||
|
const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
|
||||||
|
const bg = album.cover_colors?.[0] ?? colors.surface3;
|
||||||
|
return (
|
||||||
|
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{uri ? (
|
||||||
|
<Image source={{ uri }} style={{ width: size, height: size }} />
|
||||||
|
) : (
|
||||||
|
<Icon name={round ? 'person' : 'album'} size={size * 0.45} color="rgba(255,255,255,0.8)" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const sec = Math.max(0, Math.round(ms / 1000));
|
||||||
|
return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackRow({
|
||||||
|
track,
|
||||||
|
current,
|
||||||
|
onPress,
|
||||||
|
onLike,
|
||||||
|
numbered,
|
||||||
|
meta,
|
||||||
|
trailing,
|
||||||
|
}: {
|
||||||
|
track: Track;
|
||||||
|
current?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
onLike?: () => void;
|
||||||
|
numbered?: number;
|
||||||
|
meta?: string;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const liked = track.reaction === 'like';
|
||||||
|
const router = useRouter();
|
||||||
|
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={!track.available}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${track.title}, ${track.artists.map((a) => a.name).join(', ')}`}
|
||||||
|
style={({ pressed }) => [s.row, current && { backgroundColor: colors['accent-soft'] }, pressed && { backgroundColor: colors.hover }, !track.available && { opacity: 0.4 }]}
|
||||||
|
>
|
||||||
|
{numbered !== undefined ? (
|
||||||
|
<Text style={s.num}>{numbered}</Text>
|
||||||
|
) : (
|
||||||
|
<Cover album={track.album} size={44} />
|
||||||
|
)}
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text numberOfLines={1} style={[s.rowTitle, current && { color: colors.accent2 }]}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} style={s.rowSub}>
|
||||||
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
|
{meta ? ` · ${meta}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{onLike && (
|
||||||
|
<Pressable
|
||||||
|
onPress={onLike}
|
||||||
|
hitSlop={10}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t(liked ? 'player.unlike' : 'player.like')}
|
||||||
|
>
|
||||||
|
<Icon name={liked ? 'favorite' : 'favorite-border'} size={20} color={liked ? colors.accent2 : colors.text3} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
{trailing}
|
||||||
|
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
|
||||||
|
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
|
||||||
|
<Icon name="playlist-add" size={22} />
|
||||||
|
</Pressable>
|
||||||
|
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
||||||
|
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нижний лист: затемнение, заголовок, содержимое; закрывается кнопкой «назад» и тапом по фону. */
|
||||||
|
export function Sheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: ReactNode }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
|
||||||
|
<Pressable style={{ flex: 1, backgroundColor: colors.overlay }} onPress={onClose} accessibilityLabel={title} />
|
||||||
|
<SafeAreaView edges={['bottom']} style={{ backgroundColor: colors.surface, borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg }}>
|
||||||
|
<View style={{ padding: 20, gap: 16 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }} accessibilityRole="header">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||||
|
<Icon name="close" color={colors.text2} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Segmented<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
}) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={s.segmented} accessibilityRole="tablist">
|
||||||
|
{options.map((o) => (
|
||||||
|
<Pressable
|
||||||
|
key={o.value}
|
||||||
|
onPress={() => onChange(o.value)}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{ selected: value === o.value }}
|
||||||
|
style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = new Map<Colors, ReturnType<typeof makeStyles>>();
|
||||||
|
|
||||||
|
/** Стили кита под активную тему (создаются один раз на тему). */
|
||||||
|
function useStyles() {
|
||||||
|
const colors = useColors();
|
||||||
|
let st = styles.get(colors);
|
||||||
|
if (!st) styles.set(colors, (st = makeStyles(colors)));
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeStyles = (colors: Colors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
screen: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
h1: { color: colors.text, fontSize: 26, fontWeight: '700', letterSpacing: -0.3 },
|
||||||
|
muted: { color: colors.text2, lineHeight: 21 },
|
||||||
|
label: { color: colors.text2, fontSize: 13, fontWeight: '600' },
|
||||||
|
input: {
|
||||||
|
height: 48,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line2,
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
reveal: { position: 'absolute', right: 6, top: 6, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
button: { height: 48, borderRadius: radius.md, flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },
|
||||||
|
error: { padding: 12, borderRadius: 10, backgroundColor: colors['danger-soft'] },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8, minHeight: 60 },
|
||||||
|
num: { width: 44, textAlign: 'center', color: colors.text3, fontVariant: ['tabular-nums'] },
|
||||||
|
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
|
||||||
|
rowSub: { color: colors.text2, fontSize: 13, marginTop: 2 },
|
||||||
|
duration: { color: colors.text3, fontSize: 12, fontVariant: ['tabular-nums'] },
|
||||||
|
segmented: { flexDirection: 'row', gap: 2, padding: 3, borderRadius: 10, backgroundColor: colors.surface2, alignSelf: 'flex-start' },
|
||||||
|
segment: { paddingHorizontal: 14, height: 32, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ApiError, unwrap } from '@pipu/core';
|
||||||
|
import { useState, useSyncExternalStore } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { QUALITIES, qualityPrefs, type Quality, type QualityPrefs } from '../lib/prefs';
|
||||||
|
import { useSession } from '../lib/session';
|
||||||
|
import { useColors } from '../theme';
|
||||||
|
import { Button, ErrorBox, Field, Segmented, Sheet, useErrorText } from './kit';
|
||||||
|
|
||||||
|
export function useQualityPrefs(): QualityPrefs {
|
||||||
|
return useSyncExternalStore(qualityPrefs.subscribe, qualityPrefs.get);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лист «Качество звука»: Wi-Fi / сотовая / загрузки. */
|
||||||
|
export function QualitySheet({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const prefs = useQualityPrefs();
|
||||||
|
const row = (key: keyof QualityPrefs) => (
|
||||||
|
<View style={{ gap: 8 }}>
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 13, fontWeight: '600' }}>{t(`mobile.quality_${key}`)}</Text>
|
||||||
|
<Segmented<Quality>
|
||||||
|
value={prefs[key]}
|
||||||
|
options={QUALITIES.map((q) => ({ value: q, label: t(`mobile.q_${q}`) }))}
|
||||||
|
onChange={(q) => void qualityPrefs.set({ [key]: q })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Sheet visible={visible} title={t('mobile.quality')} onClose={onClose}>
|
||||||
|
{row('wifi')}
|
||||||
|
{row('cellular')}
|
||||||
|
{row('download')}
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('mobile.quality_hint')}</Text>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лист смены пароля: после смены сервер выдаёт новую пару токенов, остальные сессии завершаются. */
|
||||||
|
export function PasswordSheet({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const mismatch = repeat !== '' && repeat !== next;
|
||||||
|
const fieldErr = (f: string) => (error instanceof ApiError && error.fieldCode(f) ? errorText(error) : undefined);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setRepeat('');
|
||||||
|
setError(null);
|
||||||
|
setDone(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const tokens = unwrap(await session.client.POST('/api/v1/me/password', { body: { current_password: current, new_password: next } }));
|
||||||
|
await session.apply(tokens);
|
||||||
|
setDone(true);
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setRepeat('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet visible={visible} title={t('settings.password')} onClose={close}>
|
||||||
|
{done ? (
|
||||||
|
<>
|
||||||
|
<Text style={{ color: colors.ok }}>{t('settings.password_changed')}</Text>
|
||||||
|
<Button title={t('form.close')} onPress={close} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field label={t('settings.current_password')} value={current} onChangeText={setCurrent} secure autoComplete="current-password" error={fieldErr('current_password')} />
|
||||||
|
<Field label={t('settings.new_password')} value={next} onChangeText={setNext} secure autoComplete="new-password" error={fieldErr('new_password')} />
|
||||||
|
<Field
|
||||||
|
label={t('settings.repeat_password')}
|
||||||
|
value={repeat}
|
||||||
|
onChangeText={setRepeat}
|
||||||
|
secure
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={mismatch ? t('form.passwords_mismatch') : undefined}
|
||||||
|
/>
|
||||||
|
<ErrorBox>{error && !fieldErr('current_password') && !fieldErr('new_password') ? errorText(error) : null}</ErrorBox>
|
||||||
|
<Button
|
||||||
|
title={t('settings.change_password')}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!current || !next || mismatch || repeat === ''}
|
||||||
|
onPress={() => void submit()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
|
||||||
|
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
||||||
|
import { useFocusEffect } from 'expo-router';
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { AppState, Pressable, View } from 'react-native';
|
||||||
|
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { useThemeName } from '../../theme';
|
||||||
|
import { drawLiquid, drawOrb } from './draw';
|
||||||
|
|
||||||
|
export interface WaveOrbProps {
|
||||||
|
variant: 'orb' | 'liquid';
|
||||||
|
playing: boolean;
|
||||||
|
bpm?: number;
|
||||||
|
energy?: number;
|
||||||
|
colors?: readonly string[];
|
||||||
|
size: number;
|
||||||
|
label?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Моя волна» на Skia (docs/design.md#3): в покое «дышит», при игре пульсирует в такт BPM с силой от energy,
|
||||||
|
* при нажатии сжимается с отскоком. Кадры считаются в UI-потоке; вне экрана и в фоне анимация стоит.
|
||||||
|
* Без `onPress` — декоративная визуализация (полноэкранный плеер в виде «волна»).
|
||||||
|
*/
|
||||||
|
export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, size, label, onPress, children }: WaveOrbProps) {
|
||||||
|
const dark = useThemeName() === 'dark';
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const [focused, setFocused] = useState(true);
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
return () => setFocused(false);
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||||
|
const palette = useMemo(() => wavePalette(colors), [colors]);
|
||||||
|
|
||||||
|
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
||||||
|
const state = useSharedValue<WaveState>(initialWaveState(size));
|
||||||
|
const pressT = useSharedValue(0);
|
||||||
|
// Новый объект на каждом кадре — пересчёт картинки (одно и то же число могло бы не вызвать его).
|
||||||
|
const time = useSharedValue({ ts: 0, dt: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
props.value = { variant, playing, bpm, energy, colors: palette, dark, reduced };
|
||||||
|
}, [variant, playing, bpm, energy, palette[0], palette[1], palette[2], dark, reduced]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = AppState.addEventListener('change', (st) => setForeground(st === 'active'));
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const frame = useFrameCallback((info) => {
|
||||||
|
time.value = { ts: info.timestamp, dt: Math.min(0.05, (info.timeSincePreviousFrame ?? 16) / 1000) };
|
||||||
|
}, false);
|
||||||
|
useEffect(() => {
|
||||||
|
frame.setActive(focused && foreground);
|
||||||
|
}, [focused, foreground]);
|
||||||
|
|
||||||
|
const S = size * 1.6;
|
||||||
|
const picture = useDerivedValue(() => {
|
||||||
|
const dt = time.value.dt;
|
||||||
|
const s = state.value;
|
||||||
|
Object.assign(s, props.value);
|
||||||
|
s.size = size;
|
||||||
|
if (pressT.value === 0 && s.pressT === 1) s.pressV -= 0.12; // отпустили — отскок
|
||||||
|
s.pressT = pressT.value;
|
||||||
|
step(s, dt);
|
||||||
|
const r = size * 0.5 * waveScale(s);
|
||||||
|
return createPicture(
|
||||||
|
(canvas) => {
|
||||||
|
if (s.variant === 'liquid') drawLiquid(canvas, S / 2, S / 2, r, s);
|
||||||
|
else drawOrb(canvas, S / 2, S / 2, r, s);
|
||||||
|
},
|
||||||
|
{ width: S, height: S },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const labelStyle = useAnimatedStyle(() => {
|
||||||
|
void time.value.ts; // пересчёт на каждом кадре
|
||||||
|
const s = state.value;
|
||||||
|
return { transform: [{ scale: waveScale(s) + s.pulse * 0.03 }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Canvas style={{ position: 'absolute', width: S, height: S, left: (size - S) / 2, top: (size - S) / 2 }} pointerEvents="none">
|
||||||
|
<Picture picture={picture} />
|
||||||
|
</Canvas>
|
||||||
|
<Animated.View style={[{ alignItems: 'center', justifyContent: 'center', gap: 4 }, labelStyle]} pointerEvents="none">
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
if (!onPress) return <View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">{body}</View>;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onPressIn={() => (pressT.value = 1)}
|
||||||
|
onPressOut={() => (pressT.value = 0)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityState={{ selected: playing }}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Отрисовка «Моей волны» на Skia — тот же алгоритм, что Canvas 2D в вебе (apps/web/src/widgets/wave/draw.ts).
|
||||||
|
// Функции — worklet'ы: кадр собирается в UI-потоке Reanimated, JS-поток не участвует.
|
||||||
|
import { mix, rgb, rgba, type Rgb, type WaveState } from '@pipu/core';
|
||||||
|
import { BlendMode, ClipOp, PaintStyle, Skia, TileMode, type SkCanvas, type SkPaint } from '@shopify/react-native-skia';
|
||||||
|
|
||||||
|
function radial(cx: number, cy: number, r0: number, r1: number, colors: string[], pos: number[]): SkPaint {
|
||||||
|
'worklet';
|
||||||
|
const p = Skia.Paint();
|
||||||
|
p.setAntiAlias(true);
|
||||||
|
p.setShader(
|
||||||
|
Skia.Shader.MakeTwoPointConicalGradient(
|
||||||
|
{ x: cx, y: cy },
|
||||||
|
r0,
|
||||||
|
{ x: cx, y: cy },
|
||||||
|
r1,
|
||||||
|
colors.map((c) => Skia.Color(c)),
|
||||||
|
pos,
|
||||||
|
TileMode.Clamp,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Градиент со смещённым центром (аналог createRadialGradient(x0, y0, 0, x1, y1, r)). */
|
||||||
|
function conical(x0: number, y0: number, x1: number, y1: number, r: number, colors: string[]): SkPaint {
|
||||||
|
'worklet';
|
||||||
|
const p = Skia.Paint();
|
||||||
|
p.setAntiAlias(true);
|
||||||
|
p.setShader(Skia.Shader.MakeTwoPointConicalGradient({ x: x0, y: y0 }, 0, { x: x1, y: y1 }, r, colors.map((c) => Skia.Color(c)), [0, 1], TileMode.Clamp));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawOrb(canvas: SkCanvas, cx: number, cy: number, r: number, s: WaveState) {
|
||||||
|
'worklet';
|
||||||
|
const t = s.t;
|
||||||
|
const c1 = rgb(s.colors[0]);
|
||||||
|
const c2 = rgb(s.colors[1]);
|
||||||
|
const c3 = rgb(s.colors[2]);
|
||||||
|
canvas.drawCircle(
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
r * 1.65,
|
||||||
|
radial(cx, cy, r * 0.6, r * 1.65, [rgba(c1, 0.32 + s.pulse * 0.35 + s.hover * 0.12), rgba(c2, 0.1 + s.pulse * 0.12), rgba(c1, 0)], [0, 0.5, 1]),
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
const clip = Skia.Path.Make();
|
||||||
|
clip.addCircle(cx, cy, r);
|
||||||
|
canvas.clipPath(clip, ClipOp.Intersect, true);
|
||||||
|
const bg = Skia.Paint();
|
||||||
|
bg.setColor(Skia.Color(s.dark ? '#060B1C' : '#0B1533'));
|
||||||
|
const box = Skia.XYWHRect(cx - r, cy - r, r * 2, r * 2);
|
||||||
|
canvas.drawRect(box, bg);
|
||||||
|
|
||||||
|
const blobs: Rgb[] = [c1, c2, c3, c1, c2];
|
||||||
|
for (let i = 0; i < blobs.length; i++) {
|
||||||
|
const c = blobs[i]!;
|
||||||
|
const a = t * (0.25 + i * 0.07) + i * 1.7;
|
||||||
|
const bx = cx + Math.cos(a) * r * (0.35 + 0.12 * Math.sin(t * 0.3 + i)) + s.mx * r * 0.18 * s.hover;
|
||||||
|
const by = cy + Math.sin(a * 1.3) * r * 0.38 + s.my * r * 0.18 * s.hover;
|
||||||
|
const br = r * (0.75 + 0.15 * Math.sin(t * 0.7 + i) + s.pulse * 0.2);
|
||||||
|
const g = radial(bx, by, 0, br, [rgba(c, 0.55), rgba(c, 0)], [0, 1]);
|
||||||
|
g.setBlendMode(BlendMode.Plus);
|
||||||
|
canvas.drawRect(box, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = 9;
|
||||||
|
for (let l = 0; l < lines; l++) {
|
||||||
|
const yy = cy - r * 0.75 + (l / (lines - 1)) * r * 1.5;
|
||||||
|
const amp = r * (0.03 + 0.05 * s.amp + 0.09 * s.pulse + 0.03 * s.hover);
|
||||||
|
const path = Skia.Path.Make();
|
||||||
|
for (let x = -r; x <= r; x += 4) {
|
||||||
|
const y = yy + Math.sin((x / r) * (3 + l * 0.4) + t * (1.2 + l * 0.15) + l) * amp * Math.cos((x / r) * 1.4);
|
||||||
|
if (x === -r) path.moveTo(cx + x, y);
|
||||||
|
else path.lineTo(cx + x, y);
|
||||||
|
}
|
||||||
|
const stroke = Skia.Paint();
|
||||||
|
stroke.setAntiAlias(true);
|
||||||
|
stroke.setStyle(PaintStyle.Stroke);
|
||||||
|
stroke.setStrokeWidth(1.5);
|
||||||
|
stroke.setColor(Skia.Color(`rgba(255,255,255,${0.05 + 0.05 * s.amp + (l % 3 === 0 ? 0.05 : 0)})`));
|
||||||
|
stroke.setBlendMode(BlendMode.Plus);
|
||||||
|
canvas.drawPath(path, stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawRect(box, conical(cx - r * 0.35, cy - r * 0.45, cx - r * 0.35, cy - r * 0.45, r * 0.9, ['rgba(255,255,255,0.18)', 'rgba(255,255,255,0)']));
|
||||||
|
canvas.restore();
|
||||||
|
|
||||||
|
const ring = Skia.Paint();
|
||||||
|
ring.setAntiAlias(true);
|
||||||
|
ring.setStyle(PaintStyle.Stroke);
|
||||||
|
ring.setStrokeWidth(1);
|
||||||
|
ring.setColor(Skia.Color(rgba(mix(c1, [255, 255, 255], 0.4), 0.35 + s.pulse * 0.3)));
|
||||||
|
canvas.drawCircle(cx, cy, r - 0.5, ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawLiquid(canvas: SkCanvas, cx: number, cy: number, r: number, s: WaveState) {
|
||||||
|
'worklet';
|
||||||
|
const t = s.t;
|
||||||
|
const cols: Rgb[] = [rgb(s.colors[0]), rgb(s.colors[1]), rgb(s.colors[2])];
|
||||||
|
canvas.drawCircle(cx, cy, r * 1.6, radial(cx, cy, r * 0.4, r * 1.6, [rgba(cols[0]!, 0.3 + s.pulse * 0.3), rgba(cols[0]!, 0)], [0, 1]));
|
||||||
|
for (let L = 0; L < 3; L++) {
|
||||||
|
const path = Skia.Path.Make();
|
||||||
|
const P = 140;
|
||||||
|
for (let j = 0; j <= P; j++) {
|
||||||
|
const th = (j / P) * Math.PI * 2;
|
||||||
|
const w =
|
||||||
|
1 +
|
||||||
|
0.06 * Math.sin(3 * th + t * (0.9 + L * 0.2) + L * 2) +
|
||||||
|
0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L) +
|
||||||
|
0.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp) +
|
||||||
|
0.08 * s.pulse * Math.sin(2 * th + L) +
|
||||||
|
0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3);
|
||||||
|
const rr = r * (0.86 - L * 0.07) * w;
|
||||||
|
const x = cx + Math.cos(th) * rr;
|
||||||
|
const y = cy + Math.sin(th) * rr;
|
||||||
|
if (j) path.lineTo(x, y);
|
||||||
|
else path.moveTo(x, y);
|
||||||
|
}
|
||||||
|
path.close();
|
||||||
|
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3;
|
||||||
|
const oy = Math.sin(t * 0.5 + L) * r * 0.3;
|
||||||
|
const c = cols[L]!;
|
||||||
|
const g = conical(cx + ox, cy + oy, cx, cy, r * 1.1, [rgba(mix(c, [255, 255, 255], 0.25), 0.85), rgba(c, 0.25)]);
|
||||||
|
g.setAlphaf(s.dark ? 0.6 : 0.75);
|
||||||
|
g.setBlendMode(s.dark ? BlendMode.Plus : BlendMode.SrcOver);
|
||||||
|
canvas.drawPath(path, g);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { AuthSession, Track } from '@pipu/core';
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@react-native-community/netinfo', () => ({ default: {
|
||||||
|
fetch: async () => ({ type: 'wifi' }), addEventListener: () => () => {},
|
||||||
|
} }));
|
||||||
|
vi.mock('../src/lib/prefs', () => ({
|
||||||
|
qualityPrefs: { load: async () => ({ wifi: 'high', cellular: 'medium' }) },
|
||||||
|
playerCache: { clearIfRequested: async () => {} },
|
||||||
|
}));
|
||||||
|
vi.mock('../src/offline/events', () => ({ PersistentEvents: class {} }));
|
||||||
|
vi.mock('../src/offline/downloads', () => ({
|
||||||
|
downloads: { isDownloaded: () => false, localTrack: () => undefined, localCover: () => undefined, downloaded: () => [] },
|
||||||
|
sizedCover: (s: string) => s,
|
||||||
|
}));
|
||||||
|
vi.mock('react-native-track-player', () => ({
|
||||||
|
default: {
|
||||||
|
isServiceRunning: async () => true, setupPlayer: async () => {}, updateOptions: async () => {},
|
||||||
|
addEventListener: vi.fn(), reset: vi.fn().mockResolvedValue(undefined),
|
||||||
|
add: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined),
|
||||||
|
skip: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
AppKilledPlaybackBehavior: {}, Capability: {}, Event: {}, State: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function track(id: string): Track {
|
||||||
|
return { id, title: id, duration_ms: 1000, artists: [],
|
||||||
|
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||||
|
genres: [], disc_no: 1, track_no: 1, available: true, stream_url: `/s/${id}?sig=x`, reaction: null, features: null };
|
||||||
|
}
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (v: T) => void;
|
||||||
|
const promise = new Promise<T>((yes) => { resolve = yes; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
const reply = (id: string) => ({ data: { session_id: id, activity: null, mood: null, fallback: false, tracks: [track(id)] }, response: new Response() });
|
||||||
|
|
||||||
|
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
it('ignores a wave creation response after the player is reset', async () => {
|
||||||
|
const { player } = await import('../src/player/controller');
|
||||||
|
const native = (await import('react-native-track-player')).default;
|
||||||
|
const pending = deferred<ReturnType<typeof reply>>();
|
||||||
|
const post = vi.fn().mockReturnValue(pending.promise);
|
||||||
|
player.attach({ client: { POST: post } } as unknown as AuthSession, 'http://localhost', null);
|
||||||
|
const start = player.startWave(null, null);
|
||||||
|
await vi.waitFor(() => expect(post).toHaveBeenCalled());
|
||||||
|
await player.reset();
|
||||||
|
pending.resolve(reply('old'));
|
||||||
|
await start;
|
||||||
|
expect(player.getState().wave).toBeNull();
|
||||||
|
expect(player.getState().queue).toEqual([]);
|
||||||
|
expect(native.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a late context response replace a manually selected playlist', async () => {
|
||||||
|
const { player } = await import('../src/player/controller');
|
||||||
|
const pending = deferred<ReturnType<typeof reply>>();
|
||||||
|
const session = { client: {
|
||||||
|
POST: vi.fn().mockResolvedValue(reply('wave')),
|
||||||
|
PATCH: vi.fn().mockReturnValue(pending.promise), DELETE: vi.fn().mockResolvedValue({}),
|
||||||
|
} };
|
||||||
|
player.attach(session as unknown as AuthSession, 'http://localhost', null);
|
||||||
|
await player.startWave(null, null);
|
||||||
|
const change = player.setWaveContext('focus', null);
|
||||||
|
await player.playList([track('manual')], 0, { type: 'album', id: 'album' });
|
||||||
|
pending.resolve(reply('stale'));
|
||||||
|
await change;
|
||||||
|
expect(player.getState().wave).toBeNull();
|
||||||
|
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
|
||||||
|
expect(player.getState().context?.type).toBe('album');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restore a stale saved queue after manual playback starts', async () => {
|
||||||
|
const { player } = await import('../src/player/controller');
|
||||||
|
const saved = deferred<unknown>();
|
||||||
|
const get = vi.fn().mockReturnValue(saved.promise);
|
||||||
|
const post = vi.fn();
|
||||||
|
player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null);
|
||||||
|
const restoring = player.restore();
|
||||||
|
await vi.waitFor(() => expect(get).toHaveBeenCalled());
|
||||||
|
await player.playList([track('chosen')], 0, { type: 'queue' });
|
||||||
|
saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() });
|
||||||
|
await restoring;
|
||||||
|
expect(player.getState().track?.id).toBe('chosen');
|
||||||
|
expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything());
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { EventBuffer } from '@pipu/core';
|
||||||
|
import { eventId } from '../src/player/eventId';
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('queues restored-track events on Hermes without Web Crypto', () => {
|
||||||
|
vi.stubGlobal('crypto', undefined);
|
||||||
|
const nativeUuid = vi.fn()
|
||||||
|
.mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff')
|
||||||
|
.mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe');
|
||||||
|
vi.stubGlobal('expo', { uuidv4: nativeUuid });
|
||||||
|
const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId);
|
||||||
|
buffer.push({ type: 'play_start', track_id: 'restored-track' });
|
||||||
|
buffer.push({ type: 'skip', track_id: 'restored-track' });
|
||||||
|
expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff');
|
||||||
|
expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id);
|
||||||
|
expect(nativeUuid).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { releaseNeed } from '../src/lib/releaseNeed';
|
||||||
|
import { offlineWave } from '../src/player/offlineWave';
|
||||||
|
import type { Track } from '@pipu/core';
|
||||||
|
|
||||||
|
function track(id: string, reaction: Track['reaction']): Track {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
duration_ms: 1000,
|
||||||
|
artists: [],
|
||||||
|
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
|
||||||
|
genres: [],
|
||||||
|
disc_no: 1,
|
||||||
|
track_no: 1,
|
||||||
|
available: true,
|
||||||
|
stream_url: null,
|
||||||
|
reaction,
|
||||||
|
features: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('offline wave', () => {
|
||||||
|
it('drops dislikes and keeps likes ahead of the rest', () => {
|
||||||
|
const tracks = [track('d', 'dislike'), track('n', null), track('l', 'like')];
|
||||||
|
const order = offlineWave(tracks, () => 0.5).map((t) => t.id);
|
||||||
|
expect(order).toEqual(['l', 'n']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update dialog decision', () => {
|
||||||
|
it('marks an older install below the minimum as required', () => {
|
||||||
|
expect(releaseNeed(10, 12, 11)).toBe('required');
|
||||||
|
expect(releaseNeed(11, 12, 11)).toBe('optional');
|
||||||
|
expect(releaseNeed(12, 12, 11)).toBe('current');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Web и desktop UI
|
||||||
|
|
||||||
|
- Маршруты: `src/app/router.tsx`; сессия: `src/app/session.tsx`; страницы собирают возможности, `src/features` владеет сценариями.
|
||||||
|
- Воспроизведение: `src/player/player.ts` — фасад; адаптеры рядом. Общие правила очереди, событий и волны — `packages/core/src`.
|
||||||
|
- Загрузки: `src/features/uploads`; страница очереди — `src/pages/admin/UploadQueuePage.tsx`. Сохраняй query keys/invalidation, права и поведение диалогов.
|
||||||
|
- `pnpm --filter @pipu/web typecheck`; `pnpm --filter @pipu/web test`. Для локальной проверки можно передать путь теста после `test`.
|
||||||
|
- UI/навигация/диалоги: `pnpm --filter @pipu/web test:e2e`. Проверки используют fake API; реальная серверная авторизация покрывается Go отдельно.
|
||||||
|
- Restore не запускает музыку и не создаёт событие прослушивания. Ошибка preload не пропускает трек. Dispose должен освобождать обработчики.
|
||||||
@@ -5,9 +5,20 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
RUN npm install -g pnpm@11.9.0
|
RUN npm install -g pnpm@11.9.0
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
COPY . .
|
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
||||||
|
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает)
|
||||||
|
COPY patches/ patches/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/mobile/package.json apps/mobile/
|
||||||
|
COPY packages/api-client/package.json packages/api-client/
|
||||||
|
COPY packages/core/package.json packages/core/
|
||||||
|
COPY packages/design-tokens/package.json packages/design-tokens/
|
||||||
|
COPY packages/i18n/package.json packages/i18n/
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||||
pnpm install --frozen-lockfile --filter @pipu/web...
|
pnpm install --frozen-lockfile --filter @pipu/web...
|
||||||
|
COPY . .
|
||||||
# Сгенерированные клиенты и токены лежат в репозитории (их актуальность проверяет CI), поэтому generate здесь не нужен.
|
# Сгенерированные клиенты и токены лежат в репозитории (их актуальность проверяет CI), поэтому generate здесь не нужен.
|
||||||
RUN pnpm --filter @pipu/web build
|
RUN pnpm --filter @pipu/web build
|
||||||
|
|
||||||
|
|||||||