Compare commits

..
37 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 421b1a3eb9 ci: allow manual runs of the db and mobile checks
ci / db / db (push) Successful in 54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 56s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:18:11 +03:00
mrleo1nidandClaude Opus 5.5 b8243c3072 docs(roadmap): M6 and M7 status
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:59 +03:00
mrleo1nidandClaude Opus 5.5 30f063456e fix(mobile): white text sits on the contrast-safe accent fill (M7)
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 7s
ci / ts / ts (packages + web) (push) Successful in 49s
Primary buttons, selected segments and context chips use accent-fill, as on
the web, so their labels meet WCAG AA contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:38 +03:00
mrleo1nidandClaude Opus 5.5 de4e59d9f8 fix(web): accessibility pass to WCAG 2.1 AA (M7)
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 31s
ci / ts / ts (packages + web) (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 44s
An axe-core audit of every screen in both themes found low contrast on white
text over the accent and on caption text, track rows using row roles without
cells, and unlabelled progress bars. Accent fills now use a darker token,
caption text meets 4.5:1, track lists are lists and progress bars are labelled.
The queue drawer slides in from the right as designed, takes focus, closes on
Escape and notes that the wave never ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:03 +03:00
mrleo1nidandClaude Opus 5.5 c0e50bf88e docs: administrator guide (M7)
Day-to-day operation after install: users and roles, storages and scans, the
upload queue, wave metrics and training, updates, backups and restore,
monitoring and common situations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:02:54 +03:00
mrleo1nidandClaude Opus 5.5 86c3e4e477 test(load): k6 smoke for 100 wave listeners and 20 rps suggest (M7)
Three minutes of wave sessions with stream starts, play events and next batches
alongside a steady suggest load; thresholds follow the documented p95 targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:01:10 +03:00
mrleo1nidandClaude Opus 5.5 aacf55679a feat(deploy): nightly database backups with verified restore (M7)
ci / db / db (push) Failing after 52s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m3s
A backup container dumps the database every night, keeps 14 daily and 8 weekly
copies and once a week restores a copy into a scratch database to check tables
and row counts. update.sh takes a copy before updating, restore.sh brings one
back and keeps the old database aside. CI runs the dump, verify and restore
cycle on the database the end-to-end journey filled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:59:27 +03:00
mrleo1nidandClaude Opus 5.5 d91ff89c64 feat(admin): wave metrics split by model version (M6)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m5s
ci / ts / ts (packages + web) (push) Successful in 1m3s
ci / api / api (push) Successful in 29s
ci / db / db (push) Failing after 12s
The library page compares listen-through, early-skip and like rates per
impression model version, so v1 and the trained ranker can be compared online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:57:11 +03:00
mrleo1nidandClaude Opus 5.5 fbbe5d7528 feat(search): personalize suggestions by listening history (M6)
ci / api / api (push) Successful in 18s
ci / db / db (push) Successful in 30s
Suggest fetches three times as many candidates and reranks them in the API:
what the user plays, finishes and likes moves up a few positions and what they
dislike moves down, with Meilisearch relevance still the base order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:55:07 +03:00
mrleo1nidandClaude Opus 5.5 e250837de9 fix(search): reindex on a fresh Meilisearch
ci / db / db (push) Successful in 34s
release / release (версия и заметки) (push) Successful in 5s
ci / api / api (push) Successful in 19s
release / android (APK в релиз) (push) Successful in 3m17s
Deleting a missing <uid>_next index is accepted with 202 and fails inside the
task with index_not_found. That error was not treated as not-found, so the first
reindex aborted and search stayed unavailable until indexes existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:44:59 +03:00
mrleo1nidandClaude Opus 5.5 58bd14ef38 feat(recommender): LightGBM lambdarank ranker with impression features (M6)
ci / db / db (push) Failing after 1m15s
ci / api / api (push) Successful in 17s
ci / recommender / recommender (push) Successful in 32s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m41s
The wave logs each served candidate's feature vector to rec_impression_features.
Nightly training joins them with impression outcomes, validates on the last week
and activates the model only when it beats the v1 order and the active model.
The service loads the active ranker and uses its order in place of the v1 score.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:41:26 +03:00
mrleo1nidandClaude Opus 5.5 fb07f962eb feat: taste onboarding for new listeners (M6)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Failing after 1m29s
ci / ts / ts (packages + web) (push) Successful in 49s
ci / api / api (push) Successful in 1m25s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 48s
ci / recommender / recommender (push) Successful in 21s
- GET /onboarding offers the library's top artists (with album covers)
  and genres and says whether to show it (not done and < 10 plays);
  PUT /me/onboarding stores the choice or a skip (migration 00009).
- The recommender seeds the taste from it while the user has fewer than
  30 scored tracks: chosen artists' tracks +1, chosen genres +0.5.
- Web: onboarding replaces the wave on the home page until done or
  skipped; artist grid with covers, genre chips, at least 3 artists.
- e2e journey and the recommender DB test cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:35:43 +03:00
mrleo1nidandClaude Opus 5.5 ff0aafdb1e feat(recommender): Thompson-sampling bandit over candidate sources (M6)
ci / recommender / recommender (push) Failing after 4s
ci / db / db (push) Failing after 2m21s
Nightly Beta(α, β) per user and source from wave impressions (reward:
complete or like, ×0.99 daily decay) stored in user_profiles_rec.bandit;
the wave adds 0.2·(θ − 0.5) per candidate source, sources without data
stay neutral. The user's exploration share now follows how well `fresh`
does against the other sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:31:24 +03:00
mrleo1nidandClaude Opus 5.5 f7722e28ad ci(release): rebalance APK build memory so it does not thrash and get OOM-killed
release / android (APK в релиз) (push) Successful in 3m19s
release / release (версия и заметки) (push) Successful in 8s
v0.7.3 crawled for 35 minutes at the 6 GB cap and was killed without a
log. The container now gets 7 GB, the Gradle heap is 2 GB, the Metro
node process is capped at 1.5 GB and the job times out after 60 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:12:54 +03:00
mrleo1nidandClaude Opus 5.5 ed0302d90f docs(uploads): import writes only the final track file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:11:56 +03:00
mrleo1nidandClaude Opus 5.5 85fceaa686 fix(storage-agent): write nothing but the music files to the mounted storage
ci / api / api (push) Successful in 50s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Failing after 10s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 31m43s
ci / db / db (push) Failing after 19s
- Imported tracks are created directly under their final name with
  O_EXCL (never overwriting; " (2)" on conflict), fsync'ed and checked
  against SHA-256 — no hidden .pipu-tmp file next to them any more.
- On any failure the agent removes its own partial file and the empty
  directories it created for it.
- The write check uses access(W_OK) and the read-only mount flag instead
  of creating and deleting .pipu-write-test in the storage root.
- Tests assert the storage tree holds only audio files after successful
  and failed writes and after the write check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:22:49 +03:00
mrleo1nidandClaude Opus 5.5 6ad49fe0e3 fix(wave): switch to the new context right away when activity or mood changes
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
ci / ts / ts (packages + web) (push) Successful in 1m47s
release / release (версия и заметки) (push) Successful in 12s
release / android (APK в релиз) (push) Failing after 34m58s
Changing the activity or mood used to keep the current track playing
and only replace the upcoming ones, so nothing seemed to happen. Now the
first track of the new batch starts immediately (web and Android). The
interrupted track is not reported to the wave as a skip and is recorded
in history only if it played for at least 30 s, so a context change does
not teach the recommender a false dislike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:18:26 +03:00
mrleo1nidandClaude Opus 5.5 7cf741d82f ci(release): recover from a corrupted Gradle cache instead of failing
release / release (версия и заметки) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 9m5s
A hard reboot of the runner VM mid-build left broken transform metadata
in the persistent Gradle cache ("Could not read workspace metadata").
The APK step now detects it, drops the transforms cache and retries the
build once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:04:33 +03:00
mrleo1nidandClaude Opus 5.5 4fb4e124a0 ci(release): cap the Android build so it cannot freeze the runner host
release / android (APK в релиз) (push) Failing after 2m4s
release / release (версия и заметки) (push) Successful in 9s
The APK job container now runs with --cpus=3 --memory=6g and no swap;
CMake / ninja for the React Native native code are limited to 2 jobs
(they used every core), and Gradle runs under nice / ionice. Notes for
hypervisor-hosted runners (Proxmox) are in docs/ci-cd.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:59:46 +03:00
mrleo1nidandClaude Opus 5.5 4cca427b82 test(e2e): log service warnings and give search indexing more time
ci / api / api (push) Successful in 33s
ci / db / db (push) Failing after 1m34s
release / release (версия и заметки) (push) Successful in 11s
release / android (APK в релиз) (push) Canceled after 29m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:29:17 +03:00
mrleo1nidandClaude Opus 5.5 2285107d04 test: end-to-end user journey in CI; fix ALS vectors for small libraries
ci / api / api (push) Successful in 30s
ci / recommender / recommender (push) Successful in 30s
ci / db / db (push) Failing after 1m8s
- newApp (extracted from main) is the exact production wiring; the
  `ci / db` job now also runs it on PostgreSQL, Redis and Meilisearch
  and walks the main user flows over HTTP: first admin sign-in and
  credential change, local source and scan, search, album / artist /
  top tracks, signed stream, events and history, likes, fallback wave,
  playback state, upload queue up to review.
- ALS on a small library produced fewer than 64 factors and the insert
  into vector(64) failed; vectors are now zero-padded to the column size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:26:36 +03:00
mrleo1nidandClaude Opus 5.5 f5c276092f ci: integration tests on PostgreSQL + pgvector for migrations and store SQL
ci / db / db (push) Failing after 53s
ci / recommender / recommender (push) Successful in 36s
ci / api / api (push) Successful in 34s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m9s
New `ci / db` job with a pgvector service: Go applies all migrations,
rolls them back to zero and applies them again, then runs the scanner,
fingerprint, write-template, top-tracks and upload-queue store queries;
Python runs the analysis worker, catalog / profile loading and a full
ALS training run against the same schema. Until now this SQL ran
nowhere before reaching a real server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:18:44 +03:00
mrleo1nidandClaude Opus 5.5 1c4e209e86 feat(web): align the web UI with the design prototype
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Successful in 1m39s
- Ambient background in the app shell: three drifting blurred blobs,
  the third one tinted with the current track's cover; can be turned off
  in settings and stays still with reduced motion.
- Island player variant (floating glass panel) next to the bar, chosen
  in settings; player source line ("Album · …", "My Wave"), reactions
  around the transport controls, queue drawer.
- Sidebar: like / dislike counters, pending uploads badge on Queue,
  admin Settings page holding the upload queue limits.
- Collection: header tile with kicker, underline tabs, time / number
  lead column with an equalizer for the current track, album column,
  dimmed dislikes with undo, Play all / Shuffle pills.
- Album: cover-tinted header and Play / Shuffle; artist: artist wave,
  Play and popular tracks (new GET /artists/{id}/top-tracks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:13:24 +03:00
mrleo1nidandClaude Opus 5.5 3afed02f23 feat(recommender): nightly ALS with offline metrics and CF candidates in the wave (M6)
ci / recommender / recommender (push) Successful in 37s
- Implicit scores from play events and reactions per the signals table
  (90-day half-life, weaker skips outside the wave, dislikes override).
- numpy ALS for implicit feedback; time-split evaluation on the last
  7 days (NDCG@10, Recall@50) against a popularity baseline; a new model
  is activated only if NDCG@10 does not regress by more than 2%.
- Vectors are written to track_embeddings.cf and user_profiles_rec.taste_cf,
  versions to model_versions; NightlyTrainer runs at TRAIN_HOUR and on
  first start, `pipu-rec train` runs it manually.
- Wave: cf and item_cf candidate sources and a 0.10 cf term in the v1
  score; impressions are tagged heuristic-v1+cf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:54:25 +03:00
mrleo1nidandClaude Opus 5.5 a3f212d253 feat(web): suggest tracks, my uploads and admin upload queue screens (M5.5)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m4s
ci / ts / ts (packages + web) (push) Successful in 1m23s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m51s
- Suggest tracks: drag-and-drop of files and folders with relative
  paths, streaming SHA-256 in a Web Worker (hash-wasm), duplicate
  precheck, batch reservation and tus-js-client upload (8 MB chunks,
  4 in parallel, token refresh on 401, resume of failed files).
- My uploads: statuses, rejection reasons, withdraw, quota meter.
- Upload queue (admin): status tabs, possible-duplicate filter, batches,
  metadata and storage editing, bulk approve / reject with a reason,
  import retry, and the queue settings form.
- Sidebar section for moderators and admins; ru / en strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:46:18 +03:00
mrleo1nidandClaude Opus 5.5 ca106e8eb1 feat(api): upload queue with quota, tus intake, processing and import (M5.5)
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / ts / ts (packages + web) (push) Successful in 1m38s
- Migration 00008: upload_batches, upload_files, upload_quota.
- Quota reserved under a row lock when a batch is created; every status
  change goes through one transition that frees space in the same
  transaction. SHA-256 duplicates are rejected before any upload.
- tus 1.0.0 core (HEAD/PATCH) for the author only; offset is the real
  staging file size, data beyond the declared size is refused (ADR-015).
- Processing worker: ffprobe checks, SHA-256 verification, exact and
  Chromaprint duplicates against library and queue, metadata from tags
  or the dropped folder path, possible_duplicate on metadata match, and
  the target path from write_template reusing existing folder spelling.
- Import worker: edited tags written with ffmpeg stream copy, file
  written by storage-agent, point scan of the new file, imported status;
  idempotent and resumed after restart. Stale uploads expire after 24 h,
  orphan staging files are removed at startup.
- API: quota, precheck, batches, my uploads, withdraw, admin list,
  metadata edit, bulk review, retry and queue settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:23:05 +03:00
mrleo1nidandClaude Opus 5.5 f66be2ba0a feat: storage writes via storage-agent, accepts_uploads and write_template (M5.5)
ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s
- storage-agent: POST /internal/files copies a staged file into a source
  (temp file, fsync, SHA-256 check, hard link so nothing is overwritten,
  " (2)" suffix on conflict, owner PIPU_FILE_UID/GID) and
  POST /internal/files/test. NFS sources that accept uploads are mounted
  rw in an agent-only /library-rw and bind-mounted read-only for others;
  local sources are written through /library-host-rw.
- meta: write templates ({track:02}, {disc:1-}, [optional]) with name
  sanitising and length limits, suggestion from the source's regexes
  and a reversibility check against 50 catalogue tracks.
- API and admin UI: accepts_uploads / write_template on sources,
  write-template suggest and check endpoints, write test result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:46:56 +03:00
mrleo1nidandClaude Opus 5.5 9b50038bb4 feat(api): SHA-256 and Chromaprint fingerprints for the library (M5.5)
ci / api / api (push) Successful in 27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 58s
A background Fingerprinter runs after every scan and at startup, one file
at a time, filling media_files.sha256 and track_fingerprints (fpcalc raw,
first 120 s). Without fpcalc only SHA-256 is computed and files stay
queued for Chromaprint. Changing a file's content resets its prints.
Similarity() compares raw prints with a ±80-point alignment search; it is
the basis for duplicate detection in the upload queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:37:05 +03:00
mrleo1nidandClaude Opus 5.5 ed063d3763 feat(mobile): Skia wave, wave player view, themes and playback settings (M5)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m30s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m57s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 33s
- My Wave is rendered with react-native-skia on the UI thread; physics
  and colour helpers moved to @pipu/core (wave-anim) and shared with web.
- Full-screen player honours ui_prefs.mobile_player (cover / wave).
- Theme switching (dark / light / system) via a theme context and
  useColors() across all screens.
- Profile: theme, wave variant, player view, sound quality sheet
  (Wi-Fi / mobile / downloads), recent-tracks cache clearing on next
  cold start, and a change-password sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:30:21 +03:00
mrleo1nidandClaude Opus 5.5 756e915ff9 feat(mobile): queue track reactions made offline and sync on reconnect (M5)
Likes and dislikes without a network are kept in a pending_reactions
table (last reaction per track wins) and sent when the connection
returns, instead of being rolled back. Downloaded track DTOs pick up the
new reaction so the offline wave and Downloads reflect it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:08:36 +03:00
mrleo1nidandClaude Opus 5.5 be34ee5361 feat(mobile): offline downloads, persistent event queue and offline start (M5)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / ts / ts (packages + web) (push) Canceled after 17m42s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s
- Downloads queue (2 parallel, Wi-Fi only by default) stores files under
  the app's document directory and track metadata in SQLite; Collection
  gets a Downloads tab, album and player screens get download buttons.
- Player plays downloaded files and local covers; My Wave falls back to
  a shuffle of downloaded tracks when the server is unreachable.
- Playback events that fail on the network are kept in SQLite and sent
  when the connection returns.
- AuthSession can open offline with the cached user and fetches an
  access token before the first request once online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:02:47 +03:00
mrleo1nidandClaude Opus 5.5 765345e0f4 feat(mobile): self-update dialog with verified APK download and install (M5)
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m33s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 35s
Checks the server for a newer release at most every 6 hours and on
demand in Profile, shows release notes, downloads the APK with progress,
verifies SHA-256 in 1 MB chunks and hands it to the system installer.
Updates below min_supported cannot be dismissed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:55:34 +03:00
mrleo1nidandClaude Opus 5.5 6a67c27f50 feat(api): Android self-update endpoints proxied from Gitea releases (M5)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 35s
ci / ts / ts (packages + web) (push) Successful in 1m20s
GET /api/v1/app/android/latest reads the latest Gitea release with the
server token (cached for 10 minutes); the download endpoint caches the
APK under /updates, verifies its SHA-256 and serves it with Range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:52:41 +03:00
mrleo1nidandClaude Opus 5.5 9fd616da57 feat(mobile): sign-in, tabs, search, collection and background player (M5)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m39s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 1m5s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 39s
- expo-router app in src/app: connect (server check, recent servers),
  login, mandatory credential / password change, tabs Home / Search /
  Collection / Profile, album and artist pages, full-screen player
- AuthSession per server with the refresh token in expo-secure-store
  (Android Keystore); language from the profile
- player on react-native-track-player: ExoPlayer queue with a
  notification and lock-screen controls via a playback service, 500 MB
  stream cache, quality by network (Wi-Fi high, cellular medium),
  play events with played time, reactions, playback state restore and
  the endless My Wave with feedback
- home: pulsing wave circle driven by BPM / energy in the cover
  palette, activity and mood chips that switch the wave on the fly
- New Architecture is off until react-native-track-player supports it;
  the release build runs codegen only when it is on; query-string is a
  direct dependency because expo-router 5.1 imports it undeclared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:48:55 +03:00
mrleo1nidandClaude Opus 5.5 eaedd1fc62 feat(web): My Wave button, mood picker and endless wave playback (M4)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 41s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Successful in 1m14s
ci / api / api (push) Successful in 36s
- WaveButton: TypeScript port of the prototype's canvas animation
  (orb and liquid), breathing at rest, pulsing to the track's BPM and
  energy in the cover palette, following the cursor, pressing with a
  spring; reduced motion and hidden tabs slow or stop it
- home: the wave in the centre with activity and mood chips; changing
  them while playing rebuilds the upcoming tracks without stopping
- player wave mode: asks for the next batch when two tracks are left,
  sends complete / skip / early skip / like / dislike as feedback and
  refreshes the queue right after a reaction or an early skip
- tracks expose bpm, energy and loudness once analysed; library page
  shows analysis progress and 7-day wave metrics; wave style setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:29:38 +03:00
mrleo1nidandClaude Opus 5.5 3908814cda feat(api): My Wave sessions with a Go fallback and wave metrics (M4)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 9s
ci / api / api (push) Successful in 35s
ci / ts / ts (packages + web) (push) Successful in 51s
- /wave/options, POST /wave/sessions (activity, mood, seed),
  /next with reactions, PATCH to switch the context on the fly, DELETE
- the recommender is called with an 800 ms budget; on failure the api
  serves a fallback wave of likes, favourite artists and random
  available tracks with the same dislike / recent / served filters
- every served track is an impression with its source and model; wave
  events (context_type wave) set its outcome, reactions win
- library status shows the analysis queue and 7-day wave metrics:
  listen-through, early skip and like rate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:23:17 +03:00
mrleo1nidandClaude Opus 5.5 ef29b85320 feat(rec): audio analyzer and My Wave v1 engine (M4)
ci / api / api (push) Successful in 27s
ci / recommender / recommender (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m26s
- migration 00006: track features and embeddings (pgvector, HNSW),
  analysis queue filled by a trigger on new tracks, wave sessions and
  impressions, recommender profiles, model versions
- analyzer basic-1 (ADR-014): ffmpeg decode, three 30 s excerpts,
  256-d timbre/rhythm embedding, tempo by onset autocorrelation, key,
  loudness, energy, danceability, acousticness, intro length; worker
  with SKIP LOCKED claims, retries and re-analysis on version change
- wave engine v1: candidates (session/taste similarity, fresh,
  liked artists, rediscover, popular, exploration, seed), filters
  (dislikes, played in 3 h, already served), heuristic score, activity
  and mood fit from contexts.yaml with relaxing thresholds, MMR with
  one track per artist, exploration share, session vector updated by
  reactions and a change of course after three skips
- POST /internal/wave/batch with the catalog index cached in memory
  and the session kept in Redis

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:20:55 +03:00
199 changed files with 24874 additions and 601 deletions

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
# Интеграционные проверки SQL на настоящем PostgreSQL + pgvector: миграции вверх / вниз / вверх,
# запросы сканера, отпечатков, шаблонов и очереди, сквозной сценарий пользователя через HTTP (Go),
# загрузка каталога, профиля и обучение ALS (Python).
name: ci / db
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
- 'services/api/**'
- 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml'
pull_request:
paths:
- 'services/api/**'
- 'services/recommender/**'
- 'deploy/backup.sh'
- '.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 }
- name: Go — миграции и запросы store
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/
- 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
# База уже наполнена сценариями выше — проверяем настоящий цикл копия → проверка → восстановление.
- name: Бэкап, проверка и восстановление (deploy/backup.sh)
env:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: pipu
PGDATABASE: pipu
BACKUP_DIR: /tmp/backups
VERIFY_EXACT: "1"
run: |
SUDO=$(command -v sudo || true)
$SUDO install -d /usr/share/postgresql-common/pgdg
$SUDO curl -fsSLo /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list >/dev/null
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq postgresql-client-17 >/dev/null
export PATH=/usr/lib/postgresql/17/bin:$PATH
before=$(psql -Atc "SELECT count(*) FROM tracks")
file=$(bash deploy/backup.sh once)
bash deploy/backup.sh verify "$file"
bash deploy/backup.sh restore "$file"
after=$(psql -Atc "SELECT count(*) FROM tracks")
echo "tracks: $before → $after"
test "$before" = "$after"
+1
View File
@@ -2,6 +2,7 @@
name: ci / mobile
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
+28 -2
View File
@@ -48,6 +48,8 @@ jobs:
android:
name: android (APK в релиз)
needs: release
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
timeout-minutes: 60
runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
@@ -58,6 +60,9 @@ jobs:
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
@@ -100,6 +105,11 @@ jobs:
# 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
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
@@ -109,12 +119,28 @@ jobs:
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
run: |
set -o pipefail
npx expo prebuild --platform android --clean --no-install
cd android
gradle=(./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
# 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" /tmp/gradle.log; then
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
./gradlew --stop || true
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
"${gradle[@]}" assembleRelease -x lintVitalRelease
else
exit 1
fi
fi
./gradlew --stop
- name: Диагностика сбоя (диск, память, OOM)
+1138 -3
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -12,7 +12,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
version,
orientation: 'portrait',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
// react-native-track-player 4 не поддерживает New Architecture (React Native Directory) — фоновое
// воспроизведение и уведомление важнее; вернём, когда плеер её поддержит (docs/mobile.md).
newArchEnabled: false,
backgroundColor: '#05070D',
android: {
package: 'app.pipu.music',
@@ -23,9 +25,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
platforms: ['android'],
plugins: [
// Маршруты — файлы в src/app (docs/mobile.md).
['expo-router', { root: './src/app' }],
'expo-secure-store',
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
// Android по умолчанию запрещает незашифрованный HTTP.
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
],
extra: { versionCode },
experiments: { typedRoutes: false },
});
+7 -3
View File
@@ -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';
+29 -1
View File
@@ -10,20 +10,48 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^14.1.0",
"@pipu/api-client": "workspace:*",
"@pipu/core": "workspace:*",
"@pipu/design-tokens": "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-build-properties": "~0.14.8",
"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",
"i18next": "^25.2.1",
"js-sha256": "^1.0.0",
"query-string": "^7.1.3",
"react": "19.0.0",
"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": {
"@types/node": "^22.20.5",
"@types/react": "~19.0.10"
},
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": [
"react-native-track-player"
],
"listUnknownPackages": false
}
}
}
}
-33
View File
@@ -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>
);
}
-122
View File
@@ -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 },
});
+148
View File
@@ -0,0 +1,148 @@
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 { UpdateDialog } from '../../ui/UpdateDialog';
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
export default function TabsLayout() {
const colors = useColors();
const { t } = useTranslation();
const labels: Record<string, string> = {
index: t('nav.home'),
search: t('mobile.search'),
collection: t('nav.collection'),
profile: t('mobile.profile'),
};
const net = useNetInfo();
return (
<>
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
<Tabs
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
tabBar={({ state, navigation }) => (
<View>
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
onPress={(name) => navigation.navigate(name)}
/>
</View>
)}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="profile" />
</Tabs>
<UpdateDialog />
</>
);
}
function TabBar({
routes,
active,
labels,
onPress,
}: {
routes: string[];
active: number;
labels: Record<string, string>;
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]}
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
>
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
<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>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { unwrap, type Track } from '@pipu/core';
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 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 = (
<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') },
]}
/>
);
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>
);
}
+133
View File
@@ -0,0 +1,133 @@
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 { 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() {
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>
);
}
+197
View File
@@ -0,0 +1,197 @@
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 { 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>
<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>
</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>
);
}
+142
View File
@@ -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>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack, useRouter, useSegments } from 'expo-router';
import { 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 segments = useSegments();
const router = useRouter();
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();
}, []);
useEffect(() => {
if (!ready) return;
const first = segments[0] as string | undefined;
let target: string | null = null;
if (!server) target = first === 'connect' ? null : '/connect';
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
if (target) router.replace(target as never);
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
if (!ready) return <Splash />;
return (
<ThemeContext.Provider value={theme}>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<Screens />
</ThemeContext.Provider>
);
}
function Screens() {
const colors = useColors();
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack>
);
}
+68
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { 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 prefs = useUser().ui_prefs;
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);
return (
<Screen>
<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>
<DownloadButton track={track} />
</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>
</View>
<View style={{ gap: 6 }}>
<Pressable
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
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 onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<Icon name="skip-previous" size={40} color={colors.text} />
</Pressable>
<Pressable
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 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>
);
}
+72
View File
@@ -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>
);
}
+67
View File
@@ -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);
},
};
+91
View File
@@ -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;
}
+81
View File
@@ -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);
}
+184
View File
@@ -0,0 +1,184 @@
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';
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();
if (release.version_code > installed) {
this.set({
status: 'available',
release,
required: installed < release.min_supported,
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();
+38
View File
@@ -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;
}
+289
View File
@@ -0,0 +1,289 @@
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(`${this.abs(track.album.cover_url)}&size=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;
}
}
export const downloads = new Downloads();
+120
View File
@@ -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 без ответа сервера
}
+442
View File
@@ -0,0 +1,442 @@
import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
PlayAttempt,
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 } from '../offline/downloads';
import { PersistentEvents } from '../offline/events';
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export interface PlayerState {
ready: boolean;
track: Track | null;
queue: Track[];
index: number;
playing: boolean;
buffering: boolean;
positionMs: number;
durationMs: number;
context: PlayContext | null;
wave: WaveInfo | null;
}
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_000;
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
return tracks
.filter((t) => t.reaction !== 'dislike')
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
.sort((a, b) => a.key - b.key)
.map((x) => x.t);
}
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
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 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 waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>();
private state: PlayerState = {
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 };
for (const fn of this.listeners) fn();
}
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
attach(session: AuthSession | null, server: string, userId: string | null) {
if (this.session === session) return;
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 }) : 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();
});
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 });
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 async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
return tracks.map((t) => ({
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
duration: t.duration_ms / 1000,
}));
}
async playList(tracks: Track[], index: number, context: PlayContext) {
const list = tracks.filter(playable);
const target = tracks[index];
if (!target || !list.length) return;
await this.setup();
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();
await TrackPlayer.add(await this.toRntp(list));
await TrackPlayer.skip(start);
await TrackPlayer.play();
}
toggle() {
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
}
next() {
void TrackPlayer.skipToNext().catch(() => undefined);
}
async prev() {
const { position } = await TrackPlayer.getProgress();
if (position > 3) await TrackPlayer.seekTo(0);
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
}
seek(ms: number) {
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.session) return;
await this.setup();
this.leaveWave();
let res;
try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
} catch (e) {
// Нет связи с сервером — офлайн-волна из скачанного.
const local = offlineWave(downloads.downloaded());
if (!local.length) throw e;
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
}
const tracks = res.tracks.filter(playable);
if (!tracks.length) return;
this.finishAttempt(this.lastPositionMs / 1000);
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) {
const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
await this.replaceUpcoming(res.tracks);
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;
if (!wave) return;
this.waveFeedback = [];
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) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
}
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
private async replaceUpcoming(tracks: Track[]) {
const keep = this.state.queue.slice(0, this.state.index + 1);
await TrackPlayer.removeUpcomingTracks();
const add = tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...keep, ...add] });
}
private async refillWave(refresh = false) {
const wave = this.state.wave;
if (!wave || !this.session || this.waveLoading) return;
const remaining = this.state.queue.length - this.state.index - 1;
if (!refresh && remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return;
if (refresh) await this.replaceUpcoming(res.tracks);
else {
const add = res.tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...this.state.queue, ...add] });
}
this.update({ wave: { ...wave, fallback: res.fallback } });
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback];
} finally {
this.waveLoading = false;
}
}
// --- состояние ---
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) return;
await this.setup();
try {
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (!st.track_id || !st.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
const tracks = res.items.filter(playable);
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
if (!tracks.length) return;
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(tracks));
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
} catch {
// офлайн или нет сохранённого состояния
}
}
async reset() {
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();
}
}
export const player = new Controller();
+9
View File
@@ -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 };
+21
View File
@@ -0,0 +1,21 @@
import TrackPlayer, { Event } from 'react-native-track-player';
import { player } from './controller';
/**
* Фоновый сервис: кнопки уведомления, экрана блокировки и гарнитуры. Работает и без UI —
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
*/
export async function PlaybackService(): Promise<void> {
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
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 }) => {
if (permanent) void TrackPlayer.pause();
else if (paused) void TrackPlayer.pause();
});
}
+17 -3
View File
@@ -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 const colors = tokens.color.dark;
export type Colors = Record<keyof typeof tokens.color.dark, string>;
/** Активная тема (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;
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useSyncExternalStore } from 'react';
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 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>}
{release.notes_md.trim() !== '' && (
<ScrollView style={{ maxHeight: 240 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</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>
);
}
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
function plainNotes(md: string): string {
return md
.replace(/^#{1,6}\s*/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/^\s*[-*]\s+/gm, '• ')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.trim();
}
+44
View File
@@ -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>
);
}
+330
View File
@@ -0,0 +1,330 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
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 } 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 ? `${remote}&size=${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 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}
{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' },
});
+110
View File
@@ -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>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core';
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
import { useFocusEffect } from 'expo-router';
import { useCallback, useEffect, 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 = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
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>
);
}
+129
View File
@@ -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);
}
}
+3 -1
View File
@@ -21,9 +21,11 @@
"@pipu/i18n": "workspace:*",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20",
"hash-wasm": "^4.12.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.3"
"react-i18next": "^15.5.3",
"tus-js-client": "^4.3.1"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
+32 -4
View File
@@ -79,7 +79,7 @@ describe('sign-in', () => {
await user.type(screen.getByLabelText(label), 'a-strong-owner-pass');
}
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
await screen.findByText('Привет, owner!');
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
expect(router.state.location.pathname).toBe('/');
expect(api.calls.find((c) => c.path === '/api/v1/me/bootstrap-credentials' && c.body)?.body).toMatchObject({
new_username: 'owner',
@@ -93,7 +93,7 @@ describe('sign-in', () => {
await user.type(screen.getByLabelText('Новый пароль'), 'bobs-own-pass');
await user.type(screen.getByLabelText('Повторите пароль'), 'bobs-own-pass');
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
await screen.findByText('Привет, bob!');
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
});
it('signs out from the profile menu', async () => {
@@ -110,7 +110,7 @@ describe('navigation by role', () => {
it('hides the admin section from listeners and blocks the route', async () => {
const alice = makeUser('alice', 'user');
const { router } = await renderApp([alice], '/admin/users', { signedInAs: alice });
await screen.findByText('Привет, alice!');
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
expect(router.state.location.pathname).toBe('/');
expect(screen.queryByRole('link', { name: 'Пользователи' })).not.toBeInTheDocument();
});
@@ -250,7 +250,7 @@ describe('player', () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
@@ -285,6 +285,34 @@ describe('player', () => {
});
});
describe('my wave', () => {
it('starts the wave and switches the mood on the fly', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Бегаю' }));
await user.click(screen.getByRole('button', { name: 'Запустить «Мою волну»' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByText('Группа крови')).toBeInTheDocument());
expect(api.calls.find((c) => c.method === 'POST' && c.path === '/api/v1/wave/sessions')?.body).toEqual({
activity: 'running',
mood: null,
});
expect(screen.getByRole('button', { name: 'Пауза «Моей волны»' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Спокойное' }));
await waitFor(() =>
expect(api.calls.find((c) => c.method === 'PATCH' && c.path.startsWith('/api/v1/wave/sessions/'))?.body).toEqual({
activity: 'running',
mood: 'calm',
}),
);
expect(within(bar).getByText('Группа крови')).toBeInTheDocument(); // музыка не прервалась
expect(screen.getByText('Бегаю · Спокойное')).toBeInTheDocument();
});
});
describe('safeRedirect', () => {
it.each([
['/settings', '/settings'],
+31
View File
@@ -11,6 +11,10 @@ import {
import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage';
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
import { SuggestPage } from '../pages/uploads/SuggestPage';
import { SourcesPage } from '../pages/admin/SourcesPage';
import { UsersPage } from '../pages/admin/UsersPage';
import { AlbumPage } from '../pages/AlbumPage';
@@ -205,6 +209,29 @@ const libraryRoute = createRoute({
component: LibraryPage,
});
/** Предложение треков — модераторы и админ (docs/uploads.md#1). */
const staffOnly = ({ context }: { context: { user: AuthUser } }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
};
const suggestRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/new', beforeLoad: staffOnly, component: SuggestPage });
const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/mine', beforeLoad: staffOnly, component: MyUploadsPage });
const uploadQueueRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/uploads',
beforeLoad: adminOnly,
component: UploadQueuePage,
});
const adminSettingsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/settings',
beforeLoad: adminOnly,
component: AdminSettingsPage,
});
const routeTree = rootRoute.addChildren([
loginRoute,
setupAdminRoute,
@@ -223,6 +250,10 @@ const routeTree = rootRoute.addChildren([
sourcesRoute,
patternsRoute,
libraryRoute,
suggestRoute,
myUploadsRoute,
uploadQueueRoute,
adminSettingsRoute,
]),
]);
+50
View File
@@ -110,6 +110,56 @@
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
}
.track:has(.lead) {
grid-template-columns: 44px 44px minmax(0, 1fr) auto;
}
.track:has(.lead):has(.trackAlbum) {
grid-template-columns: 44px 44px minmax(0, 1.4fr) minmax(0, 1fr) auto;
}
.lead {
display: flex;
justify-content: center;
font-size: 13px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.dimmed {
opacity: 0.6;
}
.eq {
display: flex;
gap: 2px;
align-items: flex-end;
height: 14px;
}
.eq span {
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--accent2);
transform-origin: bottom;
animation: pipuEq 0.55s ease-in-out infinite alternate;
}
.eq span:nth-child(2) {
animation-duration: 0.72s;
animation-delay: 0.1s;
}
.eq span:nth-child(3) {
animation-duration: 0.89s;
animation-delay: 0.2s;
}
.eqPaused span {
animation-play-state: paused;
}
.track:hover {
background: var(--hover);
}
+37 -8
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon } from '../shared/ui/icons';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css';
@@ -112,21 +112,27 @@ export function TrackList({
numbered,
context,
rowMeta,
lead,
disliked,
}: {
tracks: Track[];
showAlbum?: boolean;
numbered?: boolean;
context: PlayContext;
/** Дополнительный текст под исполнителями (например, время прослушивания в истории). */
/** Дополнительный текст под исполнителями. */
rowMeta?: (t: Track, i: number) => string | undefined;
/** Первая колонка перед обложкой (docs/design: время в истории, номер в лайках); у текущего трека — эквалайзер. */
lead?: (t: Track, i: number) => string;
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
disliked?: boolean;
}) {
const { t } = useTranslation();
const player = usePlayer();
const current = usePlayerState().track;
const { track: current, playing } = usePlayerState();
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null;
return (
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc;
@@ -139,7 +145,7 @@ export function TrackList({
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div
role="row"
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
@@ -151,15 +157,16 @@ export function TrackList({
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
>
<span className={s.num}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
<span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={s.trackMain}>
<span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
@@ -172,6 +179,16 @@ export function TrackList({
</Link>
)}
<span className={s.end}>
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button
type="button"
className={cx(s.like, liked && s.liked)}
@@ -181,6 +198,7 @@ export function TrackList({
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
</div>
@@ -191,6 +209,17 @@ export function TrackList({
);
}
/** Эквалайзер текущего трека (`pipuEq`, docs/design.md#2): три столбика, на паузе замирают. */
export function Equalizer({ playing }: { playing: boolean }) {
return (
<span className={cx(s.eq, !playing && s.eqPaused)} aria-hidden>
<span />
<span />
<span />
</span>
);
}
export function ArtistChip({ artist }: { artist: ArtistRef }) {
const { t } = useTranslation();
return (
+17
View File
@@ -0,0 +1,17 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useSession } from '../app/session';
/** Сколько лайков и дизлайков — счётчики в меню (total из первой страницы списка). */
export function useCollectionCounts() {
const session = useSession();
const total = (path: '/api/v1/me/likes' | '/api/v1/me/dislikes') => ({
queryKey: ['collection-count', path],
queryFn: async () => unwrap(await session.client.GET(path, { params: { query: { limit: 1 } } })).total,
staleTime: 60_000,
});
const likes = useQuery(total('/api/v1/me/likes'));
const dislikes = useQuery(total('/api/v1/me/dislikes'));
return { likes: likes.data, dislikes: dislikes.data };
}
+48
View File
@@ -0,0 +1,48 @@
// Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5).
export interface PickedFile {
file: File;
relPath: string; // «Кино/1988 - Группа крови/01 - Группа крови.flac» или просто имя файла
}
/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */
export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv'];
export function isAudio(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
return AUDIO_EXTENSIONS.includes(ext);
}
/** Файлы из <input type="file"> (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */
export function fromInput(list: FileList | null): PickedFile[] {
return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name }));
}
/** Файлы из перетаскивания: папки обходятся рекурсивно (FileSystemEntry API). */
export async function fromDrop(dt: DataTransfer): Promise<PickedFile[]> {
const entries = [...dt.items]
.filter((it) => it.kind === 'file')
.map((it) => it.webkitGetAsEntry?.())
.filter((e): e is FileSystemEntry => !!e);
if (!entries.length) return fromInput(dt.files);
const out: PickedFile[] = [];
for (const e of entries) await walk(e, '', out);
return out;
}
async function walk(entry: FileSystemEntry, prefix: string, out: PickedFile[]) {
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => (entry as FileSystemFileEntry).file(resolve, reject));
out.push({ file, relPath: path });
return;
}
if (!entry.isDirectory || entry.name.startsWith('.')) return;
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries отдаёт порциями (~100), пока не вернёт пустой массив.
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
if (!batch.length) break;
for (const child of batch) await walk(child, path, out);
}
}
@@ -0,0 +1,40 @@
/// <reference lib="webworker" />
// SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ —
// hash-wasm считает чанками в воркере, не держа файл в памяти и не блокируя интерфейс.
import { createSHA256 } from 'hash-wasm';
export interface HashRequest {
id: string;
file: File;
}
export type HashResponse =
| { id: string; type: 'progress'; done: number }
| { id: string; type: 'done'; sha256: string }
| { id: string; type: 'error'; message: string };
const ctx = self as unknown as DedicatedWorkerGlobalScope;
ctx.onmessage = async (e: MessageEvent<HashRequest>) => {
const { id, file } = e.data;
try {
const h = await createSHA256();
h.init();
const reader = file.stream().getReader();
let done = 0;
let lastReport = 0;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
h.update(chunk.value);
done += chunk.value.byteLength;
if (done - lastReport > 8 << 20) {
lastReport = done;
ctx.postMessage({ id, type: 'progress', done } satisfies HashResponse);
}
}
ctx.postMessage({ id, type: 'done', sha256: h.digest('hex') } satisfies HashResponse);
} catch (err) {
ctx.postMessage({ id, type: 'error', message: err instanceof Error ? err.message : String(err) } satisfies HashResponse);
}
};
+287
View File
@@ -0,0 +1,287 @@
import { unwrap, type AuthSession } from '@pipu/core';
import * as tus from 'tus-js-client';
import { isAudio, type PickedFile } from './files';
import type { HashRequest, HashResponse } from './hash.worker';
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
// (ничего не резервирует) → POST /uploads (резерв квоты) → tus, до 4 файлов параллельно, куски по 8 МБ.
// Синглтон модуля: загрузка продолжается при переходах по приложению, но не переживает перезагрузку страницы.
export type Stage =
| 'hashing'
| 'checking'
| 'ready'
| 'duplicate'
| 'too_large'
| 'uploading'
| 'done'
| 'rejected'
| 'error';
export interface UploadItem {
id: string;
file: File;
relPath: string;
stage: Stage;
progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string;
trackId?: string | null;
possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
message?: string;
uploadFileId?: string;
tusUrl?: string;
}
export interface UploaderState {
items: UploadItem[];
skipped: number; // не аудио (обложки, .cue, …)
busy: boolean; // идёт загрузка
batchId: string | null;
error: unknown;
}
const PARALLEL = 4;
const CHUNK = 8 << 20;
let seq = 0;
export class Uploader {
private state: UploaderState = { items: [], skipped: 0, busy: false, batchId: null, error: null };
private listeners = new Set<() => void>();
private worker: Worker | null = null;
private hashQueue: string[] = [];
private hashing = false;
private uploads = new Map<string, tus.Upload>();
private refreshing: Promise<unknown> | null = null;
constructor(private readonly session: AuthSession) {}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getState = () => this.state;
private set(patch: Partial<UploaderState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
}
private patch(id: string, p: Partial<UploadItem>) {
this.set({ items: this.state.items.map((it) => (it.id === id ? { ...it, ...p } : it)) });
}
private item(id: string) {
return this.state.items.find((it) => it.id === id);
}
/** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */
add(files: PickedFile[]) {
if (this.state.busy) return;
const fresh: UploadItem[] = [];
let skipped = 0;
for (const f of files) {
if (!isAudio(f.file.name)) {
skipped++;
continue;
}
// Тот же путь и размер уже в списке — не добавляем второй раз.
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 });
}
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
this.hashQueue.push(...fresh.map((it) => it.id));
void this.hashNext();
}
remove(id: string) {
if (this.state.busy) return;
this.hashQueue = this.hashQueue.filter((x) => x !== id);
this.set({ items: this.state.items.filter((it) => it.id !== id) });
}
/** Очистить список (после завершения или чтобы начать заново). */
clear() {
if (this.state.busy) return;
this.hashQueue = [];
this.set({ items: [], skipped: 0, batchId: null, error: null });
}
private hashWorker(): Worker {
this.worker ??= new Worker(new URL('./hash.worker.ts', import.meta.url), { type: 'module' });
return this.worker;
}
private async hashNext() {
if (this.hashing) return;
this.hashing = true;
try {
while (this.hashQueue.length) {
const id = this.hashQueue.shift()!;
const it = this.item(id);
if (!it) continue;
try {
const sha256 = await this.hash(it);
this.patch(id, { sha256, stage: 'checking', progress: 1 });
} catch (err) {
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
}
}
await this.precheck();
} finally {
this.hashing = false;
}
}
private hash(it: UploadItem): Promise<string> {
const w = this.hashWorker();
return new Promise((resolve, reject) => {
const onMessage = (e: MessageEvent<HashResponse>) => {
const m = e.data;
if (m.id !== it.id) return;
if (m.type === 'progress') {
this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) });
return;
}
w.removeEventListener('message', onMessage);
if (m.type === 'done') resolve(m.sha256);
else reject(new Error(m.message));
};
w.addEventListener('message', onMessage);
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest);
});
}
/** Предпроверка всех посчитанных файлов: дубли отсеиваются до загрузки. */
private async precheck() {
const pending = this.state.items.filter((it) => it.stage === 'checking' && it.sha256);
if (!pending.length) return;
try {
const res = unwrap(
await this.session.client.POST('/api/v1/uploads/precheck', {
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) },
}),
);
for (const r of res.files) {
const stage: Stage = r.result === 'accepted' ? 'ready' : r.result;
this.patch(r.client_id, { stage, trackId: r.track_id, possibleDuplicate: r.possible_duplicate });
}
} catch (err) {
// Предпроверка — подсказка: при сбое файлы всё равно можно отправить, сервер проверит дубли сам.
for (const it of pending) this.patch(it.id, { stage: 'ready' });
this.set({ error: err });
}
}
get ready(): UploadItem[] {
return this.state.items.filter((it) => it.stage === 'ready');
}
/** Создать пачку (резерв квоты) и загрузить принятые файлы. */
async start(comment: string) {
const ready = this.ready;
if (this.state.busy || !ready.length) return;
this.set({ busy: true, error: null });
try {
const res = unwrap(
await this.session.client.POST('/api/v1/uploads', {
body: {
comment: comment.trim() || undefined,
files: ready.map((it) => ({
client_id: it.id,
name: it.file.name,
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.file.size,
sha256: it.sha256!,
})),
},
}),
);
this.set({ batchId: res.batch_id });
const queue: { id: string; url: string }[] = [];
for (const f of res.files) {
if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id });
else if (f.tus_url && f.upload_file_id) {
this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 });
queue.push({ id: f.client_id, url: f.tus_url });
}
}
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} catch (err) {
this.set({ error: err });
} finally {
this.set({ busy: false });
}
}
/** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */
async retryFailed() {
const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl);
if (this.state.busy || !failed.length) return;
this.set({ busy: true, error: null });
try {
const queue = failed.map((it) => {
this.patch(it.id, { stage: 'uploading', message: undefined });
return { id: it.id, url: it.tusUrl! };
});
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} finally {
this.set({ busy: false });
}
}
private async drain(queue: { id: string; url: string }[]) {
for (let next = queue.shift(); next; next = queue.shift()) await this.upload(next.id, next.url);
}
private upload(id: string, url: string): Promise<void> {
const it = this.item(id);
if (!it) return Promise.resolve();
return new Promise((resolve) => {
const up = new tus.Upload(it.file, {
uploadUrl: url,
chunkSize: CHUNK,
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
storeFingerprintForResuming: false,
onBeforeRequest: async (req) => {
await this.refreshing;
const token = this.session.accessToken;
if (token) req.setHeader('Authorization', `Bearer ${token}`);
},
onShouldRetry: (err) => {
const status = (err as tus.DetailedError).originalResponse?.getStatus();
if (status === 401) {
this.refreshing = this.session.refresh();
return true;
}
// Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит).
return status === undefined || status === 0 || status >= 500 || status === 409 || status === 423;
},
onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }),
onSuccess: () => {
this.uploads.delete(id);
this.patch(id, { stage: 'done', progress: 1 });
resolve();
},
onError: (err) => {
this.uploads.delete(id);
this.patch(id, { stage: 'error', message: err.message });
resolve();
},
});
this.uploads.set(id, up);
up.start();
});
}
}
let instance: { session: AuthSession; uploader: Uploader } | null = null;
/** Загрузчик текущей сессии (новый при смене пользователя). */
export function uploaderFor(session: AuthSession): Uploader {
if (instance?.session !== session) instance = { session, uploader: new Uploader(session) };
return instance.uploader;
}
+28 -10
View File
@@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
@@ -13,6 +16,7 @@ import c from './catalog.module.css';
export function AlbumPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const album = useQuery({
queryKey: ['album', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
@@ -21,17 +25,12 @@ export function AlbumPage({ id }: { id: string }) {
if (album.isError) return <NotFound error={album.error} />;
const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a?.title };
return (
<div className={s.page}>
<header className={c.hero}>
{colors && (
<span
className={c.heroBg}
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
aria-hidden
/>
)}
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
<Cover album={a ?? { id }} size={200} />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span>
@@ -41,13 +40,32 @@ export function AlbumPage({ id }: { id: string }) {
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} ·{' '}
{formatDuration(a.duration_ms)}
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
</span>
)}
{a && a.tracks.some((tr) => tr.available) && (
<div className={c.heroActions}>
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
<Button
className={c.pill}
icon={shuffleIcon}
onClick={() =>
player.playList(
[...a.tracks].sort(() => Math.random() - 0.5),
0,
ctx,
)
}
>
{t('collection.shuffle')}
</Button>
</div>
)}
</div>
</header>
{a && <TrackList tracks={a.tracks} numbered context={{ type: 'album', id: a.id, label: a.title }} />}
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
</div>
);
}
+36 -3
View File
@@ -3,7 +3,10 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, Cover } from '../entities/catalog';
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { playIcon, waveIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
@@ -11,18 +14,25 @@ import c from './catalog.module.css';
export function ArtistPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const artist = useQuery({
queryKey: ['artist', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
});
const top = useQuery({
queryKey: ['artist-top', id],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/artists/{id}/top-tracks', { params: { path: { id }, query: { limit: 6 } } })).items,
});
const albums = useQuery({
queryKey: ['artist-albums', id],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
});
if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data;
const tracks = top.data ?? [];
const ctx = { type: 'artist' as const, id, label: a?.name };
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
@@ -38,8 +48,31 @@ export function ArtistPage({ id }: { id: string }) {
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span>
)}
{a && (
<div className={c.heroActions}>
<Button
variant="primary"
className={c.pill}
icon={waveIcon}
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
>
{t('catalog.artist_wave')}
</Button>
{tracks.length > 0 && (
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
)}
</div>
)}
</div>
</header>
{tracks.length > 0 && (
<section className={c.section}>
<h2>{t('catalog.top_tracks')}</h2>
<TrackList tracks={tracks} showAlbum context={ctx} lead={(_, i) => String(i + 1)} />
</section>
)}
{own.length > 0 && (
<section className={c.section}>
<h2>{t('catalog.albums')}</h2>
+46 -10
View File
@@ -6,26 +6,46 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { TrackList, type Track } from '../entities/catalog';
import { useCollectionCounts } from '../entities/collection';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { playIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError } from '../shared/ui/ui';
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
import c from './catalog.module.css';
import s from './page.module.css';
export type CollectionTab = 'history' | 'likes' | 'dislikes';
const TABS: CollectionTab[] = ['history', 'likes', 'dislikes'];
const HERO: Record<CollectionTab, { icon: string; tile: string }> = {
history: { icon: historyFillIcon, tile: 'linear-gradient(140deg, #35D3FF, #2F80FF 60%, #1B3FA8)' },
likes: { icon: likeFillIcon, tile: 'linear-gradient(140deg, #5E9BFF, #2F80FF 50%, #7C5CFF)' },
dislikes: { icon: dislikeFillIcon, tile: 'linear-gradient(140deg, #4A5572, #1A2236)' },
};
export function CollectionPage({ tab }: { tab: CollectionTab }) {
const { t } = useTranslation();
const counts = useCollectionCounts();
const hero = HERO[tab];
const sub =
tab === 'likes'
? t('collection.sub_likes', { count: counts.likes ?? 0 })
: t(tab === 'history' ? 'collection.sub_history' : 'collection.sub_dislikes');
return (
<div className={s.page}>
<div className={s.head}>
<h1>{t('collection.title')}</h1>
<header className={c.colHero}>
<span className={c.tile} style={{ background: hero.tile }} aria-hidden>
<Icon src={hero.icon} size={52} />
</span>
<div className={c.heroText}>
<span className={c.kicker}>{t('collection.title')}</span>
<h1 className={c.colTitle}>{t(`collection.${tab}`)}</h1>
<span className={c.heroSub}>{sub}</span>
</div>
<div className={s.chips} role="tablist" aria-label={t('collection.title')}>
</header>
<div className={c.underlineTabs} role="tablist" aria-label={t('collection.title')}>
{TABS.map((k) => (
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.tab}>
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.underlineTab}>
{t(`collection.${k}`)}
</Link>
))}
@@ -92,11 +112,12 @@ function History() {
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
{groups.map((g) => (
<section key={g.key} className={c.section}>
<h2>{g.title}</h2>
<h2 className={c.day}>{g.title}</h2>
<TrackList
tracks={g.items.map((i) => i.track)}
showAlbum
context={{ type: 'history' }}
rowMeta={(_, i) => time.format(new Date(g.items[i]!.played_at))}
lead={(_, i) => time.format(new Date(g.items[i]!.played_at))}
/>
</section>
))}
@@ -154,9 +175,16 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
<>
{reaction === 'like' && tracks.length > 0 && (
<div className={s.toolbar}>
<Button variant="primary" icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
{t('collection.play_all')}
</Button>
<Button
className={c.pill}
icon={shuffleIcon}
onClick={() => player.playList([...tracks].sort(() => Math.random() - 0.5), 0, context)}
>
{t('collection.shuffle')}
</Button>
<span className={s.note}>{t('catalog.track_count', { count: total })}</span>
</div>
)}
@@ -164,7 +192,15 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
{list.isSuccess && tracks.length === 0 && (
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
)}
{tracks.length > 0 && <TrackList tracks={tracks} showAlbum context={context} />}
{tracks.length > 0 && (
<TrackList
tracks={tracks}
showAlbum
context={context}
lead={(_, i) => String(i + 1)}
disliked={reaction === 'dislike'}
/>
)}
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('collection.more')}
+178 -9
View File
@@ -1,22 +1,191 @@
import { unwrap } from '@pipu/core';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUser } from '../app/session';
import { playIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import { useSession, useUser } from '../app/session';
import { usePlayer, usePlayerState } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import {
pauseIcon,
playIcon,
waveBedtimeIcon,
waveBoltIcon,
waveCarIcon,
waveFitnessIcon,
waveHappyIcon,
waveHeadphonesIcon,
waveRainyIcon,
waveRunIcon,
waveSpaIcon,
waveSunIcon,
waveWeekendIcon,
} from '../shared/ui/icons';
import { cx, FormError, Icon } from '../shared/ui/ui';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './home.module.css';
import { Onboarding } from './Onboarding';
/** Главная. Кнопка волны — заглушка до M4 (рекомендации). */
/** Иконки Material Symbols по именам из /wave/options. */
const ICONS: Record<string, string> = {
directions_run: waveRunIcon,
fitness_center: waveFitnessIcon,
wb_sunny: waveSunIcon,
headphones: waveHeadphonesIcon,
directions_car: waveCarIcon,
weekend: waveWeekendIcon,
bedtime: waveBedtimeIcon,
bolt: waveBoltIcon,
sentiment_satisfied: waveHappyIcon,
spa: waveSpaIcon,
rainy: waveRainyIcon,
};
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
type Tr = (key: string) => string;
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
export function HomePage() {
const session = useSession();
const queryClient = useQueryClient();
const onboarding = useQuery({
queryKey: ['onboarding'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: Infinity,
});
if (onboarding.data?.required) {
return (
<Onboarding
options={onboarding.data}
onDone={() => queryClient.setQueryData(['onboarding'], { ...onboarding.data, required: false })}
/>
);
}
return <WaveHome />;
}
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
function WaveHome() {
const { t } = useTranslation();
const tr = t as unknown as Tr;
const session = useSession();
const user = useUser();
const player = usePlayer();
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 track = waveOn ? st.track : null;
const colors: [string, string, string] =
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
const run = (fn: () => Promise<void> | void) => {
setError(null);
void Promise.resolve(fn()).catch(setError);
};
const onWave = () => {
if (!waveOn) run(() => player.startWave(activity, mood));
else run(() => player.toggle());
};
const choose = (kind: 'activity' | 'mood', key: string) => {
const nextActivity = kind === 'activity' ? (activity === key ? null : key) : activity;
const nextMood = kind === 'mood' ? (mood === key ? null : key) : mood;
setActivity(nextActivity);
setMood(nextMood);
// Смена контекста во время игры — без остановки музыки.
if (waveOn) run(() => player.setWaveContext(nextActivity, nextMood));
};
const ctxLabel =
[activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') ||
t('wave.for_you');
return (
<div className={s.home}>
<h1 className={s.greeting}>{t('home.greeting', { name: user.display_name ?? user.username })}</h1>
<div className={s.orb} aria-disabled="true">
<Icon src={playIcon} size={44} />
<span className={s.orbLabel}>{t('home.wave')}</span>
<WaveButton
variant={user.ui_prefs.wave_variant ?? 'orb'}
playing={playing}
bpm={track?.features?.bpm ?? undefined}
energy={track?.features?.energy ?? undefined}
colors={colors}
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
size={320}
label={t(playing ? 'wave.pause' : 'wave.start')}
onClick={onWave}
>
<Icon src={playing ? pauseIcon : playIcon} size={44} />
<span className={s.orbLabel}>{t('wave.title')}</span>
<span className={s.orbContext}>{ctxLabel}</span>
</WaveButton>
{st.wave?.fallback && <p className={s.note}>{t('wave.fallback')}</p>}
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
<FormError>{error ? errorText(error) : null}</FormError>
{options.data && (
<div className={s.picker}>
<Chips
title={t('wave.activity_label')}
options={options.data.activities}
value={activity}
label={(k) => tr(`wave.activities.${k}`)}
onPick={(k) => choose('activity', k)}
/>
<Chips
title={t('wave.mood_label')}
options={options.data.moods}
value={mood}
label={(k) => tr(`wave.moods.${k}`)}
onPick={(k) => choose('mood', k)}
/>
</div>
<p className={s.soon}>{t('home.wave_soon')}</p>
)}
</div>
);
}
/** MoodPicker: одно занятие и одно настроение; повторный клик снимает выбор. */
function Chips({
title,
options,
value,
label,
onPick,
}: {
title: string;
options: { key: string; icon: string }[];
value: string | null;
label: (key: string) => string;
onPick: (key: string) => void;
}) {
return (
<section className={s.group} aria-label={title}>
<h2 className={s.groupTitle}>{title}</h2>
<div className={s.chips}>
{options.map((o) => (
<button
key={o.key}
type="button"
className={cx(s.chip, value === o.key && s.chipOn)}
aria-pressed={value === o.key}
onClick={() => onPick(o.key)}
>
{ICONS[o.icon] && <Icon src={ICONS[o.icon]!} size={18} />}
{label(o.key)}
</button>
))}
</div>
</section>
);
}
+113
View File
@@ -0,0 +1,113 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { checkIcon } from '../shared/ui/icons';
import { Button, cx, FormError, Icon } from '../shared/ui/ui';
import s from './onboarding.module.css';
type Options = components['schemas']['Onboarding'];
/** Минимум исполнителей, чтобы волне было от чего оттолкнуться. */
const MIN_ARTISTS = 3;
/**
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
*/
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [artists, setArtists] = useState<Set<string>>(new Set());
const [genres, setGenres] = useState<Set<string>>(new Set());
const save = useMutation({
mutationFn: async (skip: boolean) =>
unwrap(
await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] },
}),
),
onSuccess: onDone,
});
// Функциональное обновление: быстрые нажатия подряд не теряют выбор.
const toggle = (update: (fn: (prev: Set<string>) => Set<string>) => void, key: string) =>
update((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
return (
<div className={s.page}>
<header className={s.head}>
<h1>{t('onboarding.title')}</h1>
<p>{t('onboarding.subtitle')}</p>
</header>
<section aria-label={t('onboarding.artists')}>
<h2 className={s.label}>{t('onboarding.artists')}</h2>
<div className={s.artists}>
{options.artists.map((a) => {
const on = artists.has(a.id);
return (
<button
key={a.id}
type="button"
className={cx(s.artist, on && s.on)}
aria-pressed={on}
onClick={() => toggle(setArtists, a.id)}
>
<span className={s.avatar}>
<Cover album={{ id: a.id, cover_url: a.cover_url, cover_colors: a.cover_colors }} size={96} round />
{on && (
<span className={s.check}>
<Icon src={checkIcon} size={22} />
</span>
)}
</span>
<span className={s.name}>{a.name}</span>
</button>
);
})}
</div>
</section>
{options.genres.length > 0 && (
<section aria-label={t('onboarding.genres')}>
<h2 className={s.label}>{t('onboarding.genres')}</h2>
<div className={s.genres}>
{options.genres.map((g) => (
<button
key={g.name}
type="button"
className={s.genre}
aria-pressed={genres.has(g.name)}
onClick={() => toggle(setGenres, g.name)}
>
{g.name}
</button>
))}
</div>
</section>
)}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<div className={s.actions}>
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
{t('onboarding.skip')}
</Button>
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
</Button>
</div>
</div>
);
}
+43 -4
View File
@@ -12,6 +12,7 @@ import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
export function SettingsPage() {
const { t } = useTranslation();
@@ -74,15 +75,17 @@ function AppearanceSection() {
const session = useSession();
const user = useUser();
const theme: Theme = user.ui_prefs.theme ?? 'dark';
const setTheme = async (value: Theme) => {
// Оптимистично: тема применяется сразу, сервер подтверждает.
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, theme: value } });
const wave: WaveVariant = user.ui_prefs.wave_variant ?? 'orb';
// Оптимистично: настройка применяется сразу, сервер подтверждает.
const setPrefs = async (prefs: components['schemas']['UiPrefs']) => {
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, ...prefs } });
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: { theme: value } } })));
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: prefs } })));
} catch {
session.setUser(user);
}
};
const setTheme = (value: Theme) => setPrefs({ theme: value });
return (
<section className={s.section}>
<h2>{t('settings.appearance')}</h2>
@@ -99,6 +102,42 @@ function AppearanceSection() {
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.wave_variant')}</span>
<Segmented<WaveVariant>
label={t('settings.wave_variant')}
value={wave}
onChange={(v) => void setPrefs({ wave_variant: v })}
options={[
{ value: 'orb', label: t('settings.wave_orb') },
{ value: 'liquid', label: t('settings.wave_liquid') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.player_view')}</span>
<Segmented<'bar' | 'island'>
label={t('settings.player_view')}
value={user.ui_prefs.web_player ?? 'bar'}
onChange={(v) => void setPrefs({ web_player: v })}
options={[
{ value: 'bar', label: t('settings.player_bar') },
{ value: 'island', label: t('settings.player_island') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'>
label={t('settings.ambient')}
value={user.ui_prefs.ambient === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ ambient: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('language.label')}</span>
<LanguageSwitch />
@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import s from '../page.module.css';
import { UploadSettingsSection } from './UploadQueuePage';
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
export function AdminSettingsPage() {
const { t } = useTranslation('admin');
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('server_settings.title')}</h1>
<p className={s.subtitle}>{t('server_settings.subtitle')}</p>
</div>
</div>
<UploadSettingsSection />
</div>
);
}
+81
View File
@@ -1,5 +1,6 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
@@ -65,6 +66,69 @@ export function LibraryPage() {
</div>
))}
</div>
{d && (
<section className={s.section}>
<h2>{t('library.analysis')}</h2>
<ScanLineBar
done={d.analysis.done}
total={d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed}
label={t('library.analysis_progress', {
done: d.analysis.done,
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
})}
/>
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
</section>
)}
{d && (
<section className={s.section}>
<h2>{t('library.wave')}</h2>
<div className={l.tiles}>
{(
[
['wave_served', d.wave.served.toLocaleString()],
['wave_sessions', d.wave.sessions.toLocaleString()],
['wave_listen', pct(d.wave.listen_through_rate)],
['wave_early_skip', pct(d.wave.early_skip_rate)],
['wave_likes', d.wave.like_rate === null ? '—' : d.wave.like_rate.toFixed(1)],
] as const
).map(([k, v]) => (
<div key={k} className={l.tile}>
<span className={l.tileValue}>{v}</span>
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
</div>
))}
</div>
{d.wave.by_model.length > 1 && (
<div className={s.tableWrap}>
<table className={s.table}>
<caption className={s.muted}>{t('library.wave_by_model')}</caption>
<thead>
<tr>
<th scope="col">{t('library.wave_model')}</th>
<th scope="col">{t('library.wave_served')}</th>
<th scope="col">{t('library.wave_listen')}</th>
<th scope="col">{t('library.wave_early_skip')}</th>
<th scope="col">{t('library.wave_likes')}</th>
</tr>
</thead>
<tbody>
{d.wave.by_model.map((m) => (
<tr key={m.model_ver}>
<td>{m.model_ver || '—'}</td>
<td>{m.served.toLocaleString()}</td>
<td>{pct(m.listen_through_rate)}</td>
<td>{pct(m.early_skip_rate)}</td>
<td>{m.like_rate === null ? '—' : m.like_rate.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{d && d.sources.length > 0 && (
<section className={s.section}>
<h2>{t('library.sources')}</h2>
@@ -92,3 +156,20 @@ export function LibraryPage() {
</div>
);
}
const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%`);
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
const value = total > 0 ? Math.round((done / total) * 100) : 0;
const id = useId();
return (
<div className={l.scan}>
<div className={l.scanText} id={id}>
{label}
</div>
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${value}%` }} />
</div>
</div>
);
}
@@ -11,6 +11,7 @@ import { addIcon, arrowDownIcon, arrowUpIcon, deleteIcon } from '../../shared/ui
import { Button, cx, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
import { WriteTemplateSection } from './WriteTemplateSection';
type Pattern = components['schemas']['PathPattern'];
type Preview = components['schemas']['PatternPreview'];
@@ -155,6 +156,7 @@ export function PatternsPage({ id }: { id: string }) {
<FormError>{err && !perr ? errorText(err) : null}</FormError>
</section>
{preview.data && <PreviewTable preview={preview.data} />}
{source.data && <WriteTemplateSection source={source.data} />}
</div>
);
}
+4 -2
View File
@@ -1,4 +1,5 @@
import type { components } from '@pipu/api-client';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../../shared/lib/format';
@@ -16,14 +17,15 @@ export function StatusBadge({ status }: { status: SourceStatus }) {
/** Прогресс идущего скана или итог последнего. */
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
const { t, i18n } = useTranslation('admin');
const id = useId();
if (scan?.running) {
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
return (
<div className={s.scan}>
<div className={s.scanText}>
<div className={s.scanText} id={id}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
</div>
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} />
</div>
</div>
+6
View File
@@ -110,6 +110,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
{source.name}
<span className={l.typeBadge}>{t(`sources.type_${source.type}`)}</span>
<StatusBadge status={source.status} />
{source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>}
</div>
<span className={l.location}>{location(source)}</span>
{source.status === 'error' && source.status_message && (
@@ -140,6 +141,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
{test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })}
</span>
)}
{test && test.write_ok !== null && test.write_ok !== undefined && (
<span className={`${l.test} ${test.write_ok ? l.testOk : l.testFail}`} role="status">
{test.write_ok ? t('write_template.write_ok') : t('write_template.write_fail', { message: test.write_message ?? '' })}
</span>
)}
<FormError>{error ? errorText(error) : null}</FormError>
</div>
{confirmDelete && (
@@ -0,0 +1,532 @@
import type { components } from '@pipu/api-client';
import { ApiError, unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
import u from '../uploads/uploads.module.css';
type Meta = components['schemas']['UploadMeta'];
type Settings = components['schemas']['UploadSettings'];
const TABS = {
review: ['pending_review'],
failed: ['import_failed'],
progress: ['uploading', 'processing', 'approved', 'importing'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
} satisfies Record<string, UploadStatus[]>;
type Tab = keyof typeof TABS;
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
export function UploadQueuePage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [tab, setTab] = useState<Tab>('review');
const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null);
const [rejecting, setRejecting] = useState(false);
const list = useInfiniteQuery({
queryKey: ['admin-uploads', tab, onlyPossible],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(
await session.client.GET('/api/v1/admin/uploads', {
params: {
query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 },
},
}),
),
getNextPageParam: (p) => p.next_cursor ?? undefined,
refetchInterval: (q) =>
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
const refresh = async () => {
setSelected(new Set());
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
};
const review = useMutation({
mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) =>
unwrap(
await session.client.POST('/api/v1/admin/uploads/review', {
body: { file_ids: v.ids, decision: v.decision, reason: v.reason },
}),
),
onSuccess: refresh,
});
const retry = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
onSuccess: refresh,
});
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
const batches = useMemo(() => {
const m = new Map<string, UploadFile[]>();
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
return [...m.values()];
}, [items]);
const toggle = (ids: string[], on: boolean) =>
setSelected((cur) => {
const next = new Set(cur);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
return next;
});
const selectable = tab === 'review' || tab === 'failed';
const ids = [...selected];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.queue_title')}</h1>
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(TABS) as Tab[]).map((k) => (
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
{t(`uploads.tab_${k}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
{t('uploads.only_possible')}
</label>
</div>
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
{batches.map((files) => (
<BatchCard
key={files[0]!.batch_id}
files={files}
selectable={selectable}
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onRetry={(id) => retry.mutate(id)}
/>
))}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
{ids.length > 0 && (
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
<span>{t('uploads.selected', { count: ids.length })}</span>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setSelected(new Set())}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
{t('uploads.reject')}
</Button>
{tab === 'review' && (
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
{t('uploads.approve')}
</Button>
)}
</div>
)}
{editing && (
<EditDialog
file={editing}
onClose={() => setEditing(null)}
onSaved={() => {
review.reset();
void refresh();
}}
/>
)}
{rejecting && (
<RejectDialog
count={ids.length}
busy={review.isPending}
onClose={() => setRejecting(false)}
onReject={(reason) => {
setRejecting(false);
review.mutate({ decision: 'reject', ids, reason });
}}
/>
)}
</div>
);
}
/** Problem из ответа массового решения → объект, понятный useErrorText. */
function problemError(p: components['schemas']['Problem']) {
return new ApiError(p.status, p.code, p.params ?? {});
}
function BatchCard({
files,
selectable,
selected,
onToggle,
onEdit,
onRetry,
}: {
files: UploadFile[];
selectable: boolean;
selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void;
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0);
return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}>
{selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)}
<b>{first.author.username}</b>
<span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
</div>
{files.map((f) => (
<QueueRow
key={f.id}
file={f}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</section>
);
}
function QueueRow({
file: f,
selectable,
checked,
onCheck,
onEdit,
onRetry,
}: {
file: UploadFile;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
onRetry: () => void;
}) {
const { t, i18n } = useTranslation('admin');
const reason = useReasonText()(f.status_reason);
const m = f.metadata;
const quality = [
f.format?.toUpperCase(),
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
f.bit_depth ? `${f.bit_depth} bit` : null,
f.duration_ms ? formatDuration(f.duration_ms) : null,
formatBytes(f.size_bytes, i18n.language),
]
.filter(Boolean)
.join(' · ');
const editable = f.status === 'pending_review' || f.status === 'import_failed';
return (
<div className={u.queueRow}>
{selectable ? (
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
) : (
<span />
)}
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
{m && (
<span className={u.sub}>
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
.filter(Boolean)
.join(' · ')}
{f.metadata_edited && ` · ${t('uploads.edited')}`}
</span>
)}
<span className={u.sub}>{quality}</span>
<span className={u.sub} title={f.client_rel_path ?? undefined}>
{f.client_rel_path ?? f.original_name}
</span>
</div>
<div className={u.meta}>
<div className={u.flags}>
<UploadStatusBadge status={f.status} />
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : (
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
</div>
<div className={u.rowActions}>
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} />
</Button>
)}
{f.status === 'import_failed' && (
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
{t('uploads.retry')}
</Button>
)}
</div>
</div>
);
}
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const fieldError = useFieldError();
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
const [source, setSource] = useState(file.target_source_id ?? '');
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/files/{id}', {
params: { path: { id: file.id } },
body: { metadata: m, ...(source ? { target_source_id: source } : {}) },
}),
),
onSuccess: () => {
onSaved();
onClose();
},
});
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
);
const num = (k: 'year' | 'track' | 'disc') => (
<Field
label={t(`uploads.meta_${k}`)}
value={m[k] === null ? '' : String(m[k])}
inputMode="numeric"
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
/>
);
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
return (
<Dialog
title={t('uploads.edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
<div className={u.grid}>
{text('artist')}
{text('albumartist')}
{text('album')}
{text('title')}
{text('genre')}
{num('year')}
{num('track')}
{num('disc')}
</div>
<label className={s.sessions}>
<span>{t('uploads.target_source')}</span>
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">{t('uploads.target_default')}</option>
{accepting.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<p className={s.note}>{t('uploads.tags_note')}</p>
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
</div>
</Dialog>
);
}
function RejectDialog({
count,
busy,
onClose,
onReject,
}: {
count: number;
busy: boolean;
onClose: () => void;
onReject: (reason: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const [reason, setReason] = useState('');
return (
<Dialog
title={t('uploads.reject_title', { count })}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
{t('uploads.reject')}
</Button>
</>
}
>
<label className={s.sessions}>
<span>{t('uploads.reject_reason')}</span>
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
</label>
</Dialog>
);
}
const GB = 1024 ** 3;
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
export function UploadSettingsSection() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const settings = useQuery({
queryKey: ['upload-settings'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/settings/uploads')),
});
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null);
useEffect(() => {
if (settings.data) setForm(toForm(settings.data));
}, [settings.data]);
const save = useMutation({
mutationFn: async () => {
const f = form!;
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
return unwrap(
await session.client.PATCH('/api/v1/admin/settings/uploads', {
body: {
queue_limit_bytes: gb(f.queue),
max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null,
},
}),
);
},
onSuccess: async (data) => {
queryClient.setQueryData(['upload-settings'], data);
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
if (!form) return null;
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
return (
<section className={s.section}>
<h2>{t('uploads.settings_title')}</h2>
<div className={u.grid}>
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
<Field
label={t('uploads.set_per_user')}
value={form.perUser}
hint={t('uploads.set_per_user_hint')}
onChange={(v) => setForm({ ...form, perUser: v })}
inputMode="decimal"
/>
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<label className={s.sessions}>
<span>{t('uploads.set_source')}</span>
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
<option value="">{t('uploads.set_source_auto')}</option>
{sources.data
?.filter((x) => x.accepts_uploads)
.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
</div>
<p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</div>
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</section>
);
}
function toForm(st: Settings) {
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
return {
queue: gb(st.queue_limit_bytes),
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '',
};
}
@@ -0,0 +1,154 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { Button, cx, Field, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
type Source = components['schemas']['Source'];
type Check = components['schemas']['WriteTemplateCheck'];
/**
* Приём загрузок и шаблон пути новых файлов (docs/uploads.md#7): автоподбор по существующей структуре
* и проверка обратимости регэкспами хранилища. Сохранить можно и с расхождениями — админ видит предупреждение.
*/
export function WriteTemplateSection({ source }: { source: Source }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const fieldError = useFieldError();
const id = source.id;
const [accepts, setAccepts] = useState(source.accepts_uploads);
const [template, setTemplate] = useState(source.write_template ?? '');
const [check, setCheck] = useState<Check | null>(null);
useEffect(() => {
setAccepts(source.accepts_uploads);
setTemplate(source.write_template ?? '');
}, [source.accepts_uploads, source.write_template]);
const runCheck = useMutation({
mutationFn: async (tpl: string) =>
unwrap(
await session.client.POST('/api/v1/admin/sources/{id}/write-template/check', {
params: { path: { id } },
body: { template: tpl },
}),
),
onSuccess: setCheck,
});
const suggest = useMutation({
mutationFn: async () =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } }))
.template,
onSuccess: (tpl) => {
setTemplate(tpl);
runCheck.mutate(tpl);
},
});
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/sources/{id}', {
params: { path: { id } },
body: { accepts_uploads: accepts, write_template: template },
}),
),
onSuccess: async (src) => {
queryClient.setQueryData(['source', id], src);
await queryClient.invalidateQueries({ queryKey: ['sources'] });
},
});
const err = save.error ?? suggest.error ?? runCheck.error;
const tplError = fieldError(err, 'write_template') ?? fieldError(err, 'template');
const dirty = accepts !== source.accepts_uploads || template !== (source.write_template ?? '');
return (
<section className={s.section}>
<h2>{t('write_template.title')}</h2>
<p className={s.note}>{t('write_template.intro')}</p>
<label className={l.checkRow}>
<input type="checkbox" checked={accepts} onChange={(e) => setAccepts(e.target.checked)} />
<span>{t('write_template.accepts')}</span>
</label>
{accepts && source.type === 'nfs' && <p className={s.note}>{t('write_template.nfs_rw_hint')}</p>}
<Field
label={t('write_template.template')}
value={template}
onChange={(v) => {
setTemplate(v);
setCheck(null);
save.reset();
}}
hint={t('write_template.hint')}
error={tplError}
mono
/>
<div className={s.toolbar}>
<Button busy={suggest.isPending} onClick={() => suggest.mutate()}>
{t('write_template.suggest')}
</Button>
<Button busy={runCheck.isPending} disabled={!template.trim()} onClick={() => runCheck.mutate(template)}>
{t('write_template.check')}
</Button>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || (accepts && !template.trim())} onClick={() => save.mutate()}>
{t('write_template.save')}
</Button>
</div>
{accepts && !template.trim() && <p className={s.note}>{t('write_template.required')}</p>}
{save.isSuccess && !dirty && <span className={s.ok}>{t('write_template.saved')}</span>}
<FormError>{err && !tplError ? errorText(err) : null}</FormError>
{check && <CheckResult check={check} />}
</section>
);
}
function CheckResult({ check }: { check: Check }) {
const { t } = useTranslation('admin');
if (check.total === 0) return <p className={s.note}>{t('write_template.no_tracks')}</p>;
return (
<>
<p className={cx(s.note, check.ok ? s.ok : l.scanError)} role="status">
{t(check.ok ? 'write_template.check_ok' : 'write_template.check_warn', {
round_trip: check.round_trip,
same_path: check.same_path,
total: check.total,
})}
</p>
{check.mismatches.length > 0 && (
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
<table className={s.table}>
<thead>
<tr>
<th>{t('write_template.col_real')}</th>
<th>{t('write_template.col_rendered')}</th>
<th>{t('write_template.col_problem')}</th>
</tr>
</thead>
<tbody>
{check.mismatches.map((m) => (
<tr key={m.rel_path}>
<td className={l.match}>{m.rel_path}</td>
<td className={l.match}>{m.rendered ?? '—'}</td>
<td>
{m.error ??
(m.fields.length
? m.fields.map((f) => (f === 'pattern' ? t('write_template.no_pattern') : f)).join(', ')
: t('write_template.other_path'))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
@@ -299,3 +299,10 @@
justify-content: flex-start;
}
}
.checkRow {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
+104
View File
@@ -66,6 +66,19 @@
pointer-events: none;
}
.tint {
position: absolute;
inset: 0;
pointer-events: none;
}
.heroActions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.heroBody {
position: relative;
display: flex;
@@ -114,3 +127,94 @@
padding: 20px 16px;
}
}
/* Шапка коллекции (макет «Коллекция»): плитка-иконка, надзаголовок, крупный заголовок. */
.colHero {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 24px;
}
.tile {
display: grid;
place-items: center;
width: 120px;
height: 120px;
border-radius: 20px;
color: #fff;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.3);
}
.heroText {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 240px;
}
.kicker {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text2);
}
.colTitle {
margin: 0;
font-family: var(--font-display);
font-size: 40px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1;
}
.heroSub {
font-size: 14px;
color: var(--text2);
text-wrap: pretty;
}
.underlineTabs {
display: flex;
gap: 6px;
border-bottom: 1px solid var(--line);
}
.underlineTab {
display: inline-flex;
align-items: center;
height: 42px;
padding: 0 14px;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--text2);
font-size: 15px;
font-weight: 500;
text-decoration: none;
}
.underlineTab:hover {
color: var(--text);
}
.underlineTab[aria-selected='true'] {
color: var(--text);
border-bottom-color: var(--accent);
}
.day {
margin: 0;
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
font-weight: 600;
color: var(--text2);
}
.pill {
height: 44px;
border-radius: var(--radius-pill);
}
+75 -41
View File
@@ -4,60 +4,94 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
gap: 28px;
padding: 24px 0;
text-align: center;
}
.greeting {
margin: 0;
.orbLabel {
margin-top: 4px;
font-family: var(--font-display);
font-size: 24px;
font-size: 22px;
font-weight: 600;
letter-spacing: -0.01em;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
.orb {
.orbContext {
font-size: 13px;
opacity: 0.85;
}
.note {
margin: -12px 0 0;
color: var(--text2);
font-size: 13px;
}
.picker {
display: flex;
flex-direction: column;
gap: 18px;
max-width: 720px;
}
.group {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 300px;
height: 300px;
max-width: 70vw;
max-height: 70vw;
border-radius: 50%;
color: #fff;
background:
radial-gradient(circle at 35% 30%, rgba(156, 198, 255, 0.55), transparent 45%),
radial-gradient(circle at 65% 70%, var(--wave-color3), transparent 55%),
radial-gradient(circle at 50% 50%, var(--wave-color2), var(--wave-color1) 70%);
box-shadow: 0 0 80px rgba(47, 128, 255, 0.35);
opacity: 0.85;
animation: breathe 6s ease-in-out infinite;
gap: 10px;
}
.orbLabel {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
}
@keyframes breathe {
50% {
transform: scale(1.03);
}
}
@media (prefers-reduced-motion: reduce) {
.orb {
animation: none;
}
}
.soon {
max-width: 420px;
.groupTitle {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.chips {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 16px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
color: var(--text);
font-size: 14px;
font-weight: 500;
transition:
background 0.15s,
box-shadow 0.15s;
}
.chip > span {
color: var(--text2);
line-height: 1.5;
}
.chip:hover {
background: var(--surface3);
}
.chipOn,
.chipOn:hover {
background: var(--accent-fill);
border-color: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.chipOn > span {
color: #fff;
}
+124
View File
@@ -0,0 +1,124 @@
.page {
display: flex;
flex-direction: column;
gap: 28px;
max-width: 1100px;
margin: 0 auto;
animation: pipuIn 0.2s ease-out;
}
.head h1 {
margin: 0 0 8px;
font-family: var(--font-display);
font-size: 32px;
font-weight: 600;
letter-spacing: -0.02em;
}
.head p {
margin: 0;
color: var(--text2);
}
.label {
margin: 0 0 14px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.artists {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 18px 12px;
}
.artist {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 10px 6px;
border-radius: var(--radius-lg);
text-align: center;
}
.artist:hover {
background: var(--hover);
}
.avatar {
position: relative;
border-radius: 50%;
transition:
box-shadow 0.15s,
transform 0.15s;
}
.on .avatar {
box-shadow:
0 0 0 3px var(--bg),
0 0 0 5px var(--accent);
transform: scale(0.96);
}
.check {
position: absolute;
right: -2px;
bottom: -2px;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.name {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.genres {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.genre {
height: 38px;
padding: 0 16px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
font-weight: 600;
font-size: 14px;
}
.genre[aria-pressed='true'] {
background: var(--accent-fill);
border-color: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.actions {
position: sticky;
bottom: 16px;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px;
border-radius: var(--radius-lg);
border: 1px solid var(--line2);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
}
@@ -0,0 +1,156 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { formatBytes, formatDateTime } from '../../shared/lib/format';
import { addIcon } from '../../shared/ui/icons';
import { Button, cx, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
import u from './uploads.module.css';
const FILTERS = {
all: undefined,
pending: ['uploading', 'processing', 'pending_review', 'approved', 'importing', 'import_failed'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
} satisfies Record<string, UploadStatus[] | undefined>;
type Filter = keyof typeof FILTERS;
const WITHDRAWABLE: UploadStatus[] = ['uploading', 'processing', 'pending_review'];
/** «Мои предложения»: статусы своих загрузок и решения админа (docs/uploads.md#10). */
export function MyUploadsPage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [filter, setFilter] = useState<Filter>('all');
const list = useInfiniteQuery({
queryKey: ['my-uploads', filter],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(
await session.client.GET('/api/v1/uploads/mine', {
params: { query: { status: FILTERS[filter] ? [...FILTERS[filter]] : undefined, cursor: pageParam, limit: 50 } },
}),
),
getNextPageParam: (p) => p.next_cursor ?? undefined,
// Обработка и импорт идут в фоне — пока есть такие файлы, обновляем список.
refetchInterval: (q) =>
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
const withdraw = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.DELETE('/api/v1/uploads/files/{id}', { params: { path: { id } } })),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['my-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.mine_title')}</h1>
<p className={s.subtitle}>{t('uploads.mine_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(FILTERS) as Filter[]).map((f) => (
<button key={f} type="button" className={s.chip} aria-pressed={filter === f} onClick={() => setFilter(f)}>
{t(`uploads.filter_${f}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<Link to="/uploads/new">
<Button variant="primary" icon={addIcon}>
{t('uploads.suggest_title')}
</Button>
</Link>
</div>
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>}
{items.length > 0 && (
<div className={s.tableWrap}>
<table className={s.table}>
<thead>
<tr>
<th>{t('uploads.col_file')}</th>
<th>{t('uploads.col_status')}</th>
<th>{t('uploads.col_size')}</th>
<th>{t('uploads.col_date')}</th>
<th />
</tr>
</thead>
<tbody>
{items.map((f) => (
<MineRow
key={f.id}
file={f}
busy={withdraw.isPending && withdraw.variables === f.id}
onWithdraw={() => withdraw.mutate(f.id)}
/>
))}
</tbody>
</table>
</div>
)}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
</div>
);
}
function MineRow({ file: f, busy, onWithdraw }: { file: UploadFile; busy: boolean; onWithdraw: () => void }) {
const { t, i18n } = useTranslation('admin');
// Технические подробности ошибки импорта — для админа; автору достаточно причины.
const raw = f.status === 'import_failed' ? (f.status_reason?.split(':')[0] ?? null) : f.status_reason;
const reason = useReasonText()(raw);
const m = f.metadata;
return (
<tr>
<td className={u.trackCell}>
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
<span className={cx(u.sub, u.ellipsis)} title={f.client_rel_path ?? f.original_name}>
{f.client_rel_path ?? f.original_name}
</span>
{f.status === 'imported' && f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
</div>
</td>
<td>
<div className={u.meta}>
<UploadStatusBadge status={f.status} />
{f.status === 'uploading' && (
<span className={u.sub}>{t('uploads.received', { pct: Math.round((f.received_bytes / Math.max(f.size_bytes, 1)) * 100) })}</span>
)}
{f.status === 'duplicate' && <span className={u.sub}>{t('uploads.dup_found')}</span>}
{reason && f.status !== 'pending_review' && <span className={u.bad}>{reason}</span>}
</div>
</td>
<td className={s.muted}>{formatBytes(f.size_bytes, i18n.language)}</td>
<td className={s.muted}>{formatDateTime(f.created_at, i18n.language)}</td>
<td>
{WITHDRAWABLE.includes(f.status) && (
<Button variant="ghost" busy={busy} onClick={onWithdraw}>
{t('uploads.withdraw')}
</Button>
)}
</td>
</tr>
);
}
+228
View File
@@ -0,0 +1,228 @@
import { useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useEffect, useRef, useState, useSyncExternalStore, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { AUDIO_EXTENSIONS, fromDrop, fromInput } from '../../features/uploads/files';
import { uploaderFor, type UploadItem } from '../../features/uploads/uploader';
import { useErrorText } from '../../shared/lib/errors';
import { formatBytes } from '../../shared/lib/format';
import { closeIcon, folderIcon, musicIcon } from '../../shared/ui/icons';
import { Button, cx, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import { QuotaMeter, useUploadQuota } from './common';
import u from './uploads.module.css';
const ACCEPT = AUDIO_EXTENSIONS.map((e) => `.${e}`).join(',');
/**
* «Предложить треки» (docs/uploads.md#5): перетащить файлы или папки → SHA-256 в воркере → предпроверка дублей →
* резерв места → загрузка по tus с докачкой. Дубли отсеиваются до загрузки и не тратят квоту.
*/
export function SuggestPage() {
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const uploader = uploaderFor(session);
const st = useSyncExternalStore(uploader.subscribe, uploader.getState);
const quota = useUploadQuota();
const [drag, setDrag] = useState(false);
const [comment, setComment] = useState('');
const filesRef = useRef<HTMLInputElement>(null);
const dirRef = useRef<HTMLInputElement>(null);
// webkitdirectory нет в типах React — ставим атрибут напрямую.
useEffect(() => {
dirRef.current?.setAttribute('webkitdirectory', '');
}, []);
const ready = st.items.filter((it) => it.stage === 'ready');
const readyBytes = ready.reduce((n, it) => n + it.file.size, 0);
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
const finished = !st.busy && st.batchId !== null;
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
const uploaded = st.items.filter((it) => it.stage === 'done').length;
const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null;
const fmt = (n: number) => formatBytes(n, i18n.language);
const onDrop = async (e: DragEvent) => {
e.preventDefault();
setDrag(false);
uploader.add(await fromDrop(e.dataTransfer));
};
const start = async () => {
await uploader.start(comment);
setComment('');
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
await queryClient.invalidateQueries({ queryKey: ['my-uploads'] });
};
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.suggest_title')}</h1>
<p className={s.subtitle}>{t('uploads.suggest_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div
className={cx(u.drop, drag && u.dropActive)}
onDragOver={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => void onDrop(e)}
>
<Icon src={musicIcon} size={36} />
<div className={u.dropTitle}>{t('uploads.drop_title')}</div>
<div className={s.note}>{t('uploads.drop_hint', { formats: AUDIO_EXTENSIONS.join(', ') })}</div>
<div className={u.dropActions}>
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
{t('uploads.pick_files')}
</Button>
<Button icon={folderIcon} disabled={st.busy} onClick={() => dirRef.current?.click()}>
{t('uploads.pick_folder')}
</Button>
</div>
<input
ref={filesRef}
className={u.hiddenInput}
type="file"
multiple
accept={ACCEPT}
onChange={(e) => {
uploader.add(fromInput(e.target.files));
e.target.value = '';
}}
/>
<input
ref={dirRef}
className={u.hiddenInput}
type="file"
multiple
onChange={(e) => {
uploader.add(fromInput(e.target.files));
e.target.value = '';
}}
/>
</div>
{st.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>}
{st.items.length > 0 && (
<div className={u.files} aria-label={t('uploads.files')}>
{st.items.map((it) => (
<FileRow key={it.id} item={it} locked={st.busy || finished} onRemove={() => uploader.remove(it.id)} />
))}
</div>
)}
{st.items.length > 0 && !finished && (
<section className={s.section}>
<label className={s.sessions}>
<span>{t('uploads.comment')}</span>
<textarea
className={u.textarea}
value={comment}
maxLength={1000}
placeholder={t('uploads.comment_hint')}
onChange={(e) => setComment(e.target.value)}
/>
</label>
{free !== null && readyBytes > free && (
<p className={u.warn}>{t('uploads.not_enough_space', { need: fmt(readyBytes), free: fmt(free) })}</p>
)}
<div className={s.toolbar}>
<Button variant="ghost" disabled={st.busy} onClick={() => uploader.clear()}>
{t('uploads.clear')}
</Button>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={st.busy} disabled={hashing || !ready.length} onClick={() => void start()}>
{st.busy
? t('uploads.uploading_now')
: hashing
? t('uploads.hashing_wait')
: t('uploads.send', { count: ready.length, size: fmt(readyBytes) })}
</Button>
</div>
</section>
)}
{finished && (
<section className={s.section} role="status">
{uploaded > 0 && <p className={s.ok}>{t('uploads.sent', { count: uploaded })}</p>}
{failedUploads > 0 && <p className={u.bad}>{t('uploads.failed_some', { count: failedUploads })}</p>}
<div className={s.toolbar}>
{failedUploads > 0 && (
<Button variant="primary" onClick={() => void uploader.retryFailed()}>
{t('uploads.retry_failed')}
</Button>
)}
<Link to="/uploads/mine" className={s.more}>
{t('uploads.go_mine')}
</Link>
<span style={{ flex: 1 }} />
<Button onClick={() => uploader.clear()}>{t('uploads.more')}</Button>
</div>
</section>
)}
<FormError>{st.error ? errorText(st.error) : null}</FormError>
</div>
);
}
function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean; onRemove: () => void }) {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
const showProgress = item.stage === 'hashing' || item.stage === 'uploading';
let note: { text: string; cls?: string } | null = null;
switch (item.stage) {
case 'duplicate':
note = { text: t('uploads.dup_found'), cls: u.sub };
break;
case 'too_large':
note = { text: tr('errors:uploads.too_large'), cls: u.bad };
break;
case 'rejected':
note = { text: tr(`errors:${item.code ?? 'uploads.duplicate'}`), cls: u.bad };
break;
case 'error':
note = { text: t('uploads.file_error', { message: item.message ?? '' }), cls: u.bad };
break;
case 'ready':
if (item.possibleDuplicate) note = { text: t('uploads.possible_dup'), cls: u.warn };
break;
}
return (
<div className={u.file}>
<div className={u.fileName}>
<span className={u.path} title={item.relPath}>
{item.relPath}
</span>
<span className={u.sub}>
{t(`uploads.stage.${item.stage}`)}
{showProgress && ` · ${Math.round(item.progress * 100)}%`}
</span>
{showProgress && (
<div className={u.progress}>
<span style={{ width: `${item.progress * 100}%` }} />
</div>
)}
{note && <span className={note.cls}>{note.text}</span>}
</div>
<span className={u.size}>{formatBytes(item.file.size, i18n.language)}</span>
{locked ? (
<span />
) : (
<Button variant="ghost" aria-label={t('uploads.remove', { name: item.relPath })} onClick={onRemove}>
<Icon src={closeIcon} size={18} />
</Button>
)}
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { formatBytes } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from '../page.module.css';
import u from './uploads.module.css';
export type UploadStatus = components['schemas']['UploadStatus'];
export type UploadFile = components['schemas']['UploadFile'];
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
uploading: 'info',
processing: 'info',
pending_review: 'warn',
approved: 'info',
importing: 'info',
imported: 'ok',
import_failed: 'bad',
duplicate: 'muted',
invalid: 'bad',
rejected: 'bad',
withdrawn: 'muted',
expired: 'muted',
};
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
}
export function useUploadQuota() {
const session = useSession();
return useQuery({
queryKey: ['upload-quota'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
});
}
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
export function QuotaMeter() {
const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota();
const id = useId();
if (!quota.data) return null;
const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language);
return (
<div className={u.quota}>
<span className={u.quotaText} id={id}>
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
{q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</span>
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} />
</div>
</div>
);
}
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
export function useReasonText(): (reason: string | null) => string | null {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
return (reason) => {
if (!reason) return null;
const code = reason.split(':')[0]!.trim();
const key = `errors:${code}`;
if (i18n.exists(key)) {
const rest = reason.slice(code.length + 1).trim();
return rest ? `${tr(key)}: ${rest}` : tr(key);
}
return reason;
};
}
@@ -0,0 +1,271 @@
.drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 180px;
padding: 24px;
border: 2px dashed var(--line2);
border-radius: var(--radius-lg);
background: var(--surface);
text-align: center;
transition:
border-color 0.15s,
background 0.15s;
}
.dropActive {
border-color: var(--accent);
background: var(--accent-soft);
}
.dropTitle {
font-size: 16px;
font-weight: 600;
}
.dropActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.hiddenInput {
display: none;
}
.files {
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
}
.file {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.file:first-child {
border-top: 0;
}
.fileName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.path {
font-family: var(--font-mono, monospace);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
font-size: 12px;
color: var(--text3);
}
.warn {
font-size: 12px;
color: var(--warn);
}
.bad {
font-size: 12px;
color: var(--danger);
}
.size {
font-size: 13px;
color: var(--text3);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.progress {
height: 4px;
border-radius: 2px;
background: var(--surface3);
overflow: hidden;
}
.progress span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--violet));
transition: width 0.3s;
}
.quota {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 220px;
}
.quotaText {
font-size: 13px;
color: var(--text2);
}
.tone_ok {
color: var(--ok);
background: var(--ok-soft);
}
.tone_warn {
color: var(--warn);
background: var(--warn-soft);
}
.tone_bad {
color: var(--danger);
background: var(--danger-soft);
}
.tone_info {
color: var(--accent2);
background: var(--accent-soft);
}
.tone_muted {
color: var(--text3);
background: var(--surface3);
}
.batch {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
}
.batchHead {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 12px 14px;
background: var(--surface2);
font-size: 13px;
color: var(--text2);
}
.batchHead b {
color: var(--text);
}
.comment {
flex-basis: 100%;
color: var(--text);
font-style: italic;
}
.queueRow {
display: grid;
grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr) auto;
gap: 14px;
align-items: start;
padding: 12px 14px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.meta {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.metaTitle {
font-weight: 600;
}
.flags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rowActions {
display: flex;
gap: 6px;
align-items: center;
}
.bulk {
position: sticky;
bottom: 12px;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--radius-lg);
border: 1px solid var(--line2);
background: var(--elev);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.textarea {
width: 100%;
min-height: 72px;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font: inherit;
resize: vertical;
}
@media (max-width: 720px) {
.queueRow {
grid-template-columns: auto minmax(0, 1fr);
}
.file {
grid-template-columns: minmax(0, 1fr) auto;
}
}
.target {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text2);
overflow-wrap: anywhere;
}
.trackCell {
min-width: 260px;
max-width: 480px;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+141 -1
View File
@@ -11,7 +11,23 @@ import {
export type { PlayContext, Track };
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
/** Рекомендатель недоступен — играет запасная волна. */
fallback: boolean;
}
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
/** Когда в очереди волны остаётся столько треков, просим следующую пачку. */
const WAVE_REFILL_AT = 2;
/** Скип раньше этого — «ранний» (сильный отрицательный сигнал). */
const EARLY_SKIP_MS = 30_000;
export interface PlayerState {
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
index: number;
@@ -75,6 +91,8 @@ export class Player {
private preloaded: string | null = null;
private refreshedFor: string | null = null;
private timers: ReturnType<typeof setInterval>[] = [];
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null;
@@ -90,6 +108,7 @@ export class Player {
},
});
this.state = {
wave: null,
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
};
@@ -135,12 +154,114 @@ export class Player {
const target = tracks[index];
if (!target || !playable.length) return;
this.finishAttempt('skip');
this.leaveWave();
this.context = context;
this.queue.set(playable, Math.max(playable.indexOf(target), 0));
this.syncQueue();
void this.load(true);
}
// --- «Моя волна» ---
/** Запустить волну (или перезапустить с новым контекстом). */
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
this.finishAttempt('skip');
this.leaveWave();
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions', {
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
}),
);
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) {
this.update({ error: 'wave.empty' });
return;
}
this.context = { type: 'wave', id: res.session_id, label: seed?.label };
this.queue.shuffle = false;
this.queue.repeat = 'off';
this.queue.set(tracks, 0);
this.update({ wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback } });
this.syncQueue();
void this.load(true);
}
/**
* Сменить занятие / настроение на лету: сразу играет первый трек новой пачки (docs/recommendations.md#6.7).
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
*/
async setWaveContext(activity: string | null, mood: string | null) {
const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood);
this.update({ wave: { ...wave, activity, mood } });
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
this.queue.dropUpcoming();
this.queue.append(fresh);
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
if (fresh.length && this.queue.next()) {
this.finishAttempt('skip', true);
this.syncQueue();
void this.load(true);
} else {
this.syncQueue();
}
}
stopWave() {
this.leaveWave();
this.pause();
}
private leaveWave() {
const wave = this.state.wave;
if (!wave) return;
this.waveFeedback = [];
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) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
}
/**
* Следующая пачка волны с накопленными реакциями. refresh — сначала выбросить ещё не начатые треки:
* после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию.
*/
private async refillWave(refresh = false) {
const wave = this.state.wave;
if (!wave || this.waveLoading) return;
if (!refresh && this.queue.remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return; // волну уже выключили
if (refresh) this.queue.dropUpcoming();
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
this.update({ wave: { ...this.state.wave, fallback: res.fallback } });
this.syncQueue();
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
} finally {
this.waveLoading = false;
}
}
toggle() {
if (this.state.playing) this.pause();
else this.play();
@@ -246,6 +367,10 @@ export class Player {
throw e;
}
this.onReaction?.();
if (reaction) {
this.noteWave(track.id, reaction);
void this.refillWave(true);
}
if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next();
}
@@ -268,6 +393,7 @@ export class Player {
});
this.updateMediaMetadata(track);
this.saveServer();
void this.refillWave();
if (autoplay) {
try {
await this.audio.play();
@@ -279,14 +405,28 @@ export class Player {
}
/** Завершает текущую попытку: событие с реально прослушанным временем. */
private finishAttempt(type: 'play_end' | 'skip') {
/** neutral — трек прерван сменой контекста волны: не отрицательный сигнал и без короткого «скипа» в событиях. */
private finishAttempt(type: 'play_end' | 'skip', neutral = false) {
const a = this.attempt;
if (!a) return;
this.attempt = null;
const pos = Math.round(this.audio.currentTime * 1000);
if (neutral) {
if (a.playedMs >= EARLY_SKIP_MS) {
this.events.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events.flush();
}
return;
}
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events.flush();
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 bindAudio() {
+12
View File
@@ -30,3 +30,15 @@ export function formatDuration(ms: number): string {
const s = String(total % 60).padStart(2, '0');
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
}
/** Размер файла в локали интерфейса: 734003 → «717 КБ», 2.5e9 → «2,3 ГБ». */
export function formatBytes(bytes: number, locale: string): string {
const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'] as const;
let v = Math.max(0, bytes);
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return new Intl.NumberFormat(locale, { style: 'unit', unit: units[i], unitDisplay: 'short', maximumFractionDigits: v < 10 && i > 0 ? 1 : 0 }).format(v);
}
+22
View File
@@ -46,3 +46,25 @@ export { default as repeatIcon } from '@material-symbols/svg-400/rounded/repeat.
export { default as repeatOneIcon } from '@material-symbols/svg-400/rounded/repeat_one.svg';
export { default as historyIcon } from '@material-symbols/svg-400/rounded/history.svg';
export { default as historyFillIcon } from '@material-symbols/svg-400/rounded/history-fill.svg';
export { default as waveBoltIcon } from '@material-symbols/svg-400/rounded/bolt.svg';
export { default as waveBedtimeIcon } from '@material-symbols/svg-400/rounded/bedtime.svg';
export { default as waveCarIcon } from '@material-symbols/svg-400/rounded/directions_car.svg';
export { default as waveRunIcon } from '@material-symbols/svg-400/rounded/directions_run.svg';
export { default as waveFitnessIcon } from '@material-symbols/svg-400/rounded/fitness_center.svg';
export { default as waveHeadphonesIcon } from '@material-symbols/svg-400/rounded/headphones.svg';
export { default as waveRainyIcon } from '@material-symbols/svg-400/rounded/rainy.svg';
export { default as waveHappyIcon } from '@material-symbols/svg-400/rounded/sentiment_satisfied.svg';
export { default as waveSpaIcon } from '@material-symbols/svg-400/rounded/spa.svg';
export { default as waveSunIcon } from '@material-symbols/svg-400/rounded/wb_sunny.svg';
export { default as waveWeekendIcon } from '@material-symbols/svg-400/rounded/weekend.svg';
export { default as uploadIcon } from '@material-symbols/svg-400/rounded/cloud_upload.svg';
export { default as uploadFillIcon } from '@material-symbols/svg-400/rounded/cloud_upload-fill.svg';
export { default as myUploadsIcon } from '@material-symbols/svg-400/rounded/library_add.svg';
export { default as myUploadsFillIcon } from '@material-symbols/svg-400/rounded/library_add-fill.svg';
export { default as inboxIcon } from '@material-symbols/svg-400/rounded/inbox.svg';
export { default as inboxFillIcon } from '@material-symbols/svg-400/rounded/inbox-fill.svg';
export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_eq.svg';
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
+3 -3
View File
@@ -53,13 +53,13 @@
}
.primary {
background: var(--accent);
background: var(--accent-fill);
color: #fff;
box-shadow: var(--glow);
}
.primary:hover:not(:disabled) {
background: var(--accent2);
background: var(--accent-fill2);
}
.secondary {
@@ -186,7 +186,7 @@
}
.segmented button[aria-pressed='true'] {
background: var(--accent);
background: var(--accent-fill);
color: #fff;
}
+18
View File
@@ -53,6 +53,14 @@ button {
}
}
/* Выезжающие справа панели (очередь, docs/design.md#4). */
@keyframes pipuSlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -61,3 +69,13 @@ button {
transition-duration: 0.01ms !important;
}
}
/* Эквалайзер текущего трека (docs/design.md#2). */
@keyframes pipuEq {
from {
transform: scaleY(0.25);
}
to {
transform: scaleY(1);
}
}
+18
View File
@@ -17,6 +17,8 @@ const problem = (status: number, code: string, extra: object = {}) => json({ sta
let seq = 0;
const WAVE_ID = '00000000-0000-4000-8000-0000000000ee';
const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' };
const albumRef = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', year: 1988, cover_url: null, cover_colors: [] };
export const albumTracks = ['Группа крови', 'Закрой за мной дверь'].map((title, i) => ({
@@ -31,6 +33,7 @@ export const albumTracks = ['Группа крови', 'Закрой за мно
available: true,
stream_url: `/api/v1/stream/00000000-0000-4000-8000-00000000010${i}?sig=x`,
reaction: null as 'like' | 'dislike' | null,
features: { bpm: 120, energy: 0.6, loudness_lufs: -12 },
}));
const albumSummary = {
id: '00000000-0000-4000-8000-0000000000bb',
@@ -193,6 +196,21 @@ export function fakeApi(initial: FakeUser[]) {
duration_ms: 400000,
tracks: albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })),
});
case 'GET /api/v1/wave/options':
return json({
activities: [{ key: 'running', icon: 'directions_run' }, { key: 'focus', icon: 'headphones' }],
moods: [{ key: 'calm', icon: 'spa' }, { key: 'happy', icon: 'sentiment_satisfied' }],
});
case 'POST /api/v1/wave/sessions':
return json(
{ session_id: WAVE_ID, activity: body?.activity ?? null, mood: body?.mood ?? null, tracks: albumTracks, fallback: false },
201,
);
case `PATCH /api/v1/wave/sessions/${WAVE_ID}`:
case `POST /api/v1/wave/sessions/${WAVE_ID}/next`:
return json({ session_id: WAVE_ID, activity: body?.activity ?? null, mood: body?.mood ?? null, tracks: [], fallback: false });
case `DELETE /api/v1/wave/sessions/${WAVE_ID}`:
return new Response(null, { status: 204 });
case 'POST /api/v1/events':
return json({ accepted: (body?.events as unknown[]).length, duplicates: 0, rejected: 0 });
case 'GET /api/v1/me/playback-state':
+3
View File
@@ -17,3 +17,6 @@ Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
},
});
Object.defineProperty(HTMLMediaElement.prototype, 'load', { configurable: true, value() {} });
// Canvas в jsdom нет: кнопка волны без контекста просто не рисует (как в старых браузерах).
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { configurable: true, value: () => null });
+108
View File
@@ -0,0 +1,108 @@
.ambient {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
opacity: var(--amb-op);
animation: pipuHue 30s linear infinite;
}
.ambient span {
position: absolute;
border-radius: 50%;
filter: blur(30px);
will-change: transform;
}
.a {
left: -22vmax;
top: -28vmax;
width: 70vmax;
height: 70vmax;
background: radial-gradient(circle, rgba(47, 128, 255, 0.75) 0%, transparent 62%);
animation: pipuDrift1 16s ease-in-out infinite alternate;
}
.b {
right: -20vmax;
top: 8vh;
width: 60vmax;
height: 60vmax;
background: radial-gradient(circle, rgba(124, 92, 255, 0.6) 0%, transparent 62%);
animation: pipuDrift2 21s ease-in-out infinite alternate;
}
.c {
left: 22vw;
bottom: -32vmax;
width: 55vmax;
height: 55vmax;
background: radial-gradient(circle, var(--amb-c3, rgba(53, 211, 255, 0.6)) 0%, transparent 62%);
animation: pipuDrift3 26s ease-in-out infinite alternate;
transition: background 1.2s;
}
@keyframes pipuDrift1 {
0% {
transform: translate(-6%, -4%) scale(1);
}
35% {
transform: translate(45%, 28%) scale(1.2);
}
70% {
transform: translate(15%, 60%) scale(0.9);
}
100% {
transform: translate(60%, -5%) scale(1.1);
}
}
@keyframes pipuDrift2 {
0% {
transform: translate(4%, 0) scale(1.05);
}
40% {
transform: translate(-50%, 40%) scale(0.9);
}
75% {
transform: translate(-20%, -25%) scale(1.2);
}
100% {
transform: translate(-60%, 15%) scale(1);
}
}
@keyframes pipuDrift3 {
0% {
transform: translate(0, 0) scale(0.95);
}
30% {
transform: translate(-40%, -45%) scale(1.15);
}
65% {
transform: translate(45%, -25%) scale(1);
}
100% {
transform: translate(10%, -55%) scale(1.2);
}
}
@keyframes pipuHue {
0% {
filter: hue-rotate(0deg);
}
50% {
filter: hue-rotate(-35deg);
}
100% {
filter: hue-rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ambient,
.ambient span {
animation: none;
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { CSSProperties } from 'react';
import s from './Ambient.module.css';
/**
* Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека.
* Отключается в настройках; при prefers-reduced-motion пятна неподвижны.
*/
export function Ambient({ color }: { color: string }) {
return (
<div className={s.ambient} aria-hidden>
<span className={s.a} />
<span className={s.b} />
<span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} />
</div>
);
}
+51
View File
@@ -1,4 +1,5 @@
.shell {
position: relative;
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 60px 1fr 88px;
@@ -9,6 +10,23 @@
height: 100%;
}
.shell > :where(:not([aria-hidden])) {
position: relative;
z-index: 1;
}
/* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */
.island {
grid-template-rows: 60px 1fr;
grid-template-areas:
'top top'
'side content';
}
.island .content {
padding-bottom: 132px;
}
.top {
grid-area: top;
display: flex;
@@ -118,6 +136,33 @@
color: var(--text3);
}
.navLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navCount {
font-size: 12px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.navBadge {
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: var(--radius-pill);
background: var(--accent-fill);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 20px;
text-align: center;
}
.navItem[data-status='active'] .navIcon {
color: var(--accent2);
}
@@ -140,6 +185,12 @@
font-size: 0;
}
.navLabel,
.navCount,
.navBadge {
display: none;
}
.navSection {
margin: 14px 0 6px;
text-align: center;
+76 -10
View File
@@ -1,7 +1,10 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
import { useCollectionCounts } from '../entities/collection';
import {
auditFillIcon,
auditIcon,
@@ -20,11 +23,11 @@ import {
dislikeIcon,
settingsIcon,
storageFillIcon,
storageIcon,
} from '../shared/ui/icons';
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider } from '../player/context';
import { PlayerProvider, usePlayerState } from '../player/context';
import { Ambient } from './Ambient';
import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox';
import s from './AppShell.module.css';
@@ -35,17 +38,37 @@ type NavPath =
| '/admin/audit'
| '/admin/sources'
| '/admin/library'
| '/admin/uploads'
| '/uploads/new'
| '/uploads/mine'
| '/admin/settings'
| '/collection/history'
| '/collection/likes'
| '/collection/dislikes';
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
function NavLink({
to,
label,
icon,
activeIcon,
count,
badge,
}: {
to: NavPath;
label: string;
icon: string;
activeIcon: string;
count?: number; // счётчик справа (лайки, дизлайки)
badge?: number; // яркий бейдж (ждут решения)
}) {
return (
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
{({ isActive }) => (
<>
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
{label}
<span className={s.navLabel}>{label}</span>
{badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
{!badge && count ? <span className={s.navCount}>{count}</span> : null}
</>
)}
</Link>
@@ -68,9 +91,13 @@ function Shell() {
const navigate = useNavigate();
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island';
const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin');
return (
<div className={s.shell}>
<div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<header className={s.top}>
<Link to="/" className={s.logoLink}>
<Logo />
@@ -105,8 +132,14 @@ function Shell() {
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<div className={s.navSection}>{t('nav.collection')}</div>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink
to="/collection/dislikes"
label={t('nav.dislikes')}
icon={dislikeIcon}
activeIcon={dislikeFillIcon}
count={counts.dislikes}
/>
{staff && (
<>
<div className={s.navSection}>{t('nav.admin')}</div>
@@ -115,8 +148,20 @@ function Shell() {
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
{user.role === 'admin' && (
<>
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)}
</>
)}
@@ -126,7 +171,28 @@ function Shell() {
<Outlet />
</main>
<PlayerBar />
<PlayerBar variant={island ? 'island' : 'bar'} />
</div>
);
}
/** Третье пятно фона — в цвете обложки текущего трека. */
function ShellAmbient() {
const track = usePlayerState().track;
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />;
}
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
function usePendingUploads(enabled: boolean) {
const session = useSession();
const q = useQuery({
queryKey: ['admin-uploads-pending'],
enabled,
queryFn: async () =>
unwrap(
await session.client.GET('/api/v1/admin/uploads', { params: { query: { status: ['pending_review'], limit: 100 } } }),
).items.length,
refetchInterval: 60_000,
});
return q.data;
}
+105
View File
@@ -10,6 +10,45 @@
backdrop-filter: blur(20px);
}
.bar.island {
position: fixed;
left: 50%;
bottom: 16px;
z-index: 5;
width: min(1040px, calc(100% - 32px));
height: 84px;
transform: translateX(-50%);
border: 1px solid var(--line2);
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
box-shadow: var(--shadow);
}
.source {
display: flex;
align-items: center;
gap: 5px;
margin-top: 2px;
font-size: 12px;
color: var(--text3);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
a.source {
text-decoration: none;
}
a.source:hover {
color: var(--text);
}
.sourceWave {
color: var(--accent2);
}
.empty {
display: flex;
align-items: center;
@@ -192,3 +231,69 @@
display: none;
}
}
.drawer {
position: fixed;
right: 16px;
bottom: 100px;
z-index: 6;
display: flex;
flex-direction: column;
width: min(380px, calc(100% - 32px));
max-height: min(560px, calc(100% - 180px));
border: 1px solid var(--line2);
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
box-shadow: var(--shadow);
animation: pipuSlideIn 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawerNote {
margin: 0;
padding: 12px 18px 16px;
border-top: 1px solid var(--line);
color: var(--text3);
font-size: 13px;
}
.drawerIsland {
bottom: 112px;
}
.drawerHead {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 12px 10px 18px;
font-weight: 600;
font-size: 16px;
}
.drawerList {
overflow-y: auto;
padding: 0 8px 10px;
}
.queueRow {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 6px 10px;
border-radius: 12px;
text-align: left;
}
.queueRow:hover {
background: var(--hover);
}
.queueCurrent {
background: var(--accent-soft);
}
.queueCurrent .title {
color: var(--accent2);
}
+130 -34
View File
@@ -1,10 +1,14 @@
import type { CSSProperties } from 'react';
import { useCallback, useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import {
albumIcon,
closeIcon,
dislikeFillIcon,
dislikeIcon,
likeFillIcon,
@@ -14,25 +18,34 @@ import {
pauseIcon,
playIcon,
prevIcon,
queueIcon,
repeatIcon,
repeatOneIcon,
shuffleIcon,
volumeIcon,
volumeOffIcon,
waveIcon,
} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './PlayerBar.module.css';
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
export function PlayerBar() {
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const [queueOpen, setQueueOpen] = useState(false);
const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId();
const closeQueue = useCallback(() => {
setQueueOpen(false);
queueButton.current?.focus(); // фокус — туда, откуда открыли
}, []);
const track = st.track;
if (!track) {
return (
<footer className={s.bar} aria-label={t('player.label')}>
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<span className={s.empty}>
<span className={s.placeholder}>
<Icon src={musicIcon} size={22} />
@@ -43,12 +56,22 @@ export function PlayerBar() {
);
}
const react = (r: 'like' | 'dislike') =>
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const repeatLabel = t(`player.repeat_${st.repeat}`);
const ctx = st.context;
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
const source = !ctx
? null
: ctx.type === 'wave'
? ctx.label
? t('player.source_wave_seed', { label: ctx.label })
: t('player.source_wave')
: ctx.type === 'collection'
? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
return (
<footer className={s.bar} aria-label={t('player.label')}>
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<div className={s.now}>
<Cover album={track.album} size={52} />
<div className={s.meta}>
@@ -56,7 +79,23 @@ export function PlayerBar() {
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
</span>
{source &&
(ctx?.type === 'album' && ctx.id ? (
<Link to="/album/$id" params={{ id: ctx.id }} className={s.source}>
<Icon src={albumIcon} size={14} />
{source}
</Link>
) : (
<span className={cx(s.source, ctx?.type === 'wave' && s.sourceWave)}>
<Icon src={ctx?.type === 'wave' ? waveIcon : albumIcon} size={14} />
{source}
</span>
))}
</div>
</div>
<div className={s.center}>
<div className={s.controls}>
<button
type="button"
className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
@@ -66,28 +105,6 @@ export function PlayerBar() {
>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button
type="button"
className={cx(s.icon, track.reaction === 'like' && s.accent)}
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
aria-pressed={track.reaction === 'like'}
onClick={() => react('like')}
>
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.center}>
<div className={s.controls}>
<button
type="button"
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
>
<Icon src={shuffleIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<Icon src={prevIcon} size={26} />
</button>
@@ -105,12 +122,12 @@ export function PlayerBar() {
</button>
<button
type="button"
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
aria-label={repeatLabel}
title={repeatLabel}
onClick={() => player.cycleRepeat()}
className={cx(s.icon, track.reaction === 'like' && s.accent)}
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
aria-pressed={track.reaction === 'like'}
onClick={() => react('like')}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.progress}>
@@ -137,6 +154,35 @@ export function PlayerBar() {
</div>
<div className={s.right}>
<button
type="button"
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
>
<Icon src={shuffleIcon} size={20} />
</button>
<button
type="button"
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
aria-label={repeatLabel}
title={repeatLabel}
onClick={() => player.cycleRepeat()}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
<button
ref={queueButton}
type="button"
className={cx(s.icon, queueOpen && s.accent)}
aria-label={t('player.queue')}
aria-expanded={queueOpen}
aria-controls={queueOpen ? queueId : undefined}
onClick={() => setQueueOpen((v) => !v)}
>
<Icon src={queueIcon} size={20} />
</button>
<button
type="button"
className={s.icon}
@@ -157,6 +203,56 @@ export function PlayerBar() {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
</footer>
);
}
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const upcoming = st.queue.slice(st.index, st.index + 40);
const close = useRef<HTMLButtonElement>(null);
// Клавиатура: при открытии фокус в панель, Escape закрывает.
useEffect(() => {
close.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal(
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}>
<span>{t('player.queue')}</span>
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} />
</button>
</div>
<div className={s.drawerList}>
{upcoming.map((tr, k) => (
<button
key={`${tr.id}-${k}`}
type="button"
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
aria-current={k === 0 || undefined}
onClick={() => player.jump(st.index + k)}
>
<Cover album={tr.album} size={40} />
<span className={s.meta}>
<span className={s.title}>{tr.title}</span>
<span className={s.artists}>{tr.artists.map((a) => a.name).join(', ')}</span>
</span>
<span className={s.time}>{formatDuration(tr.duration_ms)}</span>
</button>
))}
</div>
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
</aside>,
document.body,
);
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { drawLiquid, drawOrb, step, type WaveState } from './draw';
export interface WaveButtonProps {
variant?: 'orb' | 'liquid';
playing?: boolean;
bpm?: number;
energy?: number;
colors?: [string, string, string];
theme?: 'dark' | 'light';
interactive?: boolean;
size?: number;
label: string;
onClick?: () => void;
children?: ReactNode;
}
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
}
/**
* Кнопка «Моя волна» (docs/design.md#3): в покое «дышит», при игре пульсирует в такт BPM с силой от energy,
* тянется к курсору, при нажатии сжимается с отскоком. Enter / пробел — то же, что клик.
*/
export function WaveButton(props: WaveButtonProps) {
const size = props.size ?? 320;
const canvasRef = useRef<HTMLCanvasElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const st = useRef<WaveState>({
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: DEFAULT_COLORS,
dark: true, reduced: false, size,
});
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
const s = st.current;
s.variant = props.variant ?? 'orb';
s.playing = !!props.playing;
s.bpm = props.bpm ?? 100;
s.energy = props.energy ?? 0.6;
s.colors = props.colors ?? DEFAULT_COLORS;
s.dark = props.theme !== 'light';
s.size = size;
useEffect(() => {
const cv = canvasRef.current;
const ctx = cv?.getContext('2d');
if (!cv || !ctx) return;
const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
st.current.reduced = prefersReducedMotion();
const onMq = () => (st.current.reduced = prefersReducedMotion());
mq?.addEventListener?.('change', onMq);
let raf = 0;
let last = performance.now();
const loop = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const w = st.current;
const S = w.size * 1.6;
const dpr = window.devicePixelRatio || 1;
if (cv.width !== Math.round(S * dpr)) {
cv.width = Math.round(S * dpr);
cv.height = Math.round(S * dpr);
}
step(w, dt);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, S, S);
const sc = 1 - w.press * 0.07 + w.hover * 0.02;
const r = w.size * 0.5 * sc;
const cx = S / 2 + w.mx * w.hover * 6;
const cy = S / 2 + w.my * w.hover * 6;
if (w.variant === 'liquid') drawLiquid(ctx, cx, cy, r, w, w.t);
else drawOrb(ctx, cx, cy, r, w, w.t);
if (labelRef.current) labelRef.current.style.transform = `scale(${sc + w.pulse * 0.03})`;
raf = requestAnimationFrame(loop);
};
// Во фоновой вкладке анимация не нужна — останавливаемся до возвращения.
const onVisibility = () => {
cancelAnimationFrame(raf);
if (document.visibilityState === 'visible') {
last = performance.now();
raf = requestAnimationFrame(loop);
}
};
raf = requestAnimationFrame(loop);
document.addEventListener('visibilitychange', onVisibility);
return () => {
cancelAnimationFrame(raf);
document.removeEventListener('visibilitychange', onVisibility);
mq?.removeEventListener?.('change', onMq);
};
}, []);
const interactive = props.interactive !== false;
const onMove = (e: ReactMouseEvent) => {
const b = wrapRef.current!.getBoundingClientRect();
const x = ((e.clientX - b.left) / b.width) * 2 - 1;
const y = ((e.clientY - b.top) / b.height) * 2 - 1;
s.mx = x;
s.my = y;
s.ang = Math.atan2(y, x);
};
return (
<div
ref={wrapRef}
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
aria-label={interactive ? props.label : undefined}
aria-pressed={interactive ? !!props.playing : undefined}
aria-hidden={interactive ? undefined : true}
onMouseMove={interactive ? onMove : undefined}
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
onMouseLeave={
interactive
? () => {
s.hoverT = 0;
s.pressT = 0;
}
: undefined
}
onMouseDown={interactive ? () => (s.pressT = 1) : undefined}
onMouseUp={
interactive
? () => {
s.pressT = 0;
s.pressV -= 0.12;
}
: undefined
}
onClick={interactive ? props.onClick : undefined}
onKeyDown={
interactive
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation(); // пробел здесь — кнопка волны, а не пауза плеера
props.onClick?.();
}
}
: undefined
}
style={{
position: 'relative',
width: size,
height: size,
maxWidth: '78vw',
maxHeight: '78vw',
borderRadius: '50%',
cursor: interactive ? 'pointer' : 'default',
flexShrink: 0,
pointerEvents: interactive ? 'auto' : 'none',
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
left: '-30%',
top: '-30%',
width: '160%',
height: '160%',
pointerEvents: 'none',
}}
/>
<div
ref={labelRef}
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
color: '#fff',
textAlign: 'center',
}}
>
{props.children}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
// Отрисовка кнопки «Моя волна» на Canvas 2D — порт прототипа docs/Разработка дизайна приложения/wave.js
// (варианты orb и liquid; «Кольца» не реализуем — docs/design.md). Физика и цвета — в @pipu/core (wave-anim).
import { mix, rgb, rgba, type Rgb, type WaveState } from '@pipu/core';
export { rgb, step, type Rgb, type WaveState } from '@pipu/core';
export function drawOrb(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) {
const [c1, c2, c3] = s.colors.map(rgb) as [Rgb, Rgb, Rgb];
const glow = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r * 1.65);
glow.addColorStop(0, rgba(c1, 0.32 + s.pulse * 0.35 + s.hover * 0.12));
glow.addColorStop(0.5, rgba(c2, 0.1 + s.pulse * 0.12));
glow.addColorStop(1, rgba(c1, 0));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, r * 1.65, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = s.dark ? '#060B1C' : '#0B1533';
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.globalCompositeOperation = 'lighter';
const blobs = [c1, c2, c3, c1, c2];
blobs.forEach((c, 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 = ctx.createRadialGradient(bx, by, 0, bx, by, br);
g.addColorStop(0, rgba(c, 0.55));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
});
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);
ctx.beginPath();
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) ctx.moveTo(cx + x, y);
else ctx.lineTo(cx + x, y);
}
ctx.strokeStyle = `rgba(255,255,255,${0.05 + 0.05 * s.amp + (l % 3 === 0 ? 0.05 : 0)})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
const hl = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.45, 0, cx - r * 0.35, cy - r * 0.45, r * 0.9);
hl.addColorStop(0, 'rgba(255,255,255,0.18)');
hl.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hl;
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.restore();
ctx.beginPath();
ctx.arc(cx, cy, r - 0.5, 0, Math.PI * 2);
ctx.strokeStyle = rgba(mix(c1, [255, 255, 255], 0.4), 0.35 + s.pulse * 0.3);
ctx.lineWidth = 1;
ctx.stroke();
}
export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) {
const cols = s.colors.map(rgb) as [Rgb, Rgb, Rgb];
const glow = ctx.createRadialGradient(cx, cy, r * 0.4, cx, cy, r * 1.6);
glow.addColorStop(0, rgba(cols[0], 0.3 + s.pulse * 0.3));
glow.addColorStop(1, rgba(cols[0], 0));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, r * 1.6, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = s.dark ? 'lighter' : 'source-over';
for (let L = 0; L < 3; L++) {
ctx.beginPath();
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) ctx.lineTo(x, y);
else ctx.moveTo(x, y);
}
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3;
const oy = Math.sin(t * 0.5 + L) * r * 0.3;
const g = ctx.createRadialGradient(cx + ox, cy + oy, 0, cx, cy, r * 1.1);
g.addColorStop(0, rgba(mix(cols[L]!, [255, 255, 255], 0.25), 0.85));
g.addColorStop(1, rgba(cols[L]!, 0.25));
ctx.fillStyle = g;
ctx.globalAlpha = s.dark ? 0.6 : 0.75;
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
+4
View File
@@ -7,6 +7,10 @@ PIPU_DATA=/srv/pipu
# Папка с музыкой на локальном диске сервера (источники type=local), например смонтированный диск.
# Пусто — $PIPU_DATA/library-host. NFS-хранилища сюда не относятся: их подключает админка.
LIBRARY_HOST=
# Владелец файлов, которые импорт загрузок пишет в хранилища (UID/GID владельца музыки на файловом сервере).
# -1 — не менять (файлы будут принадлежать root агента).
PIPU_FILE_UID=-1
PIPU_FILE_GID=-1
# --- Домен и HTTPS (docs/install-guide.md) ---
# Вариант А: встроенный Caddy с Let's Encrypt — оставьте строку COMPOSE_FILE и заполните DOMAIN, ACME_EMAIL.
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Резервные копии PostgreSQL (docs/deployment.md#бэкапы). Работает в контейнере backup (compose.yaml) и в CI;
# подключение — стандартные PGHOST, PGUSER, PGPASSWORD, PGDATABASE.
# backup.sh loop — каждый день в BACKUP_HOUR (UTC): копия и ротация; раз в неделю — проверка восстановления
# backup.sh once — копия сейчас; печатает путь к файлу
# backup.sh verify FILE — восстановить FILE во временную базу и сверить таблицы и число строк
# backup.sh restore FILE — заменить рабочую базу копией (сервисы, пишущие в базу, должны быть остановлены);
# прежняя база остаётся рядом как <db>_before_restore_<время>
set -euo pipefail
DIR="${BACKUP_DIR:-/backups}"
DB="${PGDATABASE:-pipu}"
KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}"
KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}"
log() { echo "$(date -u +%FT%TZ) backup: $*" >&2; }
# Число строк в каждой таблице базы $1: «таблица<TAB>строк», по алфавиту.
counts() {
psql -X -d "$1" -Atq -F $'\t' -c "
SELECT table_name,
(xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name),
false, true, '')))[1]::text
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY 1"
}
rotate() {
# shellcheck disable=SC2012 # имена файлов наши, без пробелов
ls -1t "$DIR"/daily/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_DAILY + 1)) | xargs -r rm -f --
# shellcheck disable=SC2012
ls -1t "$DIR"/weekly/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_WEEKLY + 1)) | xargs -r rm -f --
find "$DIR/daily" "$DIR/weekly" -name '*.counts' | while read -r c; do
[ -f "${c%.counts}.dump" ] || rm -f -- "$c"
done
}
once() {
mkdir -p "$DIR/daily" "$DIR/weekly"
local name file
name="pipu-$(date -u +%Y%m%d-%H%M%S)"
file="$DIR/daily/$name.dump"
pg_dump -Fc -d "$DB" -f "$file.part"
pg_restore -l "$file.part" >/dev/null # оглавление читается — архив не обрезан
counts "$DB" >"$file.counts" # для verify: что было в базе в момент копии
mv "$file.part" "$file"
# Еженедельная копия — первая за 7 дней (не зависит от того, в какой день сервер был выключен).
if ! find "$DIR/weekly" -name 'pipu-*.dump' -mtime -7 | grep -q .; then
cp "$file" "$DIR/weekly/$name.dump"
cp "$file.counts" "$DIR/weekly/$name.dump.counts"
fi
rotate
log "$file ($(du -h "$file" | cut -f1))"
echo "$file"
}
verify() {
local file="$1" tmpdb="${DB}_verify" got want
[ -f "$file" ] || { log "нет файла $file"; return 1; }
dropdb --if-exists "$tmpdb"
createdb "$tmpdb"
if ! pg_restore --no-owner --exit-on-error -d "$tmpdb" "$file"; then
dropdb --if-exists "$tmpdb"
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file не восстанавливается"
return 1
fi
got="$(counts "$tmpdb")"
dropdb "$tmpdb"
want="$(cat "$file.counts" 2>/dev/null || counts "$DB")"
# Таблица пропала или опустела — копия негодна. Расхождение в числе строк — рабочая база менялась во время
# копии (предупреждение); VERIFY_EXACT=1 (CI, база в покое) — тоже ошибка.
local bad=0 table n m
while IFS=$'\t' read -r table n; do
m="$(printf '%s\n' "$got" | awk -F'\t' -v t="$table" '$1 == t { print $2 }')"
if [ -z "$m" ]; then
log "в копии нет таблицы $table"
bad=1
elif [ "$m" = 0 ] && [ "$n" != 0 ]; then
log "в копии пуста таблица $table (было $n)"
bad=1
elif [ "$m" != "$n" ]; then
log "$table: $m строк в копии, $n при копировании"
[ "${VERIFY_EXACT:-}" = 1 ] && bad=1
fi
done <<<"$want"
if [ "$bad" = 1 ]; then
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file"
return 1
fi
log "проверено: $file восстанавливается ($(printf '%s\n' "$got" | wc -l) таблиц)"
date -u +%FT%TZ >"$DIR/.last-verify"
}
restore() {
local file="$1" old
verify "$file"
old="${DB}_before_restore_$(date -u +%Y%m%d%H%M%S)"
psql -X -d postgres -Atqc "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '$DB' AND pid <> pg_backend_pid()" >/dev/null
psql -X -d postgres -qc "ALTER DATABASE \"$DB\" RENAME TO \"$old\""
createdb "$DB"
if ! pg_restore --no-owner --exit-on-error -d "$DB" "$file"; then
dropdb "$DB"
psql -X -d postgres -qc "ALTER DATABASE \"$old\" RENAME TO \"$DB\""
log "восстановление не удалось — рабочая база не тронута"
return 1
fi
log "база $DB восстановлена из $file; прежняя сохранена как $old (удалить: dropdb $old)"
}
loop() {
local hour="${BACKUP_HOUR:-3}" now next file
log "ежедневная копия в $hour:30 UTC, хранится $KEEP_DAILY ежедневных и $KEEP_WEEKLY еженедельных"
while true; do
now="$(date -u +%s)"
next="$(date -u -d "today $hour:30" +%s)"
[ "$next" -gt "$now" ] || next="$(date -u -d "tomorrow $hour:30" +%s)"
sleep $((next - now))
if ! file="$(once)"; then
log "КОПИЯ НЕ СДЕЛАНА"
continue
fi
if [ -z "$(find "$DIR" -maxdepth 1 -name .last-verify -mtime -7)" ]; then
verify "$file" || true
fi
done
}
case "${1:-}" in
loop) loop ;;
once) once ;;
verify) verify "${2:?укажите файл копии}" ;;
restore) restore "${2:?укажите файл копии}" ;;
*)
sed -n '2,9p' "$0" >&2
exit 2
;;
esac
+31 -2
View File
@@ -18,8 +18,8 @@ x-library-ro: &library-ro
bind: { propagation: rslave }
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
# Всем сервисам, включая storage-agent, — только чтение: импорт загрузок ещё не реализован,
# запись откроем вместе с ним (docs/library-and-storage.md).
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw
# (docs/library-and-storage.md#запись-в-хранилище).
x-library-host-ro: &library-host-ro
type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
@@ -80,6 +80,12 @@ services:
environment:
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
LIBRARY_ROOT: /library
# rw-точки NFS-хранилищ с приёмом загрузок — каталог внутри контейнера, наружу не виден.
LIBRARY_RW_ROOT: /library-rw
LIBRARY_HOST_RW_ROOT: /library-host-rw
UPLOADS_ROOT: /uploads
PIPU_FILE_UID: ${PIPU_FILE_UID:--1}
PIPU_FILE_GID: ${PIPU_FILE_GID:--1}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- type: bind
@@ -87,6 +93,9 @@ services:
target: /library
bind: { propagation: rshared }
- *library-host-ro
- type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host-rw
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
recommender:
@@ -133,6 +142,26 @@ services:
interval: 5s
retries: 20
# Ежедневная копия базы и еженедельная проверка восстановления (deploy/backup.sh, docs/deployment.md#бэкапы).
backup:
<<: *restart
image: pgvector/pgvector:0.8.7-pg17
entrypoint: ["bash", "/backup.sh"]
command: ["loop"]
environment:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: pipu
BACKUP_HOUR: ${BACKUP_HOUR:-3}
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8}
volumes:
- ./backup.sh:/backup.sh:ro
- ${PIPU_DATA:-/srv/pipu}/backups:/backups
depends_on:
postgres: { condition: service_healthy }
redis:
<<: *restart
image: redis:7.4-alpine
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Восстановление базы из резервной копии (docs/deployment.md#бэкапы).
# ./restore.sh — список копий
# ./restore.sh daily/pipu-20261020-033000.dump
# Сервисы, пишущие в базу, останавливаются на время восстановления; прежняя база сохраняется рядом.
set -euo pipefail
cd "$(dirname "$0")"
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
DATA="$(sed -n 's/^PIPU_DATA=//p' .env)"
DATA="${DATA:-/srv/pipu}"
if [ -z "${1:-}" ]; then
echo "Копии в $DATA/backups (новые сверху):"
(cd "$DATA/backups" && ls -1t daily/*.dump weekly/*.dump 2>/dev/null) || echo " копий нет"
exit 0
fi
rel="${1#"$DATA/backups/"}"
[ -f "$DATA/backups/$rel" ] || { echo "нет файла $DATA/backups/$rel" >&2; exit 1; }
echo "→ восстановление из $rel: api, рекомендации и анализ будут остановлены на время восстановления"
read -r -p "Продолжить? [y/N] " yes
[ "$yes" = y ] || [ "$yes" = Y ] || exit 1
docker compose stop api recommender analyzer storage-agent
if docker compose run --rm backup restore "/backups/$rel"; then
status=0
else
status=1
fi
docker compose up -d
[ "$status" = 0 ] || { echo "восстановление не удалось — база не изменена" >&2; exit 1; }
echo "готово. Поиск переиндексируется при старте api (или вручную: «Администрирование → Библиотека»)."
+6
View File
@@ -53,6 +53,12 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
fi
fi
# Копия базы перед обновлением: миграции назад не откатываются (deploy/restore.sh — вернуть).
if [ -n "$(docker compose ps -q --status running postgres 2>/dev/null)" ]; then
echo "→ резервная копия базы"
docker compose run --rm --no-deps backup once >/dev/null
fi
echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF"
+1
View File
@@ -23,6 +23,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [testing.md](testing.md) | Стратегия тестирования |
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, бэкапы, диагностика |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы |
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
+133
View File
@@ -0,0 +1,133 @@
# Руководство администратора
Повседневная работа с сервером после установки ([install-guide.md](install-guide.md)): пользователи, музыка,
очередь на добавление, «Моя волна», обновления, бэкапы и диагностика. Все команды — из каталога
`/opt/pipu-music/deploy`. Справочник по сервисам и переменным — [deployment.md](deployment.md).
## 1. Первый вход
1. Логин `admin`. Пароль — `BOOTSTRAP_ADMIN_PASSWORD` из `.env`, а если он не задан — сгенерированный, его
печатает лог один раз: `docker compose logs api | grep -i bootstrap`.
2. Сервер сразу попросит сменить логин и пароль администратора (пароль ≥ 12 символов). До этого остальные
разделы недоступны.
3. Подключите хранилище с музыкой (раздел 3) — после первого скана появятся библиотека, поиск и «Моя волна».
## 2. Пользователи и роли
«Администрирование → Пользователи».
| Роль | Что может |
|---|---|
| Пользователь | Слушать, искать, собирать коллекцию, «Моя волна» |
| Модератор | Плюс: заводить пользователей и сбрасывать им пароли, смотреть статус библиотеки, предлагать треки |
| Администратор | Всё: хранилища, сканы, очередь на добавление, настройки сервера, роли, аудит |
Полная матрица прав — [auth-and-roles.md](auth-and-roles.md#матрица-прав).
- **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один
раз, передайте его человеку. При первом входе пароль придётся сменить.
- **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются.
- **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей.
- Последнего администратора нельзя понизить, заблокировать или удалить.
- **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы
паролей, изменения хранилищ и настроек, решения по очереди.
## 3. Хранилища и библиотека
«Администрирование → Хранилища».
- **Папка на сервере** — каталог хоста из `LIBRARY_HOST` в `.env` (например, смонтированный диск `/mnt/music`);
в форме указывается путь внутри него. **NFS** — сервер и экспорт; монтирует `storage-agent`.
- «Проверить» — доступ к хранилищу, число найденных аудиофайлов.
- **Шаблоны путей** («Шаблоны»): как из пути файла получить исполнителя, альбом, год и номер трека, если в тегах
пусто. «Предпросмотр» показывает разбор реальных путей до сохранения. Что важнее — теги или путь, задаётся
в карточке хранилища (по умолчанию теги).
- **Скан:** обычный — только новые и изменённые файлы (по расписанию — интервал в карточке хранилища — и после
изменения настроек), «Полный» — перечитать всё. Удалённые с диска файлы помечаются отсутствующими, а не стираются из истории.
- «Администрирование → Библиотека» — счётчики, прогресс скана и аудиоанализа, файлы с ошибками чтения
(битые файлы, неизвестные форматы) и «Переиндексировать поиск» — если поиск выдаёт устаревшее (обычно не нужно:
индекс обновляется сам и пересобирается при старте api).
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
## 4. Очередь на добавление
Модераторы и администраторы предлагают треки («Предложить треки»), администратор решает, что попадёт
в библиотеку. Подробно — [uploads.md](uploads.md).
- **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать»
предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер
получит те же исполнителя, альбом и номер трека. Хранилище по умолчанию — в «Настройках сервера».
- **Рассмотрение** («Очередь на добавление»): прослушать, поправить метаданные (в том числе сразу для всего
альбома), одобрить или отклонить с причиной — автор её увидит. Итоговый путь пересчитывается при правке.
Отметка «возможный дубль» — со ссылкой на похожий трек библиотеки; точные копии отсекаются ещё до загрузки.
- **Импорт:** одобренный файл записывается в хранилище только под окончательным именем, с проверкой контрольной
суммы; кроме самих аудиофайлов (и папок для них) на диск ничего не пишется.
- **Квота** («Настройки сервера»): общий лимит очереди (10 ГБ по умолчанию), лимит на одного модератора
(выключен) и максимальный размер файла (2 ГБ). Отклонённые файлы и дубли место не занимают; снижение лимита ниже
занятого не удаляет загрузки, просто новые не принимаются.
## 5. «Моя волна»
- «Администрирование → Библиотека → Моя волна за 7 дней»: сколько треков выдано, доля дослушанных до конца,
ранних скипов и лайков на 100 треков. Ориентир — ранних скипов меньше 30%.
- Таблица по **версиям модели**: `heuristic-v1` — эвристика, `heuristic-v1+cf` — она же с коллаборативной
фильтрацией (ALS), `lgbm-…` — обученный ранжировщик. По ней видно, стало ли лучше после смены модели.
- Обучение — каждую ночь в 3:00 UTC: ALS по прослушиваниям, бандит по источникам
кандидатов, ранжировщик по показам волны. Новая модель включается, только если по проверке на последней неделе
не хуже текущей. Ранжировщику нужно ~20 тыс. показов с известным исходом — до этого работает эвристика.
- Новые пользователи с почти пустой историей проходят **онбординг вкуса**: выбирают исполнителей и жанры
библиотеки. Пропуск тоже запоминается.
## 6. Обновление
```bash
./update.sh
```
Скрипт делает копию базы, переключается на последний релиз, собирает образы, перезапускает сервисы и ждёт
готовности; миграции применяются при старте api. Конкретная версия — `./update.sh v1.0.0`; откат на старую версию
требует `PIPU_ALLOW_DOWNGRADE=1` (миграции не откатываются — сначала проверьте совместимость). Перед обновлением
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
## 7. Бэкапы и восстановление
- Каждую ночь (`BACKUP_HOUR`, по умолчанию 3:30 UTC) контейнер `backup` делает копию базы в
`/srv/pipu/backups/daily` (14 последних) и раз в неделю — в `weekly` (8 последних).
- Раз в неделю копия **проверяется восстановлением** во временную базу. Результат:
`docker compose logs backup | tail`; при ошибке в логе — «ПРОВЕРКА НЕ ПРОЙДЕНА».
- Копия сейчас: `docker compose run --rm backup once`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/<файл>` — восстановить. Прежняя база
остаётся рядом как `pipu_before_restore_<время>`; убедившись, что всё в порядке, удалите её:
`docker compose exec postgres dropdb -U pipu pipu_before_restore_<время>`.
- В копию входит всё, что нельзя пересоздать: пользователи, история, лайки, состояние плеера, очередь на добавление,
результаты аудиоанализа и модели волны. Поиск, обложки и транскоды восстанавливаются сами.
- **Сама музыка не бэкапится** приложением — это ваш диск или NFS. Каталог бэкапов тоже лежит на сервере:
копируйте `/srv/pipu/backups` на другую машину, например `rsync` по cron.
## 8. Наблюдение
| Что | Как |
|---|---|
| Сервер жив и готов | `curl -s https://music.example.com/readyz` → все зависимости `ok` |
| Состояние сервисов | `docker compose ps` |
| Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`, `backup`) |
| Место на диске | `df -h /srv/pipu`; больше всего занимают `cache/transcode` (чистится сам) и `backups` |
| Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» |
| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) |
## 9. Частые ситуации
| Ситуация | Что делать |
|---|---|
| Трек не находится поиском | Есть ли он в «Библиотеке» (скан завершён, файл не в «ошибках»); затем «Переиндексировать поиск» |
| Неверный исполнитель или альбом | Поправьте теги файла или шаблон путей и запустите скан |
| Хранилище в статусе «Ошибка» | Текст ошибки на карточке; диск смонтирован (`findmnt`), NFS доступен с сервера; «Проверить» |
| Волна однообразная | Идёт ли аудиоанализ («Библиотека → Аудиоанализ»); чем больше прослушиваний, тем точнее волна |
| Загрузка застряла в «Обработке» | `docker compose logs api | grep upload`; повторный импорт — «Одобрить» ещё раз |
| Пользователь не может войти | Не заблокирован ли; «Сбросить пароль» |
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
+3 -2
View File
@@ -135,8 +135,8 @@
| GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` |
| POST | `/uploads/precheck` | moderator, admin | `{files:[{client_id, size, sha256, tags:{artist,title,album,duration_ms}}]}` → для каждого `accepted` / `duplicate` (+ `track`) / `too_large`; ничего не резервирует |
| POST | `/uploads` | moderator, admin | Создать пачку `{comment, files:[{client_id, name, rel_path, size, sha256}]}` → резерв квоты; `{batch_id, files:[{client_id, upload_file_id, tus_url} | {client_id, rejected: code}]}`; при нехватке места — `uploads.queue_full` для не поместившихся |
| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`) |
| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения |
| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`, ядро tus 1.0.0 — ADR-015) |
| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения (`status` — через запятую) |
| DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения |
| GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам |
| GET | `/admin/uploads/files/{id}/stream?sig=` | admin | Прослушивание из staging |
@@ -144,6 +144,7 @@
| PATCH | `/admin/uploads/batches/{id}` | admin | Правка общих полей для всех файлов пачки (исполнитель, альбом, год, обложка) |
| POST | `/admin/uploads/review` | admin | `{file_ids:[], decision: "approve"|"reject", reason?}` — массовое решение |
| POST | `/admin/uploads/files/{id}/retry` | admin | Повторить импорт после `import_failed` |
| GET · PATCH | `/admin/settings/uploads` | admin | Лимиты очереди и хранилище по умолчанию |
| GET | `/admin/audit?cursor=` | admin |
## Внутренние API
+5
View File
@@ -103,6 +103,11 @@ container:
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
```
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=7g` без свопа, Gradle `-Xmx2g`, Metro `--max-old-space-size=1536`, таймаут 60 мин, CMake / ninja / Gradle
по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
Без `valid_volumes` раннер пропускает тома из `release.yml`, и каждый релиз заново качает Gradle
и NDK (~4 мин). Признак выключенного кэша actions в логах — `Cache action is only supported on GHES…`.
+2 -2
View File
@@ -109,14 +109,14 @@ CREATE TABLE media_files (
track_id uuid, -- FK ставится после создания tracks
missing_since timestamptz, -- файл пропал при скане
scan_error text,
fingerprinted_at timestamptz, -- Chromaprint посчитан (или fpcalc не смог); NULL — в очереди
UNIQUE (source_id, rel_path)
);
CREATE INDEX ON media_files (content_hash);
CREATE INDEX ON media_files (sha256);
CREATE TABLE track_fingerprints ( -- Chromaprint, для поиска дублей при загрузке
file_id uuid PRIMARY KEY REFERENCES media_files(id) ON DELETE CASCADE,
track_id uuid,
file_id uuid PRIMARY KEY REFERENCES media_files(id) ON DELETE CASCADE, -- трек — через media_files
duration_ms int NOT NULL,
fingerprint int[] NOT NULL, -- raw-отпечаток fpcalc
fp_prefix bigint NOT NULL -- первые 64 бита — грубый индекс для отбора кандидатов
+33
View File
@@ -170,3 +170,36 @@ CI на каждый PR собирает все образы без push, что
**Последствия.** Серверу нужен доступ к репозиторию и интернет на время сборки; первая сборка — 5–15 минут,
нужно ~5–10 ГБ под кэш сборки. Откат — `update.sh` на предыдущий тег (пересборка из кэша). Отпадают секреты
`REGISTRY_USER` / `REGISTRY_TOKEN`. Заменяет часть ADR-001 / ci-cd о публикации образов.
---
## ADR-014. Анализатор v1 — признаки на numpy, Essentia позже
**Контекст.** Нейросетевые модели Essentia (Discogs-EffNet, жанры, настроения) требуют essentia-tensorflow:
тяжёлый образ, ограниченный набор платформ и версий Python. Волне v1 нужны звучание и базовые признаки уже сейчас.
**Решение.** Анализатор `basic-1` (`services/recommender/pipu_rec/analysis/features.py`) считает признаки сам,
на numpy поверх декодирования ffmpeg: 3 фрагмента по 30 с; эмбеддинг 256 = форма спектра, разброс, динамика
и ритмичность по 64 mel-полосам (рекомендатель стандартизирует его по библиотеке); темп по автокорреляции онсетов;
тональность по хромаграмме; громкость, энергия, танцевальность, акустичность, длина вступления.
Valence — грубая оценка (лад + яркость); `mood_*`, `arousal`, жанры анализа не заполняются.
**Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность
и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия
`analyzer_ver`: воркер сам ставит пересчёт всех треков.
---
## ADR-015. Приём загрузок — ядро tus своими силами, без tusd
**Контекст.** ADR-012 предполагал `tusd` как библиотеку. Но файл очереди создаёт `POST /uploads` вместе с резервом
квоты и проверкой дублей, а права (только автор, только `uploading`) проверяет `api`. От tus остаётся только ядро
протокола: `HEAD` (сколько принято) и `PATCH` (следующий кусок с `Upload-Offset`).
**Решение.** Ядро tus 1.0.0 — в `internal/uploads/tus.go` (~200 строк): смещение — фактический размер файла в staging,
один `PATCH` на файл одновременно, данные сверх заявленного размера не принимаются, куски до 64 МБ. Расширения
creation / termination / checksum не нужны. Клиент — `tus-js-client` с готовым `uploadUrl` из ответа `POST /uploads`.
**Последствия.** Нет новой зависимости и отдельного хранилища состояния tus; квота и права — в одной транзакции
с созданием файла. Целостность проверяется после приёма: SHA-256 файла сверяется с заявленным клиентом.
+15 -1
View File
@@ -97,7 +97,21 @@
## Бэкапы
- `pg_dump` ежедневно (cron-контейнер `backup`), хранить 14 дней + еженедельные 8 недель → `/srv/pipu/backups`.
- Контейнер `backup` (`deploy/backup.sh loop`): каждый день в `BACKUP_HOUR`:30 UTC (по умолчанию 3:30) —
`pg_dump -Fc` → `/srv/pipu/backups/daily/`, хранится `BACKUP_KEEP_DAILY` = 14 последних; первая копия за неделю
копируется в `weekly/` (`BACKUP_KEEP_WEEKLY` = 8). Рядом с копией — `*.counts`: число строк в каждой таблице.
- Раз в неделю копия **проверяется восстановлением**: разворачивается во временную базу `pipu_verify`, сверяются
таблицы и число строк, база удаляется; время последней успешной проверки — `backups/.last-verify`, ошибки —
в `docker compose logs backup` («ПРОВЕРКА НЕ ПРОЙДЕНА»). Тот же цикл копия → проверка → восстановление
выполняется в CI (`ci / db`) на базе после сквозного сценария.
- `update.sh` перед обновлением делает внеочередную копию.
- Вручную: `docker compose run --rm backup once` — копия сейчас; `docker compose run --rm backup verify /backups/daily/<файл>`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/pipu-….dump` — останавливает api, рекомендации и
анализ, проверяет копию, переименовывает текущую базу в `pipu_before_restore_<время>` (страховка; удалить —
`docker compose exec postgres dropdb -U pipu <имя>`), восстанавливает и запускает сервисы; поиск переиндексируется
при старте api. Redis (сессии волны, очереди) не восстанавливается — пользователи просто начнут новую волну.
- Копии лежат на том же сервере: для защиты от потери диска синхронизируйте `/srv/pipu/backups` на другую машину
(например, `rclone sync` или `rsync` по cron).
- Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ).
- Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен.
- Сама музыка — на NFS, вне зоны ответственности приложения.
+2 -1
View File
@@ -48,8 +48,9 @@
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
| `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении |
| `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки |
| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#737F9C` | `#0A0F1E` / `#4B5675` / `#677290` | Текст: основной / вторичный / подписи |
| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#7F8BA8` | `#0A0F1E` / `#4B5675` / `#5C6785` | Текст: основной / вторичный / подписи |
| `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки |
| `accent-fill` / `accent-fill2` | `#1A66E6` / `#1F6FF2` | `#1A63DB` / `#165ED6` | Заливка под белым текстом (кнопки, выбранные сегменты) и её hover — контраст ≥ 4.5:1 |
| `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус |
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
+11 -2
View File
@@ -314,12 +314,21 @@ cd /opt/pipu-music/deploy && ./update.sh
пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются
автоматически при старте API.
Бэкап базы (всё остальное восстанавливается само — см. [deployment.md](deployment.md#бэкапы)):
Бэкапы базы делаются сами: каждую ночь в `/srv/pipu/backups`, раз в неделю копия проверяется восстановлением,
перед каждым `update.sh` — внеочередная копия. Всё остальное восстанавливается само — см.
[deployment.md](deployment.md#бэкапы). Копия сейчас и восстановление:
```bash
docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump
cd /opt/pipu-music/deploy && docker compose run --rm backup once
```
```bash
cd /opt/pipu-music/deploy && ./restore.sh
```
Без аргумента `restore.sh` показывает список копий; с именем файла (`./restore.sh daily/pipu-20261020-033000.dump`)
восстанавливает базу, сохранив прежнюю рядом.
Остановить сервис, сохранив данные:
```bash
+22 -5
View File
@@ -35,11 +35,26 @@
`fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента.
- Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`,
задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`.
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет запись: создаёт и удаляет
`.pipu-write-test` в корне.
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет право записи — без создания файлов.
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
**Реализация.**
- Агент: `POST /internal/files` `{source_id, local_path?, rel_path, staging_path, sha256}` → `{rel_path, size, renamed}`;
`POST /internal/files/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/<id>` (каталог
внутри контейнера агента), а в `/library/<id>` — bind этой точки с `remount,ro`. Local-источники пишутся через
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). На музыкальный диск не пишется ничего,
кроме самих треков: файл создаётся сразу под итоговым именем (`O_EXCL` — существующий не перезаписывается;
занято — ` (2)`, ` (3)`…), `fsync`, сверка SHA-256; при ошибке агент удаляет свой недописанный файл и созданные
для него пустые каталоги. Проверка записи — `access(W_OK)` и флаг read-only файловой системы, без пробного файла.
Остальные сервисы видят музыку только на чтение (`read_only` в compose).
Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять).
- Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через
`regexp/syntax`: именованные группы → поля, `(?:(?P<disc>\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`,
хвост `\.\w+$` → `.{ext}`, ширина `{track:0N}` — по выборке), `meta.CheckTemplate` (50 случайных треков источника).
- API: `accepts_uploads`, `write_template` в `Source`; `POST /admin/sources/{id}/write-template/suggest|check`;
проверка подключения для принимающего источника дополнительно возвращает `write_ok`. UI — страница шаблонов путей.
## Шаблоны путей (регэкспы)
Для каждого источника — упорядоченный список регулярных выражений (синтаксис RE2 / Go `regexp`)
@@ -104,7 +119,7 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
| `scan_source(full=true)` | Перечитать теги / шаблоны у всех файлов |
| `scan_path(source_id, rel_path)` | Точечный скан одного файла после импорта из очереди |
| `extract_cover(album_id)` | Новый альбом |
| `fingerprint_file(file_id)` | Новый / изменённый файл: полный SHA-256 + Chromaprint (`fpcalc`) — для проверки дублей при загрузке; низкий приоритет |
| `fingerprint_file(file_id)` | Новый / изменённый файл: полный SHA-256 + Chromaprint (`fpcalc -raw`, первые 120 с) — для проверки дублей при загрузке; низкий приоритет. Реализация — `library.Fingerprinter`: один проход по файлам с `sha256 IS NULL` / `fingerprinted_at IS NULL` после каждого скана и при старте, по одному файлу. Без `fpcalc` (`FPCALC_PATH`) считается только SHA-256. Изменение содержимого файла сбрасывает отпечатки. Сравнение — `library.Similarity` (доля совпавших бит лучшего выравнивания, сдвиг ±80 точек; ≥ 0.75 — тот же трек) |
| `reindex_search` | Вручную / после крупного скана |
Алгоритм инкрементального скана:
@@ -146,7 +161,9 @@ Meilisearch, индексы:
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
отменяет предыдущий запрос (`AbortController`).
- Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше
в выдаче `suggest` (re-rank первых 20 на стороне `api`).
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
(`internal/search/personal.go`). Вес объекта — прослушивания + 2 × доигрывания − ранние скипы, лайк трека ±10,
реакция на исполнителя ±20; альбом и исполнитель суммируют вес своих треков. Знакомое поднимается на
2·ln(1+вес) позиций, нелюбимое опускается на 3 — релевантность остаётся основой порядка.
- Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача
`reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).
+28 -6
View File
@@ -4,13 +4,13 @@
| Задача | Выбор |
|---|---|
| Фреймворк | React Native (New Architecture) + Expo SDK, **prebuild / dev client** (нативный код под контролем) |
| Фреймворк | React Native + Expo SDK, **prebuild / dev client** (нативный код под контролем). New Architecture выключена, пока `react-native-track-player` её не поддерживает |
| Навигация | `expo-router` (файловые маршруты) |
| Аудио | `react-native-track-player` v4 — ExoPlayer, MediaSession, уведомление, экран блокировки, Android Auto-совместимость |
| Анимация волны | `@shopify/react-native-skia` (порт `wave.js`, общие формулы в `packages/core/wave-anim`) + `react-native-reanimated` |
| Анимация волны | `@shopify/react-native-skia` + Reanimated: кадр собирается в UI-потоке (`createPicture` в `useDerivedValue`), формулы — общие с вебом (`packages/core/src/wave-anim.ts`, worklet'ы), отрисовка — `src/ui/wave/draw.ts`; вне экрана и в фоне кадры не считаются |
| Серверное состояние | TanStack Query (+ persist в MMKV) |
| Клиентское состояние | Zustand (общие сторы из `packages/core`) |
| Хранилище | `react-native-mmkv` (настройки, кэш), `expo-sqlite` (офлайн-каталог, очередь событий), `expo-secure-store` (refresh-токен) |
| Хранилище | `expo-secure-store` (refresh-токены, список серверов, недавние запросы); `expo-sqlite` — офлайн-каталог и очередь событий (вместе с загрузками) |
| Загрузки | `expo-file-system` (фоновые загрузки, resumable) |
| Сеть | `@react-native-community/netinfo` (Wi-Fi / сотовая → качество) |
| i18n | i18next, словари из `packages/i18n` |
@@ -35,6 +35,13 @@
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
«только Wi-Fi для загрузок», кэш недавних треков (размер + «Очистить»), проверка обновлений (точка, если есть новая версия),
текущий сервер, «Админка — откроется в браузере» (для admin / moderator), выход.
- **Тема**: `ui_prefs.theme` (dark / light / system); цвета берутся через `useColors()` из контекста темы (`src/theme.ts`),
до входа — по настройке телефона.
- **Качество звука** хранится на устройстве (`src/lib/prefs.ts`, SecureStore): Wi-Fi (по умолчанию high), сотовая (medium),
загрузки (high) → параметр `q` стрима.
- **Кэш недавних треков** — кэш ExoPlayer (`cacheDir/TrackPlayer`, 500 МБ). Пока сервис плеера жив, SimpleCache держит
индекс в памяти, поэтому «Очистить» помечает кэш, а удаляется он при следующем холодном запуске до `setupPlayer`.
Путь каталога взят из KotlinAudio — проверить на устройстве.
- **Офлайн-баннер** вверху, когда нет связи с сервером.
- Админку в мобильном **не делаем** на первом этапе (ссылка «открыть в браузере»).
@@ -62,6 +69,17 @@
- реакции (лайк / дизлайк) — оптимистично локально + очередь мутаций.
- Подписанные ссылки не кэшируются — при восстановлении сети запрашиваются заново.
**Реализация** (`src/offline/`):
- `downloads.ts` — очередь загрузок: таблица `downloads` (DTO трека JSON + статус), файлы в
`documentDirectory/offline/<owner>/`, обложка 300 px рядом; `owner` — хэш «сервер + пользователь».
Перед загрузкой берётся свежий DTO (`POST /tracks/batch`), т.к. подписанная ссылка живёт 12 ч.
- `events.ts` — sink для `EventBuffer`: при сетевой ошибке пачка уходит в `pending_events` и досылается при появлении сети.
Реакции так же: без сети — в `pending_reactions` (по треку хранится последняя), UI и DTO скачанного обновляются сразу;
ответ сервера 4xx откатывает реакцию в UI, при досылке — отбрасывает отложенную.
- Запуск без сети: `AuthSession.restore()` берёт последнего пользователя из `RefreshStore.getUser` (состояние
`signed_in` с `offline: true`); access-токен получается перед первым запросом, когда сеть вернётся.
- Офлайн-волна: если `POST /wave/sessions` не удался — перемешанные скачанные треки без дизлайков, лайки ближе к началу.
## Самообновление
Приложение распространяется только через Gitea Releases (не Google Play).
@@ -75,13 +93,17 @@
2. Если `version_code` новее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже».
Если текущая версия ниже `min_supported` — обновление обязательно.
3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза).
4. Установка: `Intent.ACTION_VIEW` с `content://` URI через `FileProvider` и флагом `FLAG_GRANT_READ_URI_PERMISSION`
(небольшой локальный Expo-модуль `modules/apk-installer` на Kotlin; при необходимости — `PackageInstaller` session API).
Хэш считается кусками по 1 МБ (`js-sha256`), APK целиком в память JS не читается. Загрузка идёт с `Bearer`,
сервер отдаёт файл с поддержкой `Range` и сам сверяет SHA-256 при кэшировании из Gitea.
4. Установка: `Intent.ACTION_VIEW` с `content://` URI (`FileSystem.getContentUriAsync` — FileProvider expo-file-system)
и флагом `FLAG_GRANT_READ_URI_PERMISSION` через `expo-intent-launcher` (`src/lib/update.ts`).
5. Если нет разрешения «установка из неизвестных источников» — открываем `Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES`
для нашего пакета с пояснением.
**Требования:**
- В манифесте: `REQUEST_INSTALL_PACKAGES`, `FileProvider`.
- В манифесте: `REQUEST_INSTALL_PACKAGES` (FileProvider добавляет expo-file-system).
- Сервер: `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN`, кэш APK — `UPDATES_DIR` (`/updates`). Без них — `404 app.update_unavailable`,
приложение молча пропускает проверку.
- Все релизы подписываются **одним и тем же ключом** (keystore в секретах CI) — иначе Android откажет в обновлении.
- `versionCode` монотонно растёт: `major·1 000 000 + minor·10 000 + patch·100 + (99 | rc)` (см. [ci-cd.md](ci-cd.md#версионирование)).
- Ассеты релиза: `pipu-music-<version>.apk`, `pipu-music-<version>.apk.sha256`.
+25 -2
View File
@@ -152,6 +152,16 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
Обучение — ночью; новая модель активируется, только если офлайн-NDCG@10 не хуже активной более чем на 2%.
Пока размеченных показов меньше ~20k, используется v1 (переключение автоматическое по порогу).
Реализация (`pipu_rec/train/ranker.py`): при каждом показе волна пишет вектор признаков кандидата
в `rec_impression_features` (29 признаков, `FEATURE_NAMES` в `wave/engine.py`; порядок версионируется
`FEATURES_VERSION`). Ночью признаки соединяются с исходами `wave_impressions`, группа — сессия волны;
валидация — сессии последних 7 дней. Модель включается, если её NDCG@10 не ниже порядка по оценке v1
и не хуже активной более чем на 2%. Модель хранится строкой в `model_versions.artifact` (kind `ranker`),
сервис загружает активную при старте и после ночного обучения; в выдаче её порядок заменяет оценку v1,
MMR-разнообразие и квоты остаются. Пока модели нет, `model_ver` показа — версия v1.
Онлайн-сравнение — админка «Библиотека → Моя волна»: доля доигрываний, ранних скипов и лайков
за 7 дней отдельно по каждому `model_ver` показа.
### 6.4 Занятия и настроения
Каждый контекст — профиль целевых значений аудиопризнаков (`services/recommender/config/contexts.yaml`,
@@ -191,6 +201,11 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
что одному пользователю лучше заходит `fresh`, а другому — `rediscover`. Параметры затухают
(×0.99 в день), чтобы подстраиваться под смену вкуса.
Реализация (`pipu_rec/train/bandit.py`): параметры пересчитываются ночью по `wave_impressions` за 90 дней
(награда — `complete` / `like`) в `user_profiles_rec.bandit`; при сборке пачки источник получает бонус
`0.2·(θ − 0.5)`, где θ ~ Beta(α, β) (источник без наблюдений — нейтральные 0.5). `exploration` следует за
успехом `fresh` относительно остальных источников (0.05–0.4).
### 6.7 Реакция в реальном времени
- Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2.
@@ -204,14 +219,22 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
- **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды.
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
(по одному треку из разных кластеров), exploration 40% на первые 30 треков.
- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` —
не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор —
пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1,
жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны.
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
## 8. Обучение на всех пользователях
Ночной пайплайн (03:00, APScheduler в `recommender`):
1. Пересчёт `user_track_stats.implicit_score` (затухание), artist / genre affinity.
2. **ALS** (`implicit`, 64 фактора, confidence `c = 1 + 20·max(score, 0)`, отрицательные — через исключение
и штраф в ранжировании) → `track_embeddings.cf`, `user_profiles_rec.taste_cf`.
2. **ALS** (свой на numpy — `pipu_rec/train/als.py`, 64 фактора, confidence `c = 1 + 20·score` для положительных;
отрицательные — предпочтение 0 с уверенностью `1 + 10·|score|`) → `track_embeddings.cf`, `user_profiles_rec.taste_cf`.
Проверка: последние 7 дней — тест (впервые понравившиеся треки), NDCG@10 / Recall@50 против популярности;
новая модель активируется, если NDCG@10 не хуже активной более чем на 2%. В волне ALS даёт источники `cf`
и `item_cf` и слагаемое `0.10·cf_score`; версия модели в показах — `heuristic-v1+cf`.
Запуск: `TRAIN_HOUR` (по умолчанию 3, `-1` — выключено), вручную — `pipu-rec train`.
3. Кластеризация вкусов (центроиды) для каждого пользователя.
4. Обучение LightGBM-ранжировщика на показах за 90 дней (time-split: последние 7 дней — валидация).
5. Проверка метрик, запись `model_versions`, горячая подмена моделей в памяти сервиса.
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more