Compare commits

...
123 Commits
Author SHA1 Message Date
mrleo1nid f087ec9f88 fix(ci): serialize integration suites sharing a database
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m17s
ci / db / db (push) Successful in 1m6s
release / android (APK в релиз) (push) Successful in 3m59s
release / prune (старые файлы и прогоны) (push) Successful in 6s
2026-10-23 12:55:53 +03:00
mrleo1nid cd84ceecb2 feat(social): show friend presence and require online listening
ci / api / api (push) Successful in 1m11s
ci / db / db (push) Failing after 23s
ci / ts / ts (packages + web) (push) Successful in 3m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m29s
2026-10-23 12:52:00 +03:00
mrleo1nid 582c0dc3d4 fix(ci): align onboarding assertion with generated enum
ci / db / db (push) Successful in 57s
ci / api / api (push) Successful in 58s
2026-10-23 12:03:23 +03:00
mrleo1nid 1a693848f2 Redesign social conversations and shared music
ci / ts / ts (packages + web) (push) Successful in 2m44s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 59s
ci / db / db (push) Failing after 18s
ci / api / api (push) Successful in 19m12s
ci / module boundaries / boundaries (push) Successful in 18m23s
2026-10-23 11:27:47 +03:00
mrleo1nid 11a16cd023 feat(social): add listening activity, shared rooms and modern chat
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / ts / ts (packages + web) (push) Canceled after 1s
ci / api / api (push) Canceled after 0s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / module boundaries / boundaries (push) Canceled after 0s
2026-10-23 10:45:40 +03:00
mrleo1nid 0a05bfe2cd feat(social): add friends, private chats, inbox and music sharing
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s
2026-10-23 03:19:29 +03:00
mrleo1nid f1528de948 feat(admin): transfer user accounts and show local cover coverage
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / db / db (push) Successful in 2m50s
2026-10-23 02:17:06 +03:00
mrleo1nid 0a4cd4bff1 fix(library): parallelize cover processing and retry missing artwork
ci / db / db (push) Successful in 1m1s
ci / api / api (push) Successful in 50s
2026-10-23 02:02:36 +03:00
mrleo1nid eed7038109 fix(web): hide browser URL preview for player artists
ci / ts / ts (packages + web) (push) Successful in 4m37s
2026-10-23 00:49:14 +03:00
mrleo1nid 7fa7ee2c7d fix(web): follow server job log output automatically
ci / ts / ts (packages + web) (push) Successful in 2m14s
2026-10-23 00:35:39 +03:00
mrleo1nid 24707a0bfc fix(admin): mask all stored configuration secrets and credential URLs
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m15s
ci / ts / ts (packages + web) (push) Successful in 2m19s
2026-10-23 00:27:20 +03:00
mrleo1nid a6c9fd8a00 fix(ui): reflect update availability and config changes; expand mobile player metadata
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m39s
ci / api / api (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m0s
ci / db / db (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / ts / ts (packages + web) (push) Successful in 2m40s
2026-10-23 00:20:59 +03:00
mrleo1nid bc55528afd fix(deploy): provision management agent during server updates
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m21s
2026-10-22 23:43:11 +03:00
mrleo1nid a29a8b18ad feat(admin): manage server updates and local configuration
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / api / api (push) Successful in 52s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
2026-10-22 23:01:36 +03:00
mrleo1nid e33abc948c docs(agents): limit local checks to affected areas 2026-10-22 22:46:44 +03:00
mrleo1nid ab49e22357 feat(desktop): add offline cache and verified app updates
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / desktop / rust (desktop proxy) (push) Successful in 4m21s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m0s
release / desktop (Linux и Windows в релиз) (push) Successful in 11m49s
release / prune (старые файлы и прогоны) (push) Successful in 5s
ci / module boundaries / boundaries (push) Successful in 7s
ci / ts / ts (packages + web) (push) Successful in 3m8s
Clean release notes in Android and desktop update dialogs. Persist downloaded audio and offline playback events per server and user; add desktop update checks, checksum verification and installer launch.
2026-10-22 22:37:35 +03:00
mrleo1nid 75ae2092ad feat(library): exclude configured folders from scanning and analysis
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / recommender / recommender (push) Failing after 40s
ci / ts / ts (packages + web) (push) Successful in 7m20s
ci / api / api (push) Successful in 56s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / db / db (push) Successful in 1m8s
2026-10-22 15:51:15 +03:00
mrleo1nid 0dc0384c78 fix(library): expose scan activity and failed analysis file details
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
release / android (APK в релиз) (push) Successful in 3m50s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
release / release (версия и заметки) (push) Successful in 5s
release / prune (старые файлы и прогоны) (push) Successful in 7s
ci / api / api (push) Successful in 55s
ci / db / db (push) Successful in 52s
2026-10-22 15:25:48 +03:00
mrleo1nid 17b6dd2041 fix(web): clarify Genius processing progress and queue actions
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / ts / ts (packages + web) (push) Successful in 5m23s
2026-10-22 15:05:11 +03:00
mrleo1nid 0a59059738 feat(playlists): share playlists with revocable read-only links
ci / ts / ts (packages + web) (push) Successful in 2m19s
ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
release / android (APK в релиз) (push) Successful in 4m45s
2026-10-22 14:57:14 +03:00
mrleo1nid bfcf6e48b2 fix: harmonize wave colors from album covers
ci / ts / ts (packages + web) (push) Canceled after 5m28s
ci / module boundaries / boundaries (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
2026-10-22 14:52:57 +03:00
mrleo1nid 02307802bc style(recommender): format demographic integration test
ci / recommender / recommender (push) Successful in 37s
release / prune (старые файлы и прогоны) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m54s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 54s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m38s
2026-10-22 14:25:11 +03:00
mrleo1nid fff91c446b feat(recommendations): collect demographics and advance age cohorts
ci / ts / ts (packages + web) (push) Successful in 3m19s
ci / db / db (push) Successful in 53s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 51s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
2026-10-22 14:19:52 +03:00
mrleo1nid e158558da3 refactor: isolate playback and upload use cases
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / module boundaries / boundaries (push) Successful in 6s
2026-10-22 13:52:16 +03:00
mrleo1nid c84f90e2cc fix(web): scope playlist creation locator in e2e test
ci / ts / ts (packages + web) (push) Successful in 2m1s
2026-10-22 12:57:25 +03:00
mrleo1nidandCursor 72567acd28 feat(web): polish catalog motion, empty states, and mobile now-playing
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
release / prune (старые файлы и прогоны) (push) Successful in 6s
release / release (версия и заметки) (push) Successful in 6s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:53:37 +03:00
mrleo1nidandCursor 39aaaed969 feat(web): list previous app builds on the download page
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 2m1s
ci / db / db (push) Successful in 1m4s
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 58s
GET /app/clients now returns up to 8 Gitea releases per OS so users can download older installers while they still exist after prune.

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:45:03 +03:00
mrleo1nidandCursor fa79394869 feat: add personal playlists and shareable track links
release / android (APK в релиз) (push) Successful in 4m8s
release / release (версия и заметки) (push) Successful in 6s
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:37:53 +03:00
mrleo1nid 3a097c15c8 perf(web): preload restored audio before playback
ci / ts / ts (packages + web) (push) Successful in 2m20s
release / android (APK в релиз) (push) Successful in 3m43s
release / release (версия и заметки) (push) Successful in 5s
2026-10-21 22:49:10 +03:00
mrleo1nid 20abe20686 fix(mobile): prevent crash when restoring a saved playback queue
release / release (версия и заметки) (push) Successful in 9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m32s
ci / ts / ts (packages + web) (push) Successful in 2m49s
release / android (APK в релиз) (push) Successful in 4m20s
2026-10-21 18:41:48 +03:00
mrleo1nid c34272d508 fix(auth): allow admins to change any username including their own
ci / ts / ts (packages + web) (push) Successful in 2m59s
ci / api / api (push) Successful in 49s
ci / db / db (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
2026-10-21 17:33:19 +03:00
mrleo1nid b0cbd0f149 fix: repair Android startup and improve mobile web layouts
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m17s
release / android (APK в релиз) (push) Successful in 4m15s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Successful in 2m22s
2026-10-21 17:14:05 +03:00
mrleo1nid 4c2286e70d feat(web): react wave and ambient visuals to live audio
ci / ts / ts (packages + web) (push) Failing after 2m2s
2026-10-21 16:44:04 +03:00
mrleo1nid 9223516547 fix(web): soften wave glow and follow liquid shape
ci / ts / ts (packages + web) (push) Successful in 2m25s
2026-10-21 16:33:22 +03:00
mrleo1nidandClaude Opus 5.5 7b7ce78de8 fix(web): align the upload queue settings fields
ci / ts / ts (packages + web) (push) Successful in 1m53s
Labels that wrapped to two lines pushed their inputs down, the storage
select had its own label style, and the checkbox sat in the middle of its
cell. The section now lays label, input and hint rows out with subgrid, and
the select no longer stretches past its column.

Also add a web-fake launch config that runs the web app on the fake API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:44:20 +03:00
mrleo1nidandClaude Opus 5.5 4d2c6ce22f fix(web): treat AIFF and WavPack as lossless in the upload format hint
ci / ts / ts (packages + web) (push) Successful in 2m11s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
The web check only accepted FLAC and WAV, so AIFF files were flagged
"needs attention" although the server already counts them as lossless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:35:43 +03:00
mrleo1nidandClaude Opus 5.5 dca7173a88 ci(ts): install Playwright system deps and cache the browser
ci / ts / ts (packages + web) (push) Successful in 3m30s
E2e failed to launch Chromium on the runner (missing libnspr4.so). Install
with --with-deps and cache ~/.cache/ms-playwright, since the primary download
host is unreachable from the runner and the fallback takes minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:21 +03:00
mrleo1nid 1e3b350dfc feat(upload): enhance audio upload handling and user feedback
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / recommender / recommender (push) Successful in 1m50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m16s
ci / api / api (push) Successful in 45s
ci / ts / ts (packages + web) (push) Failing after 6m53s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m13s
- Introduced a new warning for users uploading non-FLAC or WAV formats, indicating that these formats should only be used as a last resort.
- Added functionality to highlight files needing format attention in the upload queue.
- Updated the UI to provide clearer guidance on acceptable audio formats during uploads.
- Improved documentation to reflect changes in upload requirements and user interface updates.
2026-10-21 10:07:43 +03:00
mrleo1nidandClaude Opus 5.5 74da457362 feat(auth): make upload review a separate grant instead of an admin privilege
Reviewing the upload queue is now a per-user flag (reviews_uploads), not part
of the admin role: several people can hold it, a moderator can have it, and an
admin may not. Only admins grant or revoke it; existing admins keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:07:05 +03:00
mrleo1nidandCursor 066ba24834 feat: finish the upload, playback, and test gaps from the docs
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:37:53 +03:00
mrleo1nidandCursor 2b76060ea3 fix(web): keep menu labels on one line and fold the admin section by default
ci / ts / ts (packages + web) (push) Successful in 1m27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:06:33 +03:00
mrleo1nidandCursor dfc72b1d69 chore(deploy): leave database backups to external tools
ci / db / db (push) Successful in 1m40s
ci / ts / ts (packages + web) (push) Successful in 54s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 3m51s
The app no longer dumps Postgres, restores it, or checks copies in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:03:06 +03:00
mrleo1nidandClaude Opus 5.5 978f03b8f8 feat(admin): reset a user's recommendations from the users panel
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m10s
ci / ts / ts (packages + web) (push) Successful in 1m23s
ci / api / api (push) Successful in 40s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / db / db (push) Successful in 2m35s
- POST /admin/users/{id}/reset-recommendations clears the taste profile
  (onboarding, CF vector, bandit), play stats and wave impressions; likes,
  dislikes and history stay, wave sessions stay so a playing wave keeps going
- moderators may reset plain users, admins anyone including themselves;
  the reset is written to the audit log
- users panel gets the menu action with a confirmation; docs updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:52:31 +03:00
mrleo1nidandClaude Opus 5.5 b27b915b63 feat(web): settings to keep the search open and hide home pickers; collapsible menu sections
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / db / db (push) Successful in 2m23s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m44s
ci / api / api (push) Successful in 2m27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m43s
- appearance settings get "collapse search" (off keeps the field always open)
  and separate switches for the activity and mood pickers on the home page;
  stored in ui_prefs (search_collapse, home_activities, home_moods)
- Collection and Admin headers in the sidebar fold their items; the state is
  remembered in the browser, a folded Admin shows the review queue badge;
  icon-only and phone layouts always show every item

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:26:48 +03:00
mrleo1nidandClaude Opus 5.5 73e642c1ec fix(mobile): APK starts on Android 15+ 16 KB page devices and emulators
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m56s
release / android (APK в релиз) (push) Successful in 4m53s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m27s
ci / ts / ts (packages + web) (push) Successful in 1m48s
release / release (версия и заметки) (push) Successful in 8s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m2s
On an Android 16 emulator the release APK crashed at launch with
"Cannot read property 'NativeModule' of undefined": libexpo-modules-core.so
was missing for the ABI the device picked. Two build defects:

- librnskia.so was linked with 4 KB segment alignment (Skia 2.0.0-next.4
  lacks ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES), so 16 KB page devices cannot
  load it; a pnpm patch adds the flag
- the APK carried incomplete x86, x86_64 and armeabi-v7a folders from the
  React Native libraries; an Expo config plugin keeps only arm64-v8a, the ABI
  the native modules are built for (the APK also halves in size)

scripts/check-apk.mjs now fails the release on another ABI, a missing core
library or a library not aligned to 16 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:22:13 +03:00
mrleo1nidandClaude Opus 5.5 66f5749fc1 fix(deploy): take the Gitea address for Android updates from git remote
ci / api / api (push) Successful in 22s
ci / db / db (push) Successful in 2m4s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m11s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m33s
The server kept the git.example.lan placeholder from .env.example, so the
/android page and in-app updates had no release. install.sh and update.sh now
fill GITEA_URL and GITEA_REPO from the clone's origin (https, ssh and scp
forms, credentials stripped) when they are empty or still the placeholder,
and the API treats the placeholder as not configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:23:28 +03:00
mrleo1nidandClaude Opus 5.5 823d1eb841 fix(web): suggestions stay open when you return to the search quickly
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Successful in 1m1s
release / android (APK в релиз) (push) Successful in 4m10s
Leaving the field scheduled a close 120 ms later; coming back and typing
within that window let the old timer hide the fresh suggestions. Focus now
cancels the pending close. This also made the search box test flaky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:13:08 +03:00
mrleo1nidandClaude Opus 5.5 860072e542 feat(web): choose where the search sits, and controls no longer select text
ci / api / api (push) Successful in 36s
ci / db / db (push) Successful in 2m43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 59s
ci / ts / ts (packages + web) (push) Canceled after 12s
- settings get a search position (left, center by default, right); the top bar
  is three zones and the suggestions open towards the free space
- buttons, chips, tabs, menu items and the home screen controls are not
  text-selectable, so clicks and double clicks no longer highlight labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:11:06 +03:00
mrleo1nidandClaude Opus 5.5 d2f17d8d62 ci(db): fail early with a clear message when the runner disk is full
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 3m47s
ci / db / db (push) Successful in 45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:32 +03:00
mrleo1nidandClaude Opus 5.5 4763339335 ci(db): wait for the service containers before the tests
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:59:28 +03:00
mrleo1nidandClaude Opus 5.5 fda6ed48ab feat: Android download page, analysis retry and a collapsible search
ci / api / api (push) Successful in 37s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / db / db (push) Failing after 14s
ci / recommender / recommender (push) Successful in 51s
ci / ts / ts (packages + web) (push) Successful in 57s
- /android is a public page with the latest APK, the server address to copy
  and three install steps; linked from the sign-in screen and the profile menu
- failed audio analysis can be retried from the library page, which also shows
  the most common error; the analyzer no longer burns attempts on files that
  are missing because the disk is not mounted
- the top bar search folds into a round button and slides open on click, "/"
  or Ctrl+K

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:55:51 +03:00
mrleo1nidandClaude Opus 5.5 2d49cb3f19 fix(web): header dropdowns sit above the page again
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m45s
ci / ts / ts (packages + web) (push) Successful in 50s
Every shell child got the same z-index, so the page painted over the search
suggestions and the profile menu and swallowed their clicks: suggestions could
not be picked, and the language switch and settings item did nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:47:18 +03:00
mrleo1nidandClaude Opus 5.5 3da82e902a fix(library): survive a music disk that is not mounted
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
ci / db / db (push) Successful in 47s
release / release (версия и заметки) (push) Successful in 4s
ci / api / api (push) Successful in 29s
release / android (APK в релиз) (push) Successful in 3m20s
On the test server the host music folder was not mounted, so every stream and
fingerprint failed with no such file, and a scan of the empty folder would have
marked the whole library missing and purged it after 30 days.

- a scan of an empty source folder with known files aborts with a clear error
- fingerprinting skips such a source with one warning instead of one per file
- LIBRARY_HOST mounts use rslave, so a disk mounted after start shows up
- update.sh refuses to deploy with an empty LIBRARY_HOST

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:34:52 +03:00
mrleo1nidandClaude Opus 5.5 421b1a3eb9 ci: allow manual runs of the db and mobile checks
ci / db / db (push) Successful in 54s
release / android (APK в релиз) (push) Successful in 3m24s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 56s
release / release (версия и заметки) (push) Successful in 5s
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 / ts / ts (packages + web) (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 44s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 31s
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 / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m3s
ci / db / db (push) Failing after 52s
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 / ts / ts (packages + web) (push) Successful in 1m3s
ci / api / api (push) Successful in 29s
ci / db / db (push) Failing after 12s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m5s
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 / db / db (push) Successful in 30s
ci / api / api (push) Successful in 18s
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 / api / api (push) Successful in 1m25s
ci / recommender / recommender (push) Successful in 21s
ci / ts / ts (packages + web) (push) Successful in 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 48s
- 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 / android (APK в релиз) (push) Failing after 34m58s
release / release (версия и заметки) (push) Successful in 12s
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 / android (APK в релиз) (push) Successful in 9m5s
release / release (версия и заметки) (push) Successful in 10s
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 / recommender / recommender (push) Successful in 30s
ci / db / db (push) Failing after 1m8s
ci / api / api (push) Successful in 30s
- 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 / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m9s
ci / recommender / recommender (push) Successful in 36s
ci / api / api (push) Successful in 34s
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 / mobile / mobile (Metro-бандл Android) (push) Successful in 1m36s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 52s
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 / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
- 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 / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / ts / ts (packages + web) (push) Successful in 1m38s
ci / api / api (push) Successful in 46s
- 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 / 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 / api / api (push) Successful in 33s
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 / ts / ts (packages + web) (push) Canceled after 17m42s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
- 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 / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 57s
ci / ts / ts (packages + web) (push) Successful in 1m0s
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 / ts / ts (packages + web) (push) Successful in 1m20s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 35s
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 / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Successful in 1m14s
ci / api / api (push) Successful in 36s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 41s
- 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 / ts / ts (packages + web) (push) Successful in 51s
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
- /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 / recommender / recommender (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m26s
ci / api / api (push) Successful in 27s
- 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
mrleo1nidandClaude Opus 5.5 976ffb574b feat(web): player bar, playable track lists and Collection (M3)
ci / ts / ts (packages + web) (push) Successful in 53s
release / android (APK в релиз) (push) Successful in 2m33s
release / release (версия и заметки) (push) Successful in 6s
- player bar with cover, like and dislike, previous / play / next,
  shuffle, repeat, seek bar and volume; PlayerProvider lives with the
  signed-in shell and restores the last track and position paused
- clicking a row (or Enter) plays the list from that track with its
  context (album, search, history, collection); the current track is
  highlighted and rows have a like button; a reaction refreshes lists
- Collection: history grouped by day with clearing, likes with "play
  all" and dislikes, all with paging; sidebar section
- events and playback state are flushed when the page is hidden;
  player timers survive StrictMode remounts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:11:12 +03:00
mrleo1nidandClaude Opus 5.5 ec3c2810c8 feat(core,web): player queue, play events and web player controller (M3, wip)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m6s
ci / ts / ts (packages + web) (push) Successful in 1m5s
- core: PlayQueue with shuffle and repeat, PlayAttempt counting only
  time actually played, EventBuffer with batching and backoff, uuidv7
- web: Player over HTMLAudioElement with Media Session, preload of the
  next track, expired-link refresh, local and server playback state,
  hotkeys; PlayerProvider is not wired into the shell yet
- ru/en strings and icons for the player and Collection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:28:20 +03:00
mrleo1nidandClaude Opus 5.5 c05e523cbe feat(api): streaming, transcoding, events, history and likes (M3)
ci / api / api (push) Successful in 29s
ci / ts / ts (packages + web) (push) Failing after 49s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
- tracks carry a stream_url signed for the user for 12 h and the
  user's reaction; /stream checks the signature, expiry and that the
  user is not blocked, then hands the file to nginx by X-Accel-Redirect
  (or serves it with Range support when X_ACCEL=false)
- original by default; formats browsers can't play and explicit
  high/medium/low go through Opus transcoding, never upwards: a lossy
  source under the target bitrate stays original; transcodes are
  cached per file and size, deduplicated in flight, limited by
  TRANSCODE_CONCURRENCY and trimmed hourly by LRU to the size limit
- /events takes up to 500 events with client UUIDs, drops repeats and
  events older than 30 days, counts plays (≥ half or 4 min, or
  finished), early skips and history entries (≥ 30 s)
- history with clearing, likes and dislikes with cursors and totals,
  track reactions, playback state with last-write-wins, tracks batch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:25:15 +03:00
mrleo1nidandClaude Opus 5.5 8b76aa3964 feat: album covers with palettes and signed links (M2)
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 55s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 34s
ci / api / api (push) Successful in 26s
- covers are taken from the embedded picture of the first track or a
  cover/folder/front image in the album folder, converted by ffmpeg to
  WebP 64/300/1200 and named by content hash, so albums sharing a cover
  share the files; a full scan looks again for albums without one
- a 3-colour palette (dominant, second, most saturated accent) from a
  deterministic k-means feeds placeholders, page tints and the wave
- /covers/{id} is public but HMAC-signed and immutable; nginx serves
  the file via X-Accel-Redirect, X_ACCEL=false serves it from api
- the web client picks the size by display size and pixel density
- install.sh and update.sh generate STREAM_SIGNING_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:06:15 +03:00
mrleo1nidandClaude Opus 5.5 a685b1b7ca feat(web): storage admin, search and catalog pages (M2)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m59s
ci / ts / ts (packages + web) (push) Successful in 1m56s
- Storage admin: local folders with a folder picker and NFS sources,
  connection test, scan and full scan with live progress, enable,
  disable, delete
- path pattern editor with reorder, examples and a preview table that
  shows which pattern matched and what came from tags; compile errors
  land under the broken pattern
- Library page with counters, scans and unreadable files; search
  reindex for admins
- search box in the top bar: combobox with keyboard navigation, 150 ms
  debounce, cancelled stale requests, "/" and Ctrl+K, layout fix hint
- search page with tabs and paging, artist and album pages, album
  cards and track lists; unavailable tracks are dimmed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:01:52 +03:00
mrleo1nidandClaude Opus 5.5 c1160cccd3 feat(api): search with Meilisearch (M2)
ci / api / api (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
ci / ts / ts (packages + web) (push) Successful in 1m1s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 25s
- artists, albums and tracks indexes are rebuilt from PostgreSQL next
  to the live ones and swapped in, so search keeps working; a rebuild
  runs on start, after every scan and when a source is removed
- transliteration fields (Кино ↔ kino) and a retry in the other
  keyboard layout (ghbdtn → привет) reported as corrected_query
- /search/suggest (3 artists, 3 albums, 5 tracks), /search by type
  with cursor pages, /admin/search/reindex; Meilisearch is part of
  /readyz
- found ids are hydrated from the catalog, so a lagging index never
  returns deleted entries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:51:50 +03:00
mrleo1nidandClaude Opus 5.5 c583991d14 feat(api): library sources, scanner and catalog (M2)
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s
- schema for sources, path patterns, media files, artists, albums,
  tracks, genres (migration 00003)
- path patterns (RE2 with named groups, skip), merge with tags by
  source priority, fallbacks from the file name and folder, name
  normalization and multi-artist split
- scanner: walk, skip unchanged files by size and mtime, ffprobe in 8
  workers with a single catalog writer, renames by content hash keep
  the track, duplicates fold into one track with the best file as
  primary, missing files are kept 30 days
- storage-agent mounts NFS with validated server, export and an option
  whitelist, no shell; test mount counts audio files
- admin API: sources CRUD, connection test, patterns with preview,
  scan, library status, file errors, host folder picker; scheduled
  scans and health checks; mounts are restored on start
- catalog API: artist, artist albums, album with tracks, track
- api image is alpine with ffmpeg for ffprobe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:48:37 +03:00
mrleo1nidandClaude Opus 5.5 ab092def54 feat(web): sign-in, first-run setup, app shell and user admin (M1)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / ts / ts (packages + web) (push) Successful in 43s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 27s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 50s
- core: AuthSession keeps the access token in memory, refreshes it
  once for all parallel requests (single-flight) and retries them;
  refresh races between tabs are retried, reuse signs out; ApiError
  carries problem+json codes and field errors
- web: TanStack Router with guards — guests go to /login, the bootstrap
  admin to /setup/admin, temporary passwords to /setup/password, admin
  pages by role
- login, setup screens, Classic shell (top bar, role-based sidebar,
  empty player), settings: name, theme, language, password, devices
- users admin: search, role filter, create with a one-time temporary
  password, reset, block, role change, delete as the policy allows;
  audit log for admins
- ru/en strings for auth, admin and error codes; language and theme
  are saved in the profile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:33:46 +03:00
mrleo1nidandClaude Opus 5.5 f075863f15 feat(api): user management and audit log for admins (M1)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 38s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s
ci / api / api (push) Successful in 18s
- /admin/users: list with search, role filter and cursor pages; create
  with a one-time temporary password; rename, change role, block;
  reset password; delete
- every change is checked against the role policy, a forbidden field
  rejects the whole patch; the last active admin is locked in the
  transaction and cannot be demoted, blocked or deleted
- role change and blocking revoke access tokens at once; blocking and
  password reset also end all sessions
- /admin/audit: newest-first journal with actor names
- path and query parameter errors are problem+json now

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:18:02 +03:00
mrleo1nidandClaude Opus 5.5 ed4a492dba feat(api): sign-in, refresh rotation and bootstrap admin (M1)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
- users, refresh_tokens and audit_log schema (migration 00002)
- argon2id passwords with rehash on login, password policy, lockout
  with exponential back-off and a per-IP rate limit in Redis
- Ed25519 JWT access tokens with kid-based key rotation; rotating
  refresh tokens with reuse detection (cookie for web, body for Android)
- bootstrap admin on first start; must_change_credentials and
  must_change_password gate every other endpoint
- /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password,
  /me/bootstrap-credentials, /me/sessions; server-info reports
  bootstrap_required
- role policy from docs/auth-and-roles.md with table tests
- problem+json errors with field errors; ru/en translations of the codes
- install.sh and update.sh generate JWT_PRIVATE_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:12:25 +03:00
mrleo1nidandClaude Opus 5.5 b6def39e29 fix(deploy): probe readyz via 127.0.0.1 in update.sh
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 20s
In the alpine nginx image localhost resolves to ::1 first while nginx
listens on IPv4 only, so the readiness wait never succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:51:50 +03:00
mrleo1nidandClaude Opus 5.5 6fe76aea90 fix(deploy): mount the local music folder read-only in storage-agent
Upload import is not implemented yet, so nothing needs write access to
LIBRARY_HOST. Write access will come back together with the import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:51:50 +03:00
mrleo1nidandClaude Opus 5.5 0acdc3fbb3 fix(deploy): make install.sh and update.sh executable
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 21s
Scripts were committed without the executable bit, so ./install.sh failed
with "command not found" on a fresh clone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:42:11 +03:00
mrleo1nidandClaude Opus 5.5 a996b97694 fix(release): run the android job steps in bash
release / android (APK в релиз) (push) Successful in 1m55s
release / release (версия и заметки) (push) Successful in 4s
With a container block act runs steps through sh, which rejected the bash
array in the build step (v0.1.13: Syntax error: "(" unexpected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:38:33 +03:00
mrleo1nidandClaude Opus 5.5 b8cfd64208 fix(release): run React Native codegen before the APK build
release / android (APK в релиз) (push) Failing after 26s
release / release (версия и заметки) (push) Successful in 5s
The app's CMake configure reads JNI sources generated by library codegen
(react-native-edge-to-edge), but the RN Gradle plugin does not declare that
dependency. With a warm Gradle cache configureCMake started before codegen
and v0.1.12 failed. Run generateCodegenArtifactsFromSchema in a separate
Gradle call first; both calls share one daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:37:14 +03:00
mrleo1nidandClaude Opus 5.5 0d9418df48 ci(release): skip lintVitalRelease in the APK build
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Failing after 54s
lintVital runs Google Play pre-release checks on every module (~1 min);
the APK is only published to Gitea releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:34:39 +03:00
mrleo1nidandClaude Opus 5.5 bf3c00ca71 ci(release): rename the diagnostic steps of the Android build
"Free space" no longer described them: the pre-build step now also prints
memory and the size of the persistent Gradle/Android caches, and the
failure step reports disk, memory and OOM kills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:25:50 +03:00
mrleo1nidandClaude Opus 5.5 633a8f79af ci: cache turbo, npx and Metro; install web deps before copying code
ci / ts / ts (packages + web) (push) Successful in 52s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 21s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m41s
- ci-ts restores the local turbo cache, so unchanged packages skip
  typecheck, test and build
- ci-mobile caches the npx copy of expo-doctor and Metro's transform cache
- the web image copies only workspace manifests before pnpm install, so the
  dependency layer survives code changes (CI docker job and update.sh)
- docs: table of what is cached where

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:24:29 +03:00
mrleo1nidandClaude Opus 5.5 a239650075 fix(release): public links in release notes and a cancelled status
ci / ts / ts (packages + web) (push) Successful in 1m24s
release / release (версия и заметки) (push) Successful in 45s
release / android (APK в релиз) (push) Failing after 12m0s
The job reaches the Gitea API at its internal address, so html_url in the
response pointed at http://10.10.1.129:3000 and every link in the notes was
unreachable from outside. Build links from PUBLIC_URL (repo variable,
defaults to https://gitea.hsrv.site). A cancelled Android build now shows
"cancelled" instead of staying at "building".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:09:59 +03:00
mrleo1nidandClaude Opus 5.5 718125b719 ci: split CI by area with path filters and speed up releases
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 53s
ci / recommender / recommender (push) Successful in 38s
ci / ts / ts (packages + web) (push) Successful in 37s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m47s
ci / api / api (push) Successful in 54s
release / release (версия и заметки) (push) Successful in 7s
release / android (APK в релиз) (push) Canceled after 4m25s
- ci.yml becomes ci-api, ci-contract, ci-recommender, ci-ts, ci-mobile and
  docker workflows, each filtered by paths; docs-only commits run nothing
- docker builds all four images in one job with plain docker build on the
  runner host, so the layer cache survives between runs
- release no longer re-runs the test suite (the tagged commit already passed
  CI on main); version and release notes are one job
- the android job mounts persistent Gradle and Android SDK caches from
  /opt/ci-cache (when the runner allows the volumes) and uses --build-cache
- RELEASE_TOKEN is optional: fall back to the built-in job token
- docs: CI layout and act_runner settings (capacity, cache, valid_volumes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:55:45 +03:00
mrleo1nidandClaude Opus 5.5 d3b2e7bfc3 ci(release): publish the release first, then attach the APK
ci / ts (packages + web) (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 44s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 29s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / recommender (push) Successful in 37s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 1m33s
release / android (APK в релиз) (push) Skipped
release / verify (push) Successful in 1m19s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / api (push) Successful in 1m23s
release / release (заметки) (push) Failing after 5s
The release job now creates the Gitea release right after verification with
generated notes: Android build status, changes grouped by Conventional Commit
type with commit links, server update steps and APK install/check notes.
The android job attaches the APK and .sha256 and rewrites only the status
block (building / built with size, versionCode and SHA-256 / failed with a
log link). scripts/release.mjs is idempotent on re-runs and unit-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:45:21 +03:00
mrleo1nidandClaude Opus 5.5 622eb84bc9 fix(mobile): hoist expo-modules-autolinking instead of depending on it
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 47s
ci / ts (packages + web) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / api (push) Successful in 54s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
ci / recommender (push) Canceled after 8s
expo-doctor rejects expo-modules-autolinking as a direct dependency, which
failed the CI mobile job. Expose it through publicHoistPattern so Expo can
still require it from the apps/mobile/node_modules/expo symlink and the
autolinking import stays expo.modules.ExpoModulesPackage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:28:58 +03:00
520 changed files with 85538 additions and 6207 deletions

No files matched your search

+32 -3
View File
@@ -4,14 +4,43 @@
{
"name": "design",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"],
"runtimeArgs": [
"-m",
"http.server",
"8765",
"--bind",
"127.0.0.1",
"--directory",
"docs"
],
"port": 8765
},
{
"name": "web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@pipu/web", "dev"],
"runtimeArgs": [
"--filter",
"@pipu/web",
"dev"
],
"port": 5173
},
{
"name": "web-fake",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"--filter",
"@pipu/web",
"exec",
"vite",
"--port",
"4173",
"--strictPort"
],
"env": {
"VITE_E2E": "1"
},
"port": 4173
}
]
}
}
+32
View File
@@ -0,0 +1,32 @@
# Go: сервис api и storage-agent. Только при изменении их кода или контракта.
name: ci / api
on:
push:
branches: [main]
paths:
- 'services/api/**'
- 'api/openapi.yaml'
- '.gitea/workflows/ci-api.yml'
pull_request:
paths:
- 'services/api/**'
- 'api/openapi.yaml'
- '.gitea/workflows/ci-api.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
api:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/api } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go build ./cmd/...
+27
View File
@@ -0,0 +1,27 @@
name: ci / module boundaries
on:
push:
branches: [main]
paths:
- 'packages/core/src/**'
- 'apps/web/src/features/uploads/**'
- 'services/api/internal/uploads/**'
- 'scripts/check-boundaries.mjs'
- '.gitea/workflows/ci-boundaries.yml'
pull_request:
paths:
- 'packages/core/src/**'
- 'apps/web/src/features/uploads/**'
- 'services/api/internal/uploads/**'
- 'scripts/check-boundaries.mjs'
- '.gitea/workflows/ci-boundaries.yml'
jobs:
boundaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: node scripts/check-boundaries.mjs
+59
View File
@@ -0,0 +1,59 @@
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками.
name: ci / contract
on:
push:
branches: [main]
paths:
- 'api/openapi.yaml'
- 'services/api/internal/api/gen/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'packages/api-client/**'
- 'packages/design-tokens/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-contract.yml'
pull_request:
paths:
- 'api/openapi.yaml'
- 'services/api/internal/api/gen/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'packages/api-client/**'
- 'packages/design-tokens/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-contract.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
contract:
name: contract (сгенерированный код совпадает с openapi.yaml)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
run: |
(cd services/api && go generate ./...)
pnpm generate
- name: Проверка, что всё закоммичено
# status, а не diff: diff не видит новые неотслеживаемые файлы
run: |
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
+86
View File
@@ -0,0 +1,86 @@
# Интеграционные проверки SQL на настоящем PostgreSQL + pgvector: миграции вверх / вниз / вверх,
# запросы сканера, отпечатков, шаблонов и очереди, сквозной сценарий пользователя через HTTP (Go),
# загрузка каталога, профиля и обучение ALS (Python).
name: ci / db
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
- 'services/api/**'
- 'services/recommender/**'
- '.gitea/workflows/ci-db.yml'
pull_request:
paths:
- 'services/api/**'
- 'services/recommender/**'
- '.gitea/workflows/ci-db.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
db:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:0.8.7-pg17
env:
POSTGRES_USER: pipu
POSTGRES_PASSWORD: pipu
POSTGRES_DB: pipu
options: >-
--health-cmd "pg_isready -U pipu"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7.4-alpine
meilisearch:
image: getmeili/meilisearch:v1.11
env:
MEILI_MASTER_KEY: ci-master-key-0123456789
MEILI_ENV: development
env:
TEST_DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
TEST_REDIS_URL: redis://redis:6379/0
TEST_MEILI_URL: http://meilisearch:7700
TEST_MEILI_KEY: ci-master-key-0123456789
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
run: |
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
free=$(df -Pm / | awk 'NR==2 {print $4}')
echo "свободно на раннере: ${free} МБ"
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
for svc in postgres:5432 redis:6379 meilisearch:7700; do
host=${svc%:*}; port=${svc#*:}
for i in $(seq 1 60); do
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
echo "$svc готов"; break
fi
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
sleep 2
done
done
- name: Go — миграции и запросы store
working-directory: services/api
# These suites share one database; dbtest also rolls all migrations down
# and cleans orphan catalog rows. Do not run the packages concurrently.
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
working-directory: services/api
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
- name: Python — каталог, профиль, обучение ALS
working-directory: services/recommender
run: |
uv sync --frozen
uv run pytest -q tests/test_db_integration.py
+33
View File
@@ -0,0 +1,33 @@
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
name: ci / desktop
on:
push:
branches: [main]
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
pull_request:
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
name: rust (desktop proxy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
+52
View File
@@ -0,0 +1,52 @@
# Expo: expo-doctor и Metro-бандл Android. Полная сборка APK — только в release.yml.
name: ci / mobile
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
- 'apps/mobile/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-mobile.yml'
pull_request:
paths:
- 'apps/mobile/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-mobile.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
mobile:
name: mobile (Metro-бандл Android)
runs-on: ubuntu-latest
defaults: { run: { working-directory: apps/mobile } }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm }
- run: pnpm install --frozen-lockfile
# npx expo-doctor качает пакет в кэш npm; Metro хранит трансформации в /tmp/metro-cache
- uses: actions/cache@v4
with:
path: |
~/.npm/_npx
/tmp/metro-cache
key: mobile-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: mobile-${{ hashFiles('pnpm-lock.yaml') }}-
- run: npx expo-doctor
- run: npx expo export --platform android --output-dir dist
+31
View File
@@ -0,0 +1,31 @@
# Python: recommender и analyzer.
name: ci / recommender
on:
push:
branches: [main]
paths:
- 'services/recommender/**'
- '.gitea/workflows/ci-recommender.yml'
pull_request:
paths:
- 'services/recommender/**'
- '.gitea/workflows/ci-recommender.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
recommender:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/recommender } }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy pipu_rec
- run: uv run pytest -q
+65
View File
@@ -0,0 +1,65 @@
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза.
name: ci / ts
on:
push:
branches: [main]
paths:
- 'apps/**'
- 'packages/**'
- 'scripts/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-ts.yml'
pull_request:
paths:
- 'apps/**'
- 'packages/**'
- 'scripts/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-ts.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ts:
name: ts (packages + web)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm }
- run: pnpm install --frozen-lockfile
# Локальный кэш turbo: typecheck/test/build пропускаются для пакетов, чьи входы не менялись
- uses: actions/cache@v4
with:
path: .turbo/cache
key: turbo-ts-${{ github.sha }}
restore-keys: turbo-ts-
- run: pnpm i18n:check
- run: pnpm test:scripts
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @pipu/web build
# Браузер качается долго (основной CDN Microsoft из раннера недоступен) — кэшируем по lock-файлу.
# Системные библиотеки (libnspr4 и т.п.) в образе раннера нет, их ставит --with-deps на каждом прогоне.
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
- run: |
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
pnpm --filter @pipu/web exec playwright install chromium
- run: pnpm --filter @pipu/web test:e2e
-111
View File
@@ -1,111 +0,0 @@
# Проверки на каждый push в main и на каждый PR (docs/ci-cd.md).
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: "1.24"
NODE_VERSION: "22"
jobs:
contract:
name: contract (сгенерированный код совпадает с openapi.yaml)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
run: |
(cd services/api && go generate ./...)
pnpm generate
- name: Проверка, что всё закоммичено
# status, а не diff: diff не видит новые неотслеживаемые файлы
run: |
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
api:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/api } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go build ./cmd/...
recommender:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/recommender } }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy pipu_rec
- run: uv run pytest -q
ts:
name: ts (packages + web)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm i18n:check
- run: pnpm test:scripts
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @pipu/web build
docker:
name: docker (образы собираются — их сервер собирает сам при обновлении)
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { name: api, dockerfile: services/api/Dockerfile, target: api }
- { name: storage-agent, dockerfile: services/api/Dockerfile, target: storage-agent }
- { name: recommender, dockerfile: services/recommender/Dockerfile, target: "" }
- { name: web, dockerfile: apps/web/Dockerfile, target: "" }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
target: ${{ matrix.target }}
push: false
build-args: VERSION=ci
mobile:
name: mobile (Metro-бандл Android)
runs-on: ubuntu-latest
defaults: { run: { working-directory: apps/mobile } }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: npx expo-doctor
- run: npx expo export --platform android --output-dir dist
+63
View File
@@ -0,0 +1,63 @@
# Сборка всех Docker-образов без push: сервер собирает их сам при обновлении, и здесь ловятся поломки
# Dockerfile и зависимостей. Только при изменении Dockerfile, deploy/ и манифестов зависимостей.
name: ci / docker
on:
push:
branches: [main]
paths:
- '**/Dockerfile'
- '.dockerignore'
- 'deploy/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'services/recommender/pyproject.toml'
- 'services/recommender/uv.lock'
- 'apps/web/package.json'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/docker.yml'
pull_request:
paths:
- '**/Dockerfile'
- '.dockerignore'
- 'deploy/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'services/recommender/pyproject.toml'
- 'services/recommender/uv.lock'
- 'apps/web/package.json'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/docker.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
docker:
name: docker (образы собираются — их сервер собирает сам при обновлении)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Обновление сервера, агент и конфигурация
run: python3 -m unittest discover -s deploy -p 'test_*.py' -v
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
- name: Сборка образов
env: { DOCKER_BUILDKIT: "1" }
run: |
set -euo pipefail
build() { echo "::group::$1"; docker build --build-arg VERSION=ci -t "pipu-ci/$1" "${@:2}" .; echo "::endgroup::"; }
build api -f services/api/Dockerfile --target api
build storage-agent -f services/api/Dockerfile --target storage-agent
build recommender -f services/recommender/Dockerfile
build web -f apps/web/Dockerfile
+37
View File
@@ -0,0 +1,37 @@
# Ночные проверки, которым нужен поднятый стенд или эмулятор. На каждый push не запускаются.
name: nightly / e2e
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
jobs:
schemathesis:
name: schemathesis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install schemathesis
- name: Фаззинг API
env:
PIPU_API_URL: ${{ vars.PIPU_API_URL }}
PIPU_API_TOKEN: ${{ secrets.PIPU_API_TOKEN }}
run: |
if [ -z "$PIPU_API_URL" ]; then echo "PIPU_API_URL is not set"; exit 0; fi
sh tests/contract/schemathesis.sh
maestro:
name: maestro
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Сценарии Android
env:
PIPU_E2E_SERVER: ${{ vars.PIPU_E2E_SERVER }}
PIPU_E2E_USER: ${{ vars.PIPU_E2E_USER }}
PIPU_E2E_PASSWORD: ${{ secrets.PIPU_E2E_PASSWORD }}
run: |
if [ -z "$PIPU_E2E_SERVER" ]; then echo "emulator flow is configured in apps/mobile/.maestro"; exit 0; fi
curl -Ls "https://get.maestro.mobile.dev" | bash
~/.maestro/bin/maestro test apps/mobile/.maestro
+249 -66
View File
@@ -1,8 +1,14 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): проверки → подписанный APK → Gitea Release.
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
# и переписывают статус: собран / не удалась (scripts/release.mjs).
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
#
# Секреты (Settings → Actions → Secrets):
# RELEASE_TOKEN — создание релиза и загрузка ассетов (scope: write:repository)
# RELEASE_TOKEN (необязательно) — токен для релиза; без него — встроенный токен job (права на запись в репозиторий)
# Переменные (Settings → Actions → Variables):
# PUBLIC_URL (необязательно) — внешний адрес Gitea для ссылок в тексте релиза; по умолчанию https://gitea.hsrv.site
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD — подпись APK
name: release
@@ -11,11 +17,12 @@ on:
tags: ["v*.*.*"]
env:
GO_VERSION: "1.24"
NODE_VERSION: "22"
jobs:
verify:
# Тесты здесь не повторяются: тот же коммит уже проверен CI в main (ci-*.yml). Ставьте тег после зелёного CI.
release:
name: release (версия и заметки)
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.PIPU_VERSION }}
@@ -23,35 +30,59 @@ jobs:
prerelease: ${{ steps.v.outputs.PIPU_PRERELEASE }}
steps:
- uses: actions/checkout@v4
- id: v
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2" }
- uses: pnpm/action-setup@v4
# вся история и теги: заметки строятся по коммитам с прошлого релиза
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Go
working-directory: services/api
run: go vet ./... && go test ./...
- name: Python
working-directory: services/recommender
run: uv sync --frozen && uv run ruff check . && uv run mypy pipu_rec && uv run pytest -q
- name: TypeScript
run: pnpm i18n:check && pnpm test:scripts && pnpm typecheck && pnpm test
with: { node-version: "${{ env.NODE_VERSION }}" }
- id: v
name: Версия из тега
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
- name: Gitea Release
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.v.outputs.PIPU_VERSION }}
PRERELEASE: ${{ steps.v.outputs.PIPU_PRERELEASE }}
run: node scripts/release.mjs create
android-release:
name: android + release
needs: verify
android:
name: android (APK в релиз)
needs: release
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
timeout-minutes: 60
runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
# (container.valid_volumes: ['/opt/ci-cache/**']); иначе раннер их пропускает и всё качается заново.
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
defaults:
run:
shell: bash
container:
image: docker.gitea.com/runner-images:ubuntu-latest
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
options: --cpus=3 --memory=7g --memory-swap=7g
volumes:
- /opt/ci-cache/gradle:/root/.gradle
- /opt/ci-cache/android:/root/.android
env:
PIPU_VERSION: ${{ needs.verify.outputs.version }}
PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }}
PIPU_VERSION: ${{ needs.release.outputs.version }}
PIPU_VERSION_CODE: ${{ needs.release.outputs.version_code }}
# для scripts/release.mjs
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
VERSION_CODE: ${{ needs.release.outputs.version_code }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
# при перезапуске одной этой сборки статус «не удалась» сменится на «собирается»
- name: Статус в релизе — собирается
run: node scripts/release.mjs android building
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "17" }
- uses: android-actions/setup-android@v3
@@ -64,8 +95,11 @@ jobs:
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Свободное место
run: df -h / /tmp
# Диск, память и наполненность постоянных кэшей: пустые ~/.gradle и ~/.android значат, что тома не подключились
- name: Ресурсы раннера и кэши
run: |
df -h / /tmp; free -m
du -sh ~/.gradle ~/.android 2>/dev/null || true
- name: Сборка APK
working-directory: apps/mobile
@@ -73,16 +107,51 @@ 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.
# Kotlin компилируется внутри JVM Gradle (без второго демона, поэтому heap 2.5 ГБ вместо 2), задач — не больше 2.
# -x lintVitalRelease: lint-проверки для Google Play, а APK публикуется только в Gitea (−1 мин).
# Codegen отдельным вызовом до сборки: CMake приложения читает JNI-код, который генерирует codegen
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
run: |
set -o pipefail
npx expo prebuild --platform android --clean --no-install
cd android
./gradlew --no-daemon --max-workers=2 assembleRelease -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|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
./gradlew --stop || true
find ~/.gradle -name "*.lock" -delete || true
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
"${gradle[@]}" assembleRelease -x lintVitalRelease
else
exit 1
fi
fi
./gradlew --stop
- name: Свободное место после сбоя
# Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске
# на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86).
- name: Проверка APK (ABI, 16 КБ страницы)
run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
- name: Диагностика сбоя (диск, память, OOM)
if: failure()
run: |
df -h / /tmp; free -m || true
@@ -91,7 +160,7 @@ jobs:
- name: Подпись APK
env:
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
PRERELEASE: ${{ needs.release.outputs.prerelease }}
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
@@ -106,9 +175,11 @@ jobs:
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
rm release.keystore
echo "SIGNING=release" >> "$GITHUB_ENV"
elif [ "$PRERELEASE" = "true" ]; then
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
cp "$src" "$out"
echo "SIGNING=debug" >> "$GITHUB_ENV"
else
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
exit 1
@@ -117,41 +188,153 @@ jobs:
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
echo "APK=$out" >> "$GITHUB_ENV"
- name: Changelog
env:
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
run: |
# для финального релиза — изменения с прошлого финального, а не с последнего rc
exclude=()
[ "$PRERELEASE" = "true" ] || exclude=(--exclude '*-rc*')
prev="$(git describe --tags --abbrev=0 "${exclude[@]}" "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
range="${prev:+$prev..}${GITHUB_REF_NAME}"
{
echo "## Pipu Music ${PIPU_VERSION}"
echo
for t in feat fix perf refactor docs ci chore; do
lines="$(git log --no-merges --format='%s' "$range" | grep -E "^${t}(\(|:|!)" || true)"
[ -n "$lines" ] || continue
case $t in feat) h="Новое";; fix) h="Исправления";; perf) h="Производительность";; *) h="$t";; esac
echo "### $h"; echo "$lines" | sed 's/^/- /'; echo
done
} > CHANGELOG_RELEASE.md
cat CHANGELOG_RELEASE.md
- name: APK в релиз
run: node scripts/release.mjs android success "$APK"
- name: Gitea Release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
- name: Статус в релизе — сборка не удалась
if: failure()
run: node scripts/release.mjs android failure
# срабатывает при отмене из интерфейса; если раннер убит целиком, статус обновит следующий запуск
- name: Статус в релизе — сборка отменена
if: cancelled()
run: node scripts/release.mjs android cancelled
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
desktop:
name: desktop (Linux и Windows в релиз)
needs: [release, android]
if: always() && needs.release.result == 'success'
timeout-minutes: 90
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
CARGO_BUILD_JOBS: "2"
XWIN_CACHE_DIR: /opt/ci-cache/xwin
steps:
- uses: actions/checkout@v4
- name: Статус в релизе — собирается
run: |
node scripts/release.mjs desktop linux building
node scripts/release.mjs desktop windows building
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libdbus-1-dev libsecret-1-dev patchelf \
nsis lld llvm clang
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-xwin
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
restore-keys: cargo-desktop-
- run: pnpm install --frozen-lockfile
- name: Сборка deb
id: linux
working-directory: apps/desktop
run: |
set -euo pipefail
body="$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "Pipu Music $PIPU_VERSION" \
--rawfile notes CHANGELOG_RELEASE.md --argjson pre "$PRERELEASE" \
'{tag_name:$tag, name:$name, body:$notes, prerelease:$pre, draft:false}')"
id="$(curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "$body" "$API/releases" | jq -r .id)"
for f in "$APK" "$APK.sha256"; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" > /dev/null
echo "uploaded $(basename "$f")"
pnpm bundle:linux
bundle=src-tauri/target/release/bundle
shopt -s nullglob
for f in "$bundle"/deb/*.deb; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
done
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
rm -rf src-tauri/target/release
- name: Linux в релиз
id: linux-upload
run: |
set -euo pipefail
f="pipu-music-${PIPU_VERSION}-linux.deb"
if [ ! -f "$f" ]; then
echo "::error::нет $f после сборки"
exit 1
fi
node scripts/release.mjs desktop linux success "$f"
- name: Windows toolchain
if: ${{ !cancelled() }}
run: |
set -euo pipefail
rustup target add x86_64-pc-windows-msvc
if ! command -v cargo-xwin >/dev/null; then
cargo install --locked cargo-xwin
fi
if [ ! -d /opt/ci-cache ]; then
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
mkdir -p "$HOME/.cache/xwin"
else
mkdir -p /opt/ci-cache/xwin
fi
- name: Сборка NSIS
id: win
if: ${{ !cancelled() }}
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:windows:cross
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
shopt -s nullglob
copied=
for f in "$bundle"/*-setup.exe; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
copied=1
done
if [ -z "$copied" ]; then
echo "::error::нет NSIS setup.exe после сборки в $bundle"
ls -la "$bundle" || true
exit 1
fi
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Windows в релиз
id: win-upload
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Статус Linux — сборка не удалась
if: failure() && steps.linux-upload.outcome != 'success'
run: node scripts/release.mjs desktop linux failure
- name: Статус Windows — сборка не удалась
if: failure() && steps.win-upload.outcome != 'success'
run: node scripts/release.mjs desktop windows failure
- name: Статус в релизе — сборка отменена
if: cancelled()
run: |
node scripts/release.mjs desktop linux cancelled
node scripts/release.mjs desktop windows cancelled
prune:
name: prune (старые файлы и прогоны)
needs: [release, android, desktop]
if: always() && needs.release.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}" }
- name: Оставить 8 релизов и 8 прогонов
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: node scripts/release.mjs prune
+12
View File
@@ -20,6 +20,8 @@ __pycache__/
.ruff_cache/
# env / secrets
deploy/config.local
deploy/.management.lock
.env
*.pem
*.keystore
@@ -27,6 +29,8 @@ __pycache__/
# generated test audio
tests/fixtures/music/out/
apps/web/test-results/
apps/web/playwright-report/
# os / editors
.DS_Store
@@ -34,3 +38,11 @@ Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
.cache/
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/tauri.release.json
apps/desktop/release/
# Intermediate platform icon renders (final assets live under apps/).
/artifacts/icon-build/
+10
View File
@@ -0,0 +1,10 @@
# Работа с проектом
- Начинай с ближайшего локального `AGENTS.md` и файлов конкретной возможности. Карта документов: `docs/README.md`; реализация архитектуры: `docs/architecture.md`.
- Web/desktop UI: `apps/web/src`; Android: `apps/mobile/src`; общие правила клиентов: `packages/core/src`; Go API: `services/api/internal`; рекомендации: `services/recommender/pipu_rec`.
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
- Локально достаточно запускать проверки затронутой области и непосредственно связанных сценариев. Полный набор тестов и проверок повторно выполняется в CI/CD; не запускай все проверки проекта перед каждой сдачей без необходимости. Расширяй локальную проверку при изменении общих контрактов, межмодульных зависимостей или при обнаружении ошибок. Команды запускаются из корня, если не сказано иначе.
- Справочник полных проверок (выбирай локально только относящиеся к изменению): `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
+9 -7
View File
@@ -1,11 +1,11 @@
# pipu-music
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
- Документация для разработки: [docs/README.md](docs/README.md)
- План: [docs/roadmap.md](docs/roadmap.md)
- Документация: [docs/README.md](docs/README.md)
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
## Структура
@@ -16,11 +16,12 @@ Self-hosted музыкальный сервис: веб и Android-прилож
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
| `apps/web` | Веб-клиент (React + Vite) |
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
| `apps/mobile` | Android-приложение (React Native + Expo) |
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
| `tests/fixtures/music` | Генератор тестовой библиотеки (нужен ffmpeg) |
| `.gitea/workflows` | CI (`ci.yml`) и релиз по тегу (`release.yml`) |
| `.gitea/workflows` | CI по областям (`ci-*.yml`, `docker.yml`) и релиз по тегу (`release.yml`) |
## Разработка
@@ -34,7 +35,7 @@ docker compose -f deploy/compose.dev.yaml up -d
API (из `services/api`):
```bash
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" go run ./cmd/api
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" MEILI_URL="http://localhost:7700" MEILI_MASTER_KEY="dev-master-key-change-me" LIBRARY_HOST_ROOT="$PWD/../../tests/fixtures/music/out" X_ACCEL=false CACHE_DIR="$PWD/.cache" go run ./cmd/api
```
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
```bash
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
pnpm check:boundaries
```
```bash
@@ -65,6 +67,6 @@ pnpm generate && (cd services/api && go generate ./...)
## Релиз
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: проверки → подписанный APK → Gitea Release.
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты перечислены в шапке `.gitea/workflows/release.yml`.
+4512 -1
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(fileURLToPath(import.meta.url));
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
let version = process.env.PIPU_VERSION ?? '0.0.0';
if (!process.env.PIPU_VERSION) {
try {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
version = tag.replace(/^v/, '');
} catch {
// local untagged build
}
}
conf.version = version;
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
console.log(`desktop version ${version}`);
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@pipu/desktop",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev",
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles deb",
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
"tauri": "tauri",
"icons": "node ../../scripts/generate-icons.mjs"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.0"
}
}
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "pipu-desktop"
version = "0.0.0"
edition = "2021"
[lib]
name = "pipu_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "pipu-desktop"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["net", "sync", "time"] }
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
uuid = { version = "1", features = ["v4"] }
url = "2"
souvlaki = "0.8.3"
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "s"
panic = "abort"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "Only the bundled main window may invoke desktop integration.",
"windows": ["main"],
"permissions": ["core:default", "core:event:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+160
View File
@@ -0,0 +1,160 @@
mod media;
mod proxy;
mod store;
mod update;
use media::Media;
use proxy::Proxy;
use serde::Serialize;
use std::sync::Mutex;
use tauri::{
image::Image,
include_image,
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, State,
};
struct AppState {
proxy: Proxy,
media: Media,
server: Mutex<Option<String>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Bootstrap {
proxy_base: String,
server: Option<String>,
}
fn show_main(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
#[tauri::command]
fn bootstrap(app: AppHandle, state: State<AppState>) -> Result<Bootstrap, String> {
Ok(Bootstrap {
proxy_base: state.proxy.base.clone(),
server: store::load_server(&app)?,
})
}
#[tauri::command]
async fn set_server(app: AppHandle, state: State<'_, AppState>, url: String) -> Result<String, String> {
let server = proxy::validate_server(&url)?;
state.proxy.check(&server).await?;
store::save_server(&app, Some(&server))?;
*state.proxy.server.write().map_err(|e| e.to_string())? = Some(server.clone());
*state.server.lock().map_err(|e| e.to_string())? = Some(server.clone());
Ok(server)
}
#[tauri::command]
fn clear_server(app: AppHandle, state: State<AppState>) -> Result<(), String> {
store::save_server(&app, None)?;
store::save_user(&app, None)?;
store::save_refresh(None)?;
*state.proxy.server.write().map_err(|e| e.to_string())? = None;
*state.server.lock().map_err(|e| e.to_string())? = None;
Ok(())
}
#[tauri::command]
fn token_get() -> Result<Option<String>, String> {
store::load_refresh()
}
#[tauri::command]
fn token_set(token: String) -> Result<(), String> {
store::save_refresh(Some(&token))
}
#[tauri::command]
fn token_clear() -> Result<(), String> {
store::save_refresh(None)
}
#[tauri::command]
fn user_get(app: AppHandle) -> Result<Option<String>, String> {
store::load_user(&app)
}
#[tauri::command]
fn user_set(app: AppHandle, json: String) -> Result<(), String> {
store::save_user(&app, Some(&json))
}
#[tauri::command]
fn user_clear(app: AppHandle) -> Result<(), String> {
store::save_user(&app, None)
}
#[tauri::command]
fn media_set(
state: State<AppState>,
title: String,
artist: String,
album: String,
cover_url: Option<String>,
playing: bool,
) -> Result<(), String> {
state.media.set(&title, &artist, &album, cover_url.as_deref(), playing)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
.plugin(tauri_plugin_window_state::Builder::default().build())
.manage(update::Updater::default())
.setup(|app| {
let saved = store::load_server(app.handle())?;
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
let media = Media::new();
if let Some(window) = app.get_webview_window("main") {
let _ = media.attach(app.handle().clone(), &window);
}
app.manage(AppState {
proxy,
media,
server: Mutex::new(saved),
});
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &quit])?;
let icon: Image = include_image!("icons/32x32.png");
TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => show_main(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main(tray.app_handle());
}
})
.build(app)?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set,
update::update_check, update::update_install
])
.run(tauri::generate_context!())
.expect("desktop app failed to start");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
pipu_desktop_lib::run();
}
+62
View File
@@ -0,0 +1,62 @@
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, WebviewWindow};
pub struct Media {
controls: Mutex<Option<MediaControls>>,
}
impl Media {
pub fn new() -> Self {
Self { controls: Mutex::new(None) }
}
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
#[cfg(target_os = "windows")]
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
#[cfg(not(target_os = "windows"))]
let hwnd = {
let _ = window;
None
};
let mut controls = MediaControls::new(PlatformConfig {
dbus_name: "app.pipu.music.desktop",
display_name: "Pipu Music",
hwnd,
})
.map_err(|e| format!("{e:?}"))?;
let handle = app.clone();
controls
.attach(move |event| {
let name = match event {
MediaControlEvent::Play => "play",
MediaControlEvent::Pause => "pause",
MediaControlEvent::Toggle => "toggle",
MediaControlEvent::Next => "next",
MediaControlEvent::Previous => "previous",
MediaControlEvent::Stop => "stop",
_ => return,
};
let _ = handle.emit("media", name);
})
.map_err(|e| format!("{e:?}"))?;
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
Ok(())
}
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
let Some(controls) = slot.as_mut() else { return Ok(()); };
controls
.set_metadata(MediaMetadata {
title: Some(title),
artist: Some(artist),
album: Some(album),
cover_url,
duration: None,
})
.map_err(|e| format!("{e:?}"))?;
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Loopback-only streaming transport. The random per-process path is a capability;
//! it never leaves the app except for OS media artwork requests. No open proxy.
use axum::{body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, Router};
use serde_json::Value;
use std::{sync::{Arc, RwLock}, time::Duration};
use url::Url;
#[derive(Clone)]
pub struct Proxy {
pub server: Arc<RwLock<Option<String>>>,
pub base: String,
secret: String,
authority: String,
client: reqwest::Client,
}
pub fn validate_server(raw: &str) -> Result<String, String> {
let url = Url::parse(raw.trim()).map_err(|_| "Invalid server URL")?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|| !url.username().is_empty() || url.password().is_some()
|| url.query().is_some() || url.fragment().is_some()
{ return Err("Use an HTTP(S) server address without credentials, query or fragment".into()); }
Ok(url.as_str().trim_end_matches('/').to_string())
}
impl Proxy {
pub async fn start(server: Option<String>) -> Result<Self, String> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
let authority = listener.local_addr().map_err(|e| e.to_string())?.to_string();
let secret = uuid::Uuid::new_v4().simple().to_string();
let proxy = Self {
server: Arc::new(RwLock::new(server)),
base: format!("http://{authority}/{secret}"), secret, authority,
client: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10))
.read_timeout(Duration::from_secs(60))
.build().map_err(|e| e.to_string())?,
};
let router = Router::new().fallback(forward).with_state(proxy.clone());
tauri::async_runtime::spawn(async move {
if let Err(e) = axum::serve(listener, router).await { eprintln!("Desktop transport stopped: {e}"); }
});
Ok(proxy)
}
pub async fn check(&self, server: &str) -> Result<(), String> {
let response = self.client.get(format!("{server}/api/v1/server-info"))
.timeout(Duration::from_secs(10)).send().await.map_err(|_| "Server unavailable")?;
if !response.status().is_success() { return Err("Server unavailable".into()); }
let info: Value = response.json().await.map_err(|_| "Not a Pipu server")?;
if info.get("api_version").is_none() || info.get("name").is_none() { return Err("Not a Pipu server".into()); }
Ok(())
}
}
fn allowed_origin(origin: &str) -> bool {
matches!(origin, "tauri://localhost" | "http://tauri.localhost")
|| (cfg!(debug_assertions) && origin == "http://127.0.0.1:5173")
}
// Signed media and tus URLs are relative in the API. Rewrite only API URLs from
// the configured server; never route arbitrary URLs supplied in server content.
fn local_url(value: &str, server: &str, base: &str) -> Option<String> {
if value.starts_with("/api/") { return Some(format!("{base}{value}")); }
value.strip_prefix(server).filter(|p| p.starts_with("/api/"))
.map(|p| format!("{base}{p}"))
}
fn rewrite_json(value: &mut Value, server: &str, base: &str) {
match value {
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_json(v, server, base)),
Value::Object(fields) => for (key, v) in fields {
if matches!(key.as_str(), "stream_url" | "cover_url" | "tus_url" | "download_url") {
if let Some(url) = v.as_str().and_then(|s| local_url(s, server, base)) { *v = Value::String(url); }
} else { rewrite_json(v, server, base); }
},
_ => {}
}
}
fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
if let Some(origin) = origin {
headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
headers.insert("vary", HeaderValue::from_static("Origin"));
}
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after, x-pipu-transport-error"));
}
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
let origin = request.headers().get("origin").and_then(|v| v.to_str().ok()).map(str::to_owned);
if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&proxy.authority)
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
{ return StatusCode::FORBIDDEN.into_response(); }
let prefix = format!("/{}/api/", proxy.secret);
let path = request.uri().path().to_owned();
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
let tail = tail.to_owned();
// Prevent URL normalization escaping the configured API path, including encoded segments.
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
{ return StatusCode::BAD_REQUEST.into_response(); }
if origin.is_none() && !matches!(*request.method(), Method::GET | Method::HEAD) {
return StatusCode::FORBIDDEN.into_response();
}
let mut response = if request.method() == Method::OPTIONS {
StatusCode::NO_CONTENT.into_response()
} else {
match transfer(&proxy, request, tail).await {
Ok(response) => response,
Err(_) => {
let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response();
response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1"));
response
},
}
};
cors(response.headers_mut(), origin.as_deref());
response
}
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
let url = format!("{server}/api/{tail}{query}");
let (parts, body) = request.into_parts();
let mut upstream = proxy.client.request(parts.method.clone(), url);
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
}
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
let status = upstream.status();
let mut headers = HeaderMap::new();
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
}
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
}
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
let body = if json && parts.method != Method::HEAD {
// Bound only JSON, never buffer a song or an upload in memory.
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
rewrite_json(&mut value, &server, &proxy.base);
headers.remove("content-length");
headers.remove("etag");
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
} else { Body::from_stream(upstream.bytes_stream()) };
let mut response = Response::new(body);
*response.status_mut() = status;
*response.headers_mut() = headers;
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_server_and_scopes_media_urls() {
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
rewrite_json(&mut value, "https://music.example", "http://local/key");
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
assert_eq!(value["tracks"][0]["title"], "/api/private");
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
const SERVICE: &str = "app.pipu.music.desktop";
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
let path = config_dir(app)?.join("server.txt");
match fs::read_to_string(path) {
Ok(s) => {
let s = s.trim().to_string();
Ok((!s.is_empty()).then_some(s))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.to_string()),
}
}
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
let path = config_dir(app)?.join("server.txt");
match server {
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
None => match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.to_string()),
},
}
}
fn refresh_entry() -> Result<keyring::Entry, String> {
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
}
pub fn load_refresh() -> Result<Option<String>, String> {
match refresh_entry()?.get_password() {
Ok(s) => Ok(Some(s)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
let entry = refresh_entry()?;
match token {
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
None => match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.to_string()),
},
}
}
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
let path = config_dir(app)?.join("user.json");
match fs::read_to_string(path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.to_string()),
}
}
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
let path = config_dir(app)?.join("user.json");
match json {
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
None => match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.to_string()),
},
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Server-proxied releases: never accept installer paths or commands from the UI.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{io::Write, path::Path, time::Duration};
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::sync::Mutex;
#[derive(Clone, Deserialize, Serialize)]
pub struct Build {
client: String,
version: String,
filename: String,
size: u64,
sha256: Option<String>,
download_url: String,
}
#[derive(Deserialize)]
struct Catalog { version: String, notes_md: String, clients: Vec<Build> }
#[derive(Clone, Serialize)]
pub struct Release { build: Build, notes: String }
#[derive(Default)]
pub struct Updater { release: Mutex<Option<(String, Release)>> }
fn client() -> &'static str {
if cfg!(target_os = "windows") { "windows" }
else if cfg!(target_os = "macos") { "macos" } else { "linux" }
}
fn version_code(raw: &str) -> Option<u64> {
let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v);
let parts: Vec<u64> = base.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;
let rc: u64 = rc.parse().ok()?;
if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; }
Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc)
}
fn http() -> Result<reqwest::Client, String> {
reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10)).read_timeout(Duration::from_secs(60))
.build().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result<Option<Release>, String> {
let mut saved = updater.release.lock().await;
*saved = None;
let server = crate::store::load_server(&app)?.ok_or("No server selected")?;
let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?
.json().await.map_err(|e| e.to_string())?;
let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); };
let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?;
if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); }
let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() };
let release = Release { build, notes };
validate_build(&release.build)?;
*saved = Some((server, release.clone()));
Ok(Some(release))
}
fn validate_build(build: &Build) -> Result<(), String> {
let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or("");
let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" };
let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()));
if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); }
// Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release.
if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) {
return Err("Invalid installer URL".into());
}
Ok(())
}
#[tauri::command]
pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> {
let saved = updater.release.lock().await;
let (server, release) = saved.as_ref().ok_or("Check for an update first")?;
if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); }
let build = &release.build;
validate_build(build)?;
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(&build.filename);
let part = dir.join(format!("{}.part", uuid::Uuid::new_v4()));
let result = async {
let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?;
let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?;
let mut hash = Sha256::new();
let mut bytes = 0u64;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
bytes += chunk.len() as u64;
if bytes > build.size { return Err("Installer exceeds expected size".to_string()); }
hash.update(&chunk);
file.write_all(&chunk).map_err(|e| e.to_string())?;
let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string());
}
if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); }
file.sync_all().map_err(|e| e.to_string())?;
drop(file);
if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; }
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
launch(&path)?;
Ok(())
}.await;
let _ = std::fs::remove_file(&part);
result
}
fn launch(path: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") {
let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd
} else { std::process::Command::new(path) };
command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{ std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; }
#[cfg(target_os = "macos")]
{ std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; }
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_order() {
assert!(version_code("1.7.2") > version_code("1.7.2-rc.3"));
assert!(version_code("1.8.0-rc.1") > version_code("1.7.99"));
assert_eq!(version_code("1.7.1"), Some(1070199));
assert_eq!(version_code("1.7.100"), None);
assert_eq!(version_code("1.7.1;cmd"), None);
}
#[test]
fn installer_is_restricted_to_verified_server_asset() {
let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" };
let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42,
sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) };
assert!(validate_build(&build).is_ok());
build.download_url = "https://other.example/installer.exe".into();
assert!(validate_build(&build).is_err());
build.download_url = format!("/api/v1/app/download/{}/1.8.0", client());
build.filename = "../installer.exe".into();
assert!(validate_build(&build).is_err());
build.filename = format!("pipu.{extension}");
build.sha256 = None;
assert!(validate_build(&build).is_err());
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Pipu Music",
"version": "0.0.0",
"identifier": "app.pipu.music.desktop",
"build": {
"beforeDevCommand": "pnpm --filter @pipu/web dev",
"devUrl": "http://127.0.0.1:5173",
"beforeBuildCommand": "pnpm --filter @pipu/web build",
"frontendDist": "../../web/dist"
},
"app": {
"withGlobalTauri": true,
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
}
},
"bundle": {
"active": true,
"targets": ["nsis", "appimage", "deb", "rpm"],
"category": "Music",
"shortDescription": "Self-hosted music player",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
"linux": {
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
"appimage": { "bundleMediaFramework": false }
},
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
}
}
+5
View File
@@ -0,0 +1,5 @@
appId: app.pipu.music
---
- runFlow: login.yaml
- tapOn: "Коллекция"
- assertVisible: "Лайки"
+15
View File
@@ -0,0 +1,15 @@
appId: app.pipu.music
---
- launchApp:
clearState: true
- assertVisible: "Адрес сервера"
- tapOn: "Адрес сервера"
- inputText: ${PIPU_E2E_SERVER}
- tapOn: "Подключиться"
- assertVisible: "Вход"
- tapOn: "Логин"
- inputText: ${PIPU_E2E_USER}
- tapOn: "Пароль"
- inputText: ${PIPU_E2E_PASSWORD}
- tapOn: "Войти"
- assertVisible: "Бегаю"
+10
View File
@@ -0,0 +1,10 @@
appId: app.pipu.music
---
- launchApp:
clearState: true
- assertVisible: "Адрес сервера|Server address"
- stopApp
- launchApp
- assertVisible: "Адрес сервера|Server address"
- openLink: "pipu://album/1"
- assertVisible: "Адрес сервера|Server address"
+6
View File
@@ -0,0 +1,6 @@
appId: app.pipu.music
---
- runFlow: login.yaml
- tapOn: "Профиль"
- assertVisible: "Проверить обновления"
- tapOn: "Проверить обновления"
+7
View File
@@ -0,0 +1,7 @@
# Android
- `src/player/controller.ts` — фасад и зеркало native-очереди; `service.ts` — фоновые события. Владельцем воспроизводимой очереди остаётся TrackPlayer.
- Общая политика волны — `packages/core/src`; офлайн-адаптация — `src/player/offlineWave.ts`, `src/offline`.
- Сессия/настройки — `src/lib`; маршруты — `src/app`. Сохраняй работу при выгруженном UI и изоляцию офлайн-данных по пользователю/серверу.
- `pnpm --filter @pipu/mobile typecheck`, `pnpm --filter @pipu/mobile test`.
- Изменения native playback требуют проверки на устройстве: background, смена трека, offline→online. Unit-тесты не заменяют эту проверку.
+18 -2
View File
@@ -9,12 +9,20 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
name: 'Pipu Music',
slug: 'pipu-music',
scheme: 'pipu',
icon: './assets/icon.png',
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: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
monochromeImage: './assets/monochrome-icon.png',
backgroundColor: '#05070D',
},
package: 'app.pipu.music',
versionCode,
edgeToEdgeEnabled: true,
@@ -23,9 +31,17 @@ 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 } }],
// Распаковываем .so при установке: SoLoader на x86_64 с ARM-трансляцией ищет
// внутри APK lib/x86_64 вместо arm64-v8a и не находит libreactnative.so.
['expo-build-properties', { android: { usesCleartextTraffic: true, useLegacyPackaging: true } }],
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
'./plugins/withArm64Only',
],
extra: { versionCode },
experiments: { typedRoutes: false },
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+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';
+33 -4
View File
@@ -7,24 +7,53 @@
"start": "expo start --dev-client",
"android": "expo run:android",
"prebuild": "expo prebuild --platform android --clean",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@expo/vector-icons": "^14.1.0",
"@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-modules-autolinking": "~2.1.15",
"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/react": "~19.0.10"
"@types/node": "^22.20.5",
"@types/react": "~19.0.10",
"vitest": "^3.2.7"
},
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": [
"react-native-track-player"
],
"listUnknownPackages": false
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI
// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят
// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог
// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so».
const { withAppBuildGradle } = require('expo/config-plugins');
const MARKER = '// pipu: arm64-v8a only';
module.exports = function withArm64Only(config) {
return withAppBuildGradle(config, (cfg) => {
if (cfg.modResults.contents.includes(MARKER)) return cfg;
cfg.modResults.contents = cfg.modResults.contents.replace(
/defaultConfig\s*\{/,
(m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`,
);
return cfg;
});
};
-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 },
});
+160
View File
@@ -0,0 +1,160 @@
import { useNetInfo } from '@react-native-community/netinfo';
import { Tabs, useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { Cover, Icon, type IconName } from '../../ui/kit';
import { NativeChatProvider, useSocialUnread } from '../../features/social/Social';
import { ListeningBanner } from '../../features/social/Listening';
import { UpdateDialog } from '../../ui/UpdateDialog';
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
export default function TabsLayout() {
const colors = useColors();
const { t } = useTranslation();
const unread = useSocialUnread();
const labels: Record<string, string> = {
index: t('nav.home'),
search: t('mobile.search'),
collection: t('nav.collection'),
profile: t('mobile.profile'),
friends: t('social.people'),
};
const net = useNetInfo();
return (
<NativeChatProvider>
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
<Tabs
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
tabBar={({ state, navigation }) => (
<View>
<ListeningBanner />
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
badges={{ friends: unread }}
onPress={(name) => navigation.navigate(name)}
/>
</View>
)}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="friends" />
<Tabs.Screen name="profile" />
</Tabs>
<UpdateDialog />
</NativeChatProvider>
);
}
function TabBar({
routes,
active,
labels,
badges,
onPress,
}: {
routes: string[];
active: number;
labels: Record<string, string>;
badges: Record<string, number>;
onPress: (name: string) => void;
}) {
const colors = useColors();
const insets = useSafeAreaInsets();
return (
<View
accessibilityRole="tablist"
style={{
flexDirection: 'row',
paddingBottom: insets.bottom,
backgroundColor: colors.bg2,
borderTopWidth: 1,
borderTopColor: colors.line,
}}
>
{routes.map((name, i) => {
const on = i === active;
return (
<Pressable
key={name}
onPress={() => onPress(name)}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
accessibilityLabel={`${labels[name]}${badges[name] ? ` · ${badges[name]}` : ''}`}
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
>
<View>
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
{!!badges[name] && <Text style={{ position: 'absolute', top: -5, left: 14, minWidth: 16, paddingHorizontal: 4, borderRadius: 9, backgroundColor: colors['accent-fill'], color: '#fff', fontSize: 10, textAlign: 'center' }}>{badges[name]! > 99 ? '99+' : badges[name]}</Text>}
</View>
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
{labels[name]}
</Text>
</Pressable>
);
})}
</View>
);
}
/** Мини-плеер над навигацией; нажатие — полноэкранный плеер. */
function MiniPlayer() {
const colors = useColors();
const { t } = useTranslation();
const router = useRouter();
const st = usePlayerState();
const track = st.track;
if (!track) return null;
const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0;
return (
<Pressable
onPress={() => router.push('/player')}
accessibilityRole="button"
accessibilityLabel={t('mobile.open_player')}
style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }}
>
<View style={{ height: 2, backgroundColor: colors.surface3 }}>
<View style={{ height: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 8 }}>
<Cover album={track.album} size={42} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 12 }}>
{track.artists.map((a) => a.name).join(', ')}
</Text>
</View>
<Pressable onPress={() => player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}>
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={32} color={colors.text} />
</Pressable>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={28} color={colors.text2} />
</Pressable>
</View>
</Pressable>
);
}
/** Нет сети: доступны «Скачанное» и офлайн-волна. */
function OfflineBanner({ text }: { text: string }) {
const colors = useColors();
const insets = useSafeAreaInsets();
return (
<View accessibilityRole="alert" style={{ paddingTop: insets.top + 6, paddingBottom: 6, paddingHorizontal: 16, backgroundColor: colors.surface3, flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<Icon name="cloud-off" size={16} color={colors.text2} />
<Text style={{ color: colors.text2, fontSize: 12 }}>{text}</Text>
</View>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { unwrap, type Track } from '@pipu/core';
import { useRouter } from 'expo-router';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { downloads } from '../../offline/downloads';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { DownloadButton, useDownloads } from '../../ui/downloads';
import { Button, H1, Muted, Screen, Segmented, TrackRow } from '../../ui/kit';
type Tab = 'history' | 'likes' | 'dislikes' | 'downloads';
export default function CollectionScreen() {
const colors = useColors();
const { t, i18n } = useTranslation();
const session = useSession();
const current = usePlayerState().track;
const [tab, setTab] = useState<Tab>('likes');
const router = useRouter();
const list = useInfiniteQuery({
queryKey: ['m-collection', tab],
enabled: tab !== 'downloads',
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => {
const query = { query: { cursor: pageParam, limit: 100 } };
if (tab === 'history') {
const page = unwrap(await session.client.GET('/api/v1/me/history', { params: query }));
return { items: page.items.map((i) => ({ track: i.track, at: i.played_at })), next: page.next_cursor };
}
const path = tab === 'likes' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
const page = unwrap(await session.client.GET(path, { params: query }));
return { items: page.items.map((i) => ({ track: i.track, at: i.created_at })), next: page.next_cursor };
},
getNextPageParam: (p) => p.next ?? undefined,
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
/** Все лайки целиком, а не только загруженные страницы списка. */
const downloadAllLikes = async () => {
let cursor: string | undefined;
do {
const page = unwrap(await session.client.GET('/api/v1/me/likes', { params: { query: { cursor, limit: 100 } } }));
await downloads.enqueue(page.items.map((i) => i.track));
cursor = page.next_cursor ?? undefined;
} while (cursor);
};
const tracks: Track[] = items.map((i) => i.track);
const time = new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const context = tab === 'history' ? ({ type: 'history' } as const) : ({ type: 'collection', id: tab } as const);
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
const tabs = (
<View style={{ gap: 12 }}>
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
<Segmented<Tab>
value={tab}
onChange={setTab}
options={[
{ value: 'likes', label: t('collection.likes') },
{ value: 'history', label: t('collection.history') },
{ value: 'downloads', label: t('mobile.dl_title') },
{ value: 'dislikes', label: t('collection.dislikes') },
]}
/>
</View>
);
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
return (
<Screen>
<FlatList
data={items}
keyExtractor={(it, i) => `${it.track.id}-${i}`}
onEndReached={() => list.hasNextPage && void list.fetchNextPage()}
onRefresh={() => void list.refetch()}
refreshing={list.isRefetching}
ListHeaderComponent={
<View style={{ padding: 16, gap: 14 }}>
<H1>{t('collection.title')}</H1>
{tabs}
{tab === 'likes' && tracks.length > 0 && (
<Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(tracks, 0, context)} />
)}
{tab === 'likes' && tracks.length > 0 && (
<Button title={t('mobile.dl_all_likes')} variant="secondary" icon="download" onPress={() => void downloadAllLikes()} />
)}
</View>
}
ListEmptyComponent={list.isSuccess ? <Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t(empty[tab])}</Text> : null}
renderItem={({ item, index }) => (
<TrackRow
track={item.track}
current={current?.id === item.track.id}
meta={tab === 'history' ? time.format(new Date(item.at)) : undefined}
onPress={() => void player.playList(tracks, index, context)}
onLike={tab === 'dislikes' ? undefined : () => void player.react(item.track, item.track.reaction === 'like' ? null : 'like').then(() => list.refetch())}
/>
)}
/>
</Screen>
);
}
const MB = 1024 * 1024;
/** Коллекция → Скачанное: работает без сети, данные — из SQLite. */
function DownloadsTab({ tabs }: { tabs: React.ReactNode }) {
const colors = useColors();
const { t } = useTranslation();
const dl = useDownloads();
const current = usePlayerState().track;
const [usage, setUsage] = useState<{ used: number; free: number } | null>(null);
const items = [...dl.items.values()];
const done = items.filter((i) => i.status === 'done').map((i) => i.track);
const pending = items.filter((i) => i.status === 'queued' || i.status === 'downloading').length;
const failed = items.some((i) => i.status === 'error');
const context = { type: 'collection', id: 'downloads' } as const;
useEffect(() => {
void downloads.usage().then(setUsage);
}, [done.length]);
return (
<Screen>
<FlatList
data={items}
keyExtractor={(it) => it.track.id}
ListHeaderComponent={
<View style={{ padding: 16, gap: 14 }}>
<H1>{t('collection.title')}</H1>
{tabs}
{usage && <Muted>{t('mobile.dl_usage', { used: (usage.used / MB).toFixed(0), free: (usage.free / MB / 1024).toFixed(1) })}</Muted>}
{pending > 0 && (
<Muted>{t(dl.allowed ? 'mobile.dl_pending' : dl.wifiOnly ? 'mobile.dl_waiting_wifi' : 'mobile.dl_waiting_network', { count: pending })}</Muted>
)}
{failed && <Button title={t('mobile.dl_retry')} variant="secondary" icon="refresh" onPress={() => void downloads.retry()} />}
{done.length > 0 && <Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(done, 0, context)} />}
</View>
}
ListEmptyComponent={<Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t('mobile.dl_empty')}</Text>}
renderItem={({ item }) => (
<TrackRow
track={item.track}
current={current?.id === item.track.id}
meta={item.status === 'downloading' ? `${Math.round(item.progress * 100)}%` : undefined}
onPress={() => {
if (item.status === 'done') void player.playList(done, done.indexOf(item.track), context);
}}
trailing={<DownloadButton track={item.track} size={20} />}
/>
)}
/>
</Screen>
);
}
+2
View File
@@ -0,0 +1,2 @@
import { SocialScreen } from '../../features/social/Social';
export default function FriendsTab() { return <SocialScreen root />; }
+138
View File
@@ -0,0 +1,138 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
import { OnboardingGate } from '../../ui/Onboarding';
import { WaveOrb } from '../../ui/wave/WaveOrb';
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
function iconFor(name: string): IconName {
const map: Record<string, string> = { rainy: 'umbrella', sentiment_satisfied: 'sentiment-satisfied' };
return (map[name] ?? name.replace(/_/g, '-')) as IconName;
}
/**
* Главная: кнопка «Моя волна» (Skia, вид orb / liquid из профиля) и выбор занятия / настроения.
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
*/
export default function HomeScreen() {
return <OnboardingGate><WaveHome /></OnboardingGate>;
}
function WaveHome() {
const colors = useColors();
const { t } = useTranslation();
const tr = t as unknown as (k: string) => string;
const session = useSession();
const user = useUser();
const st = usePlayerState();
const errorText = useErrorText();
const [error, setError] = useState<unknown>(null);
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
const [mood, setMood] = useState<string | null>(st.wave?.mood ?? null);
const options = useQuery({
queryKey: ['wave-options'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')),
staleTime: Infinity,
});
const waveOn = st.wave !== null;
const playing = waveOn && st.playing;
const run = (fn: () => Promise<void>) => {
setError(null);
fn().catch(setError);
};
const onWave = () => (waveOn ? player.toggle() : run(() => player.startWave(activity, mood)));
const choose = (kind: 'activity' | 'mood', key: string) => {
const a = kind === 'activity' ? (activity === key ? null : key) : activity;
const m = kind === 'mood' ? (mood === key ? null : key) : mood;
setActivity(a);
setMood(m);
if (waveOn) run(() => player.setWaveContext(a, m));
};
const ctx = [activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') || t('wave.for_you');
return (
<Screen>
<ScrollView contentContainerStyle={{ alignItems: 'center', paddingVertical: 32, gap: 24, paddingHorizontal: 16 }}>
<WaveOrb
variant={user.ui_prefs.wave_variant ?? 'orb'}
size={260}
playing={playing}
bpm={st.track?.features?.bpm ?? 100}
energy={st.track?.features?.energy ?? 0.6}
colors={waveOn ? st.track?.album.cover_colors : undefined}
onPress={onWave}
label={t(playing ? 'wave.pause' : 'wave.start')}
>
<Icon name={playing ? 'pause' : 'play-arrow'} size={44} color="#fff" />
<Text style={{ color: '#fff', fontSize: 22, fontWeight: '700' }}>{t('wave.title')}</Text>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 13 }}>{ctx}</Text>
</WaveOrb>
{st.wave?.fallback && <Text style={{ color: colors.text2, fontSize: 13 }}>{t('wave.fallback')}</Text>}
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
{options.data && (
<>
<Chips title={t('wave.activity_label')} options={options.data.activities} value={activity} label={(k) => tr(`wave.activities.${k}`)} onPick={(k) => choose('activity', k)} />
<Chips title={t('wave.mood_label')} options={options.data.moods} value={mood} label={(k) => tr(`wave.moods.${k}`)} onPick={(k) => choose('mood', k)} />
</>
)}
</ScrollView>
</Screen>
);
}
function Chips({
title,
options,
value,
label,
onPick,
}: {
title: string;
options: { key: string; icon: string }[];
value: string | null;
label: (k: string) => string;
onPick: (k: string) => void;
}) {
const colors = useColors();
return (
<View style={{ alignItems: 'center', gap: 10, width: '100%' }}>
<Text style={{ color: colors.text3, fontSize: 11, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase' }}>{title}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
{options.map((o) => {
const on = value === o.key;
return (
<Pressable
key={o.key}
onPress={() => onPick(o.key)}
accessibilityRole="button"
accessibilityState={{ selected: on }}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: 38,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: on ? colors['accent-fill'] : colors.surface2,
borderWidth: 1,
borderColor: on ? colors.accent : colors.line,
}}
>
<Icon name={iconFor(o.icon)} size={18} color={on ? '#fff' : colors.text2} />
<Text style={{ color: on ? '#fff' : colors.text, fontWeight: '500' }}>{label(o.key)}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
+210
View File
@@ -0,0 +1,210 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import Constants from 'expo-constants';
import * as Linking from 'expo-linking';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, Switch, Text, View } from 'react-native';
import { playerCache } from '../../lib/prefs';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { updater } from '../../lib/update';
import { downloads } from '../../offline/downloads';
import { player } from '../../player/controller';
import { radius, useColors } from '../../theme';
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
import { useDownloads } from '../../ui/downloads';
import { RecommendationSettings } from '../../ui/Onboarding';
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
import { useUpdateState } from '../../ui/UpdateDialog';
type UiPrefs = components['schemas']['UiPrefs'];
const MB = 1024 * 1024;
export default function ProfileScreen() {
const colors = useColors();
const { t, i18n } = useTranslation();
const session = useSession();
const user = useUser();
const { server, disconnect } = useSessionCtx();
const staff = user.role !== 'user';
const update = useUpdateState();
const dl = useDownloads();
const quality = useQualityPrefs();
const [sheet, setSheet] = useState<'quality' | 'password' | null>(null);
const [cache, setCache] = useState<{ bytes: number; pending: boolean } | null>(null);
useEffect(() => {
void Promise.all([playerCache.size(), playerCache.clearPending()]).then(([bytes, pending]) => setCache({ bytes, pending }));
}, []);
// Оптимистично, как в вебе: настройка применяется сразу, сервер подтверждает.
const setPrefs = async (prefs: UiPrefs) => {
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, ...prefs } });
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: prefs } })));
} catch {
session.setUser(user);
}
};
const clearCache = async () => {
await playerCache.requestClear();
setCache((c) => (c ? { ...c, pending: true } : c));
};
const setLocale = async (l: Locale) => {
await i18n.changeLanguage(l);
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
} catch {
// язык уже сменился локально
}
};
const logout = async () => {
await player.reset();
await session.logout();
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 16, gap: 16 }}>
<H1>{user.display_name ?? user.username}</H1>
<Muted>
@{user.username} · {t(`roles.${user.role}`)}
</Muted>
<RecommendationSettings />
<Section>
<Row label={t('language.label')}>
<Segmented<Locale>
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
onChange={(l) => void setLocale(l)}
/>
</Row>
</Section>
<Section>
<Row label={t('settings.theme')}>
<Segmented<NonNullable<UiPrefs['theme']>>
value={user.ui_prefs.theme ?? 'system'}
options={[
{ value: 'dark', label: t('settings.theme_dark') },
{ value: 'light', label: t('settings.theme_light') },
{ value: 'system', label: t('settings.theme_system') },
]}
onChange={(v) => void setPrefs({ theme: v })}
/>
</Row>
<Row label={t('settings.wave_variant')}>
<Segmented<NonNullable<UiPrefs['wave_variant']>>
value={user.ui_prefs.wave_variant ?? 'orb'}
options={[
{ value: 'orb', label: t('settings.wave_orb') },
{ value: 'liquid', label: t('settings.wave_liquid') },
]}
onChange={(v) => void setPrefs({ wave_variant: v })}
/>
</Row>
<Row label={t('mobile.player_view')}>
<Segmented<NonNullable<UiPrefs['mobile_player']>>
value={user.ui_prefs.mobile_player ?? 'cover'}
options={[
{ value: 'cover', label: t('mobile.player_cover') },
{ value: 'wave', label: t('mobile.player_wave') },
]}
onChange={(v) => void setPrefs({ mobile_player: v })}
/>
</Row>
<Row label={t('settings.normalize')}>
<Switch value={user.ui_prefs.normalize !== false} onValueChange={(v) => void setPrefs({ normalize: v })} />
</Row>
<Row label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}>
<Segmented
value={String([0, 2, 4, 6].includes(user.ui_prefs.crossfade_s ?? 0) ? (user.ui_prefs.crossfade_s ?? 0) : 0)}
options={['0', '2', '4', '6'].map((v) => ({ value: v, label: v }))}
onChange={(v) => void setPrefs({ crossfade_s: Number(v) })}
/>
</Row>
</Section>
<Section>
<Pressable onPress={() => setSheet('quality')} accessibilityRole="button" style={{ paddingVertical: 4 }}>
<Row label={t('mobile.quality')}>
<Text style={{ color: colors.text2 }}>
{t(`mobile.q_${quality.wifi}`)} · {t(`mobile.q_${quality.cellular}`)}
</Text>
</Row>
</Pressable>
<Row label={t('mobile.cache')}>
{cache?.pending ? (
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('mobile.cache_pending')}</Text>
) : (
<Pressable onPress={() => void clearCache()} accessibilityRole="button" hitSlop={8}>
<Text style={{ color: colors.accent2, fontWeight: '500' }}>
{t('mobile.cache_clear', { size: ((cache?.bytes ?? 0) / MB).toFixed(0) })}
</Text>
</Pressable>
)}
</Row>
<Row label={t('mobile.dl_wifi_only')}>
<Switch value={dl.wifiOnly} onValueChange={(v) => void downloads.setWifiOnly(v)} />
</Row>
<Row label={t('mobile.dl_auto_likes')}>
<Switch value={dl.autoLikes} onValueChange={(v) => void downloads.setAutoLikes(v)} />
</Row>
</Section>
<Section>
<Row label={t('mobile.server')}>
<Text style={{ color: colors.text2 }} numberOfLines={1}>
{server?.replace(/^https?:\/\//, '')}
</Text>
</Row>
{staff && server && (
<Pressable onPress={() => void Linking.openURL(`${server}/admin/users`)} accessibilityRole="link" style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }}>
<Icon name="open-in-new" size={20} color={colors.accent2} />
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.open_admin')}</Text>
</Pressable>
)}
</Section>
<Button title={t('settings.change_password')} variant="secondary" icon="lock" onPress={() => setSheet('password')} />
<Button
title={t(update.status === 'checking' ? 'mobile.update_checking' : 'mobile.update_check')}
variant="secondary"
icon="system-update"
busy={update.status === 'checking'}
onPress={() => void updater.check(session, true)}
/>
{update.status === 'latest' && <Muted>{t('mobile.update_latest')}</Muted>}
<Button title={t('profile.logout')} variant="danger" icon="logout" onPress={() => void logout()} />
<Button title={t('mobile.change_server')} variant="ghost" onPress={() => void logout().then(disconnect)} />
<Text style={{ color: colors.text3, textAlign: 'center', fontSize: 12 }}>
{t('mobile.version', { version: Constants.expoConfig?.version ?? '0.0.0' })}
</Text>
</ScrollView>
<QualitySheet visible={sheet === 'quality'} onClose={() => setSheet(null)} />
<PasswordSheet visible={sheet === 'password'} onClose={() => setSheet(null)} />
</Screen>
);
}
function Section({ children }: { children: React.ReactNode }) {
const colors = useColors();
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
const colors = useColors();
return (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<Text style={{ color: colors.text }}>{label}</Text>
<View style={{ flexShrink: 1 }}>{children}</View>
</View>
);
}
+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>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import type { i18n as I18n } from 'i18next';
import { useEffect, useState } from 'react';
import { I18nextProvider, useTranslation } from 'react-i18next';
import { ActivityIndicator, AppState, useColorScheme, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { SessionProvider, useAuthState, useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { player } from '../player/controller';
import { ThemeContext, useColors } from '../theme';
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
export default function RootLayout() {
const [i18n, setI18n] = useState<I18n | null>(null);
useEffect(() => {
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
void createI18n(pickLocale([deviceLocale])).then(setI18n);
}, []);
if (!i18n) return <Splash />;
return (
<SafeAreaProvider>
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<SessionProvider>
<Gate />
</SessionProvider>
</QueryClientProvider>
</I18nextProvider>
</SafeAreaProvider>
);
}
function Splash() {
const colors = useColors();
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
/**
* Куда пускать: без сервера — подключение, без входа — логин, с обязательной сменой пароля — setup,
* иначе — вкладки. Те же правила, что у веба (docs/auth-and-roles.md).
*/
function Gate() {
const { server, session, ready } = useSessionCtx();
const auth = useAuthState();
const { i18n } = useTranslation();
const user = auth.status === 'signed_in' ? auth.user : null;
const system = useColorScheme();
// Тема из профиля; «system» и экраны до входа — по настройке телефона.
const pref = user?.ui_prefs.theme ?? 'system';
const theme = pref === 'system' ? (system === 'light' ? 'light' : 'dark') : pref;
useEffect(() => {
player.attach(user ? session : null, server ?? '', user?.id ?? null);
void downloads.attach(user ? session : null, server ?? '', user?.id ?? null);
if (user) void player.restore();
}, [user?.id, session, server]);
useEffect(() => {
if (user && isLocale(user.locale) && user.locale !== i18n.language) void i18n.changeLanguage(user.locale);
}, [user?.locale, i18n]);
// Свёрнутое приложение досылает события и позицию.
useEffect(() => {
const sub = AppState.addEventListener('change', (st) => st !== 'active' && player.flush());
return () => sub.remove();
}, []);
if (!ready) return <Splash />;
return (
<ThemeContext.Provider value={theme}>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<Screens />
</ThemeContext.Provider>
);
}
function Screens() {
const colors = useColors();
const { server, session } = useSessionCtx();
const auth = useAuthState();
const connected = !!server && !!session;
const user = connected && auth.status === 'signed_in' ? auth.user : null;
const needsSetup = !!user && (user.must_change_credentials || user.must_change_password);
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
{/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
<Stack.Protected guard={!connected}>
<Stack.Screen name="connect" />
</Stack.Protected>
<Stack.Protected guard={connected && !user}>
<Stack.Screen name="login" />
</Stack.Protected>
<Stack.Protected guard={needsSetup}>
<Stack.Screen name="setup" />
</Stack.Protected>
<Stack.Protected guard={!!user && !needsSetup}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="album/[id]" />
<Stack.Screen name="artist/[id]" />
<Stack.Screen name="playlists" />
<Stack.Screen name="social" />
<Stack.Screen name="social-track" />
<Stack.Screen name="social-playlist" />
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack.Protected>
</Stack>
);
}
+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>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { MusicShareSheet } from '../features/social/Social';
import { ListeningBanner } from '../features/social/Listening';
import { trackShareUrl } from '@pipu/core';
import { useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks';
import { useColors } from '../theme';
import { DownloadButton } from '../ui/downloads';
import { Cover, Icon, Screen, formatDuration } from '../ui/kit';
import { WaveOrb } from '../ui/wave/WaveOrb';
/**
* Полноэкранный плеер: вид `cover` (крупная обложка) или `wave` (визуализация волны в цветах обложки) — по профилю;
* прогресс с перемоткой, лайк / дизлайк, скачать, управление.
*/
export default function PlayerScreen() {
const colors = useColors();
const { t } = useTranslation();
const router = useRouter();
const st = usePlayerState();
const locked = st.listening?.locked === true;
const prefs = useUser().ui_prefs;
const { server } = useSessionCtx();
const [sharing, setSharing] = useState(false);
const [barWidth, setBarWidth] = useState(1);
const track = st.track;
if (!track) {
return (
<Screen>
<Pressable onPress={() => router.back()} style={{ padding: 16 }} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable>
</Screen>
);
}
const duration = st.durationMs || track.duration_ms;
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = () => setSharing(true);
return (
<Screen>
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
<ListeningBanner />
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable>
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
<Icon name="share" size={24} color={colors.text} />
</Pressable>
<DownloadButton track={track} />
</View>
</View>
<View style={{ alignItems: 'center' }}>
{prefs.mobile_player === 'wave' ? (
<WaveOrb
variant={prefs.wave_variant ?? 'orb'}
size={280}
playing={st.playing}
bpm={track.features?.bpm ?? 100}
energy={track.features?.energy ?? 0.6}
colors={track.album.cover_colors}
>
<Cover album={track.album} size={96} round />
</WaveOrb>
) : (
<Cover album={track.album} size={300} />
)}
</View>
<View style={{ gap: 4 }}>
<Text numberOfLines={2} style={{ color: colors.text, fontSize: 22, fontWeight: '700' }}>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')}
</Text>
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
<Icon name="playlist-add" size={24} color={colors.text2} />
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
</Pressable>
</View>
<View style={{ gap: 6 }}>
<Pressable
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
disabled={locked} onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
accessibilityRole="adjustable"
accessibilityLabel={t('player.progress')}
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(e) => player.seek(st.positionMs + (e.nativeEvent.actionName === 'increment' ? 10_000 : -10_000))}
style={{ height: 24, justifyContent: 'center' }}
>
<View style={{ height: 4, borderRadius: 2, backgroundColor: colors.surface3 }}>
<View style={{ height: 4, borderRadius: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
</View>
</Pressable>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(st.positionMs)}</Text>
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(duration)}</Text>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
</Pressable>
<Pressable disabled={locked} onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<Icon name="skip-previous" size={40} color={colors.text} />
</Pressable>
<Pressable
disabled={locked} onPress={() => player.toggle()}
accessibilityRole="button"
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
>
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
</Pressable>
<Pressable disabled={locked} onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={40} color={colors.text} />
</Pressable>
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
<Icon name={track.reaction === 'like' ? 'favorite' : 'favorite-border'} size={28} color={track.reaction === 'like' ? colors.accent2 : colors.text2} />
</Pressable>
</View>
</View>
</Screen>
);
}
+144
View File
@@ -0,0 +1,144 @@
import type { components } from '@pipu/api-client';
import { MusicShareSheet } from '../features/social/Social';
import { playlistShareUrl, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
type Playlist = components['schemas']['Playlist'];
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
export default function PlaylistsScreen() {
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
}
function List({ track }: { track?: string }) {
const session = useSession();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [create, setCreate] = useState(false);
const [search, setSearch] = useState('');
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
const add = useMutation({ mutationFn: async (id: string) => {
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
return <Screen><FlatList
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
contentContainerStyle={{ padding: 16, gap: 12 }}
ListHeaderComponent={<View style={{ gap: 12 }}>
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
</View>}
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
}} />}</Screen>;
}
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
const session = useSession();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [original] = useState(playlist);
const [name, setName] = useState(playlist?.name ?? '');
const [description, setDescription] = useState(playlist?.description ?? '');
const save = useMutation({ mutationFn: async () => {
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
</ScrollView></Screen></Modal>;
}
function Detail({ id }: { id: string }) {
const [shareToken, setShareToken] = useState<string | null>(null);
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [edit, setEdit] = useState(false);
const [manage, setManage] = useState(false);
const [playError, setPlayError] = useState<unknown>();
const key = ['m-playlists', user.id, id];
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
const p = query.data;
const sharing = useMutation({ mutationFn: async () => {
if (!server || !p) throw new Error('Server unavailable');
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
setShareToken(token);
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
const play = (shuffle: boolean) => {
const tracks = p?.tracks.filter(t => t.available) ?? [];
if (shuffle) for (let i=tracks.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [tracks[i],tracks[j]]=[tracks[j]!,tracks[i]!]; }
if (tracks.length) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
};
const move = (index: number, delta: number) => {
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
};
return <Screen>{shareToken && server && p && <MusicShareSheet attachment={{ kind: 'playlist', id: shareToken, title: p.name }} url={playlistShareUrl(server, shareToken)} close={() => setShareToken(null)} />}<FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
<Button title={t('playlists.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
<Muted>{t('playlists.share_hint')}</Muted>
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
])} />
</>}
</View>}
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
{manage && <View style={{padding:12,gap:6}}>
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
</View>}
</View>}
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
}
+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>
);
}
+2
View File
@@ -0,0 +1,2 @@
import { SharedMusic } from '../features/social/SharedMusic';
export default function SharedPlaylistScreen() { return <SharedMusic kind="playlist" />; }
+2
View File
@@ -0,0 +1,2 @@
import { SharedMusic } from '../features/social/SharedMusic';
export default function SharedTrackScreen() { return <SharedMusic kind="track" />; }
+1
View File
@@ -0,0 +1 @@
export { SocialScreen as default } from '../features/social/Social';
@@ -0,0 +1,61 @@
import { CHAT_REACTIONS, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityInfo, Animated, KeyboardAvoidingView, Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { eventId } from '../../player/eventId';
import { useColors } from '../../theme';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
import { readDraft, saveDraft } from './drafts';
function Bubble({ children, mine, animate }: { children: ReactNode; mine: boolean; animate: boolean }) {
const colors = useColors(); const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => { if (!animate) return; opacity.setValue(0); const animation = Animated.timing(opacity,{toValue:1,duration:180,useNativeDriver:true});animation.start();return()=>animation.stop(); },[animate,opacity]);
return <Animated.View style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '90%', padding: 14, borderRadius: 16, backgroundColor: mine ? colors['accent-soft'] : colors.surface2, gap: 8, opacity, transform:[{translateY:opacity.interpolate({inputRange:[0,1],outputRange:[6,0]})}] }}>{children}</Animated.View>;
}
export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; server: string; active: boolean }) {
const colors=useColors();const {t,i18n}=useTranslation();const router=useRouter();const cache=useQueryClient();const errorText=useErrorText();
const [text,setText]=useState('');const [ready,setReady]=useState(false);const touched=useRef(false);const [search,setSearch]=useState('');const [q,setQ]=useState('');
const [reply,setReply]=useState<DirectMessage|null>(null);const [editing,setEditing]=useState<DirectMessage|null>(null);const [menu,setMenu]=useState<DirectMessage|null>(null);const [emojis,setEmojis]=useState(false);const [reduced,setReduced]=useState(true);
const beforeEdit=useRef('');const pending=useRef<{id:string;text:string;reply_to?:string}|null>(null);
useEffect(()=>{let live=true;void readDraft(server,userId,person.id).then(value=>{if(live){if(!touched.current)setText(value);setReady(true);}});return()=>{live=false;};},[server,userId,person.id]);
useEffect(()=>{if(!ready||editing)return;const timer=setTimeout(()=>{void saveDraft(server,userId,person.id,text).catch(()=>undefined);},300);return()=>clearTimeout(timer);},[text,ready,editing,server,userId,person.id]);
useEffect(()=>{void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);const sub=AccessibilityInfo.addEventListener('reduceMotionChanged',setReduced);return()=>sub.remove();},[]);
useEffect(()=>{const timer=setTimeout(()=>setQ(search.trim()),250);return()=>clearTimeout(timer);},[search]);
useEffect(()=>{const timer=setTimeout(()=>{void api.typing(person.id,active&&touched.current&&!!text.trim()&&!editing).catch(()=>undefined);},400);return()=>clearTimeout(timer);},[text,active,editing,api,person.id]);
useEffect(()=>()=>{void api.typing(person.id,false).catch(()=>undefined);},[api,person.id]);
const meta=useQuery({queryKey:[...queryKey,'typing',person.id],queryFn:()=>api.chatMeta(person.id),enabled:active,refetchInterval:active?2000:false});
const messages=useInfiniteQuery({queryKey:[...queryKey,'chat',person.id,q],initialPageParam:undefined as number|undefined,queryFn:({pageParam})=>api.messages(person.id,pageParam,q),getNextPageParam:last=>last.length===50?last[0]?.seq:undefined,enabled:active,refetchInterval:active?3000:false});
const list=[...(messages.data?.pages??[])].reverse().flat();const last=list.at(-1)?.seq;const unread=list.some(m=>m.sender_id!==userId&&!m.read);
useEffect(()=>{if(last&&unread&&active&&!q)void api.action({action:'read_chat',target_id:person.id,up_to_seq:last}).then(()=>cache.invalidateQueries({queryKey})).catch(()=>undefined);},[last,unread,active,q,api,person.id,messages.dataUpdatedAt]);
const refresh=()=>cache.invalidateQueries({queryKey});
const update=useMutation({mutationFn:(input:{id:string;body:ChatMessageUpdate})=>api.updateMessage(person.id,input.id,input.body),onSuccess:refresh});
const send=useMutation({mutationFn:async()=>{if(editing){await api.updateMessage(person.id,editing.id,{action:'edit',text});return;}if(!pending.current||pending.current.text!==text||pending.current.reply_to!==reply?.id)pending.current={id:eventId(Date.now()),text,...(reply?{reply_to:reply.id}:{})};await api.send(person.id,{...pending.current,attachment_kind:'none'});},onSuccess:async()=>{setText(editing?beforeEdit.current:'');setEditing(null);setReply(null);pending.current=null;await refresh();}});
const scroll=useRef<ScrollView>(null);const [bottom,setBottom]=useState(true);useEffect(()=>setBottom(true),[last]);
const cancelEdit=()=>{setText(beforeEdit.current);setEditing(null);};
const mutate=(m:DirectMessage,body:ChatMessageUpdate)=>{if(!update.isPending)update.mutate({id:m.id,body});setMenu(null);};
return <KeyboardAvoidingView behavior="padding" style={{flex:1}}>
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Muted>{t(person.online ? 'social.online' : 'social.offline')}</Muted><Field label={t('social.chat_search')} value={search} onChangeText={setSearch} maxLength={100}/><ErrorBox>{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}</ErrorBox></View>
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:14,gap:12}} onContentSizeChange={()=>{if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}>
{messages.hasNextPage&&<Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={()=>{setBottom(false);void messages.fetchNextPage();}}/>}
{!list.length&&<Muted>{t(messages.isPending?'social.loading':q?'social.chat_no_results':'social.first_message')}</Muted>}
{list.map((m,i)=><View key={m.id} style={{gap:8}}>
{(i===0||new Date(list[i-1]!.created_at).toDateString()!==new Date(m.created_at).toDateString())&&<Text style={{color:colors.text3,fontSize:11,textAlign:'center'}}>{new Date(m.created_at).toLocaleDateString(i18n.language,{day:'numeric',month:'long'})}</Text>}
<Bubble mine={m.sender_id===userId} animate={!reduced}>
<Pressable disabled={m.deleted} onLongPress={()=>setMenu(m)} accessibilityLabel={t('social.message_actions')}>
{m.reply_to&&<View style={{borderLeftWidth:2,borderLeftColor:colors.accent,paddingLeft:8,marginBottom:8}}><Muted>{m.reply_preview||t('social.message_deleted')}</Muted></View>}
<Text selectable style={{color:m.deleted?colors.text3:colors.text,fontSize:15,fontStyle:m.deleted?'italic':'normal'}}>{m.deleted?t('social.message_deleted'):m.text}</Text>
</Pressable>
{!m.deleted&&m.attachment_id&&m.attachment_kind!=='none'&&<Button title={t(m.attachment_kind==='track'?'social.listen_track':'social.listen_playlist')} variant="secondary" onPress={()=>router.push({pathname:m.attachment_kind==='track'?'/social-track':'/social-playlist',params:{id:m.attachment_id}})}/>}
{!m.deleted&&!!m.reactions?.length&&<View style={{flexDirection:'row',gap:6,flexWrap:'wrap'}}>{m.reactions.map(r=><Pressable key={r.emoji} accessibilityRole="button" accessibilityState={{selected:r.mine}} onPress={()=>mutate(m,{action:'react',emoji:r.emoji as typeof CHAT_REACTIONS[number]})} style={{padding:6,borderRadius:12,backgroundColor:r.mine?colors['accent-soft']:colors.surface3}}><Text style={{color:colors.text}}>{r.emoji} {r.count}</Text></Pressable>)}</View>}
<View style={{flexDirection:'row',gap:8,alignItems:'center'}}><Text style={{color:colors.text3,fontSize:10,flex:1}}>{new Date(m.created_at).toLocaleTimeString(i18n.language,{hour:'2-digit',minute:'2-digit'})}{m.edited_at&&!m.deleted?` · ${t('social.edited')}`:''}{m.sender_id===userId?` · ${t(m.read?'social.read':'social.sent')}`:''}</Text>{!m.deleted&&<Pressable accessibilityRole="button" accessibilityLabel={t('social.message_actions')} hitSlop={10} onPress={()=>setMenu(m)}><Text style={{color:colors.text3}}>•••</Text></Pressable>}</View>
</Bubble>
</View>)}
</ScrollView>
<Text accessibilityLiveRegion="polite" style={{minHeight:18,paddingHorizontal:14,color:colors.text3,fontSize:11}}>{active&&meta.data?.typing?`${person.display_name} ${t('social.typing')}`:''}</Text>
{(reply||editing)&&<View style={{padding:10,backgroundColor:colors['accent-soft'],gap:4}}><Muted>{t(editing?'social.editing':'social.replying')}</Muted><Text numberOfLines={2} style={{color:colors.text2}}>{(editing??reply)?.text}</Text><Button title={t(editing?'social.cancel_edit':'social.cancel_reply')} variant="ghost" onPress={()=>editing?cancelEdit():setReply(null)}/></View>}
<View style={{padding:12,gap:8,borderTopWidth:1,borderTopColor:colors.line}}><Field label={t('social.message')} value={text} onChangeText={value=>{touched.current=true;setText(value);}} maxLength={4000} multiline editable={!send.isPending}/><View style={{flexDirection:'row',gap:8}}><Button title="☺" variant="ghost" onPress={()=>setEmojis(v=>!v)}/><View style={{flex:1}}><Button title={t(editing?'playlists.save':'social.send')} busy={send.isPending} disabled={!text.trim()} onPress={()=>send.mutate()}/></View></View>{emojis&&<View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>{setText(v=>(v+emoji).slice(0,4000));setEmojis(false);}}/>)}</View>}</View>
<Modal transparent visible={!!menu} animationType={reduced?'none':'fade'} onRequestClose={()=>setMenu(null)}><View style={{flex:1,justifyContent:'flex-end',backgroundColor:colors.overlay}}><Screen style={{flex:0,maxHeight:'80%',borderTopLeftRadius:24,borderTopRightRadius:24}}><ScrollView contentContainerStyle={{padding:20,gap:10}}><Button title={t('form.close')} variant="ghost" onPress={()=>setMenu(null)}/>{menu&&<><Button title={t('social.reply')} variant="secondary" onPress={()=>{if(editing)cancelEdit();setReply(menu);setMenu(null);}}/><View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>mutate(menu,{action:'react',emoji})}/>)}</View>{menu.sender_id===userId&&<><Button title={t('social.edit_message')} variant="secondary" onPress={()=>{beforeEdit.current=text;setEditing(menu);setText(menu.text);setReply(null);setMenu(null);}}/><Button title={t('social.delete_message')} variant="danger" onPress={()=>mutate(menu,{action:'delete'})}/></>}</>}</ScrollView></Screen></View></Modal>
</KeyboardAvoidingView>;
}
@@ -0,0 +1,60 @@
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, ScrollView, Switch, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { eventId } from '../../player/eventId';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from '../../ui/kit';
function useListening() {
const session = useSession(); const { server } = useSessionCtx(); const user = useUser();
const api = useMemo(() => socialApi(session), [session]); const key = ['m-listening', server, user.id] as const;
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
return { api, key, query, user };
}
export function ListeningPanel() {
const { api, key, query, user } = useListening(); const { t } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText(); const colors = useColors();
const [expanded, setExpanded] = useState(false); const prefs = query.data?.preferences;
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const action = useMutation({ mutationFn: (a: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: eventId(Date.now()), action: a.action, target_id: a.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <View style={{ gap: 12 }}><Button title={t('social.together')} variant="secondary" onPress={() => setExpanded(v => !v)} />
{expanded && <View style={{ padding: 16, borderRadius: 16, gap: 14, backgroundColor: colors.surface }}><Muted>{t('social.stream_prefs_hint')}</Muted>
{(['requests', 'auto_accept'] as const).map(field => <View key={field} style={{ gap: 8 }}><Muted>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</Muted><View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} disabled={!prefs || save.isPending} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onPress={() => prefs && save.mutate({ ...prefs, [field]: v })} />)}</View></View>)}
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}><Text style={{ color: colors.text, flex: 1 }}>{t('social.stream_autoplay')}</Text><Switch accessibilityLabel={t('social.stream_autoplay')} value={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onValueChange={autoplay => prefs && save.mutate({ ...prefs, autoplay })} /></View>
</View>}
{query.data?.requests.map(r => <View key={r.id} style={{ padding: 16, borderRadius: 16, gap: 10, backgroundColor: colors.surface }}><Text style={{ color: colors.text }}>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</Text>{r.recipient_id === user.id ? <><Button title={t('social.accept')} disabled={action.isPending} onPress={() => action.mutate({ action: 'accept', target: r.id })} /><Button title={t('social.decline')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'decline', target: r.id })} /></> : <Button title={t('form.cancel')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'cancel', target: r.id })} />}</View>)}
<ErrorBox>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</ErrorBox>
</View>;
}
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
const { t } = useTranslation(); const [open, setOpen] = useState(false);
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
return <><Button title={t('social.together')} disabled={!person.online} variant="ghost" onPress={() => setOpen(true)} />{!person.online && <Muted>{t('social.together_offline')}</Muted>}{open && person.online && <TogetherSheet person={person} close={() => setOpen(false)} />}</>;
}
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
<Muted>{t('social.stream_hint')}</Muted><ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
{action.isSuccess ? <Muted>{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</Muted> : <Button title={t('social.send')} busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onPress={() => action.mutate()} />}
</ScrollView></Screen></Modal>;
}
export function ListeningBanner() {
const state = usePlayerState(); const session = useSession(); const user = useUser(); const colors = useColors(); const { t } = useTranslation(); const errorText = useErrorText(); const room = state.listening?.room;
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: eventId(Date.now()), action: room.owner_id === user.id ? 'end' : 'leave', target_id: room.id }) });
if (!room) return null;
return <View style={{ padding: 12, gap: 6, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.ok }}><Text style={{ color: colors.text, fontWeight: '600' }}>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</Text>
<Muted>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</Muted>
<Muted>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</Muted>
{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button title={t('social.stream_start_audio')} variant="secondary" onPress={() => player.beginListeningAudio()} />}
<Button title={t(room.owner_id === user.id ? 'social.stream_end' : 'social.stream_leave')} variant="ghost" busy={action.isPending} onPress={() => action.mutate()} />
<ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
</View>;
}
@@ -0,0 +1,38 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { Button, ErrorBox, H1, Muted, Screen, TrackRow, useErrorText } from '../../ui/kit';
export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
const { id, owner } = useLocalSearchParams<{ id: string; owner?: string }>();
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const errorText = useErrorText();
const [playError, setPlayError] = useState<unknown>();
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id, owner], refetchInterval: owner ? 15_000 : false, queryFn: async () => {
if (owner) return unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId: owner } } }));
if (kind === 'track') {
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
return { id: track.id, name: track.title, tracks: [track] };
}
return unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }));
} });
const visible = query.isError ? undefined : query.data;
return <Screen><FlatList data={visible?.tracks ?? []} keyExtractor={x => x.id}
ListHeaderComponent={<View style={{ padding: 20, gap: 16 }}>
<Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />
<H1>{visible?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
{query.isPending && <Muted>{t('social.loading')}</Muted>}
<ErrorBox>{query.error || playError ? errorText(query.error ?? playError) : null}</ErrorBox>
</View>}
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(visible?.tracks ?? [], index, { type: kind === 'track' ? 'queue' : 'collection', id: visible?.id, label: visible?.name }).catch(setPlayError)} />}
/></Screen>;
}
+149
View File
@@ -0,0 +1,149 @@
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { eventId } from '../../player/eventId';
import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useFocusEffect, useRouter } from 'expo-router';
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { useColors } from '../../theme';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
function useSocial() {
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
const api = useMemo(() => socialApi(session), [session]);
const [active, setActive] = useState(AppState.currentState === 'active');
useEffect(() => { const sub = AppState.addEventListener('change', s => setActive(s === 'active')); return () => sub.remove(); }, []);
const key = ['m-social', server, user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, enabled: active, refetchInterval: active ? 15_000 : false });
return { api, key, inbox, user, active };
}
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
export function NativeChatProvider({ children }: { children: ReactNode }) {
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
</Screen>
</View></View>
</Modal>
</ChatDockContext.Provider>;
}
export function FriendRecommendations() {
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
}
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
const { t } = useTranslation();
const { inbox, api, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [sent, setSent] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const ids = useRef(new Map<string, string>());
const send = useMutation({ mutationFn: async (peer: string) => {
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
} });
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 24, gap: 16 }}>
<Button title={t('form.cancel')} variant="ghost" onPress={close} />
<H1>{t('player.share')}</H1><Muted>{attachment.title}</Muted>
<Button title={t(copied ? 'social.copied' : 'social.copy')} disabled={send.isPending} onPress={() => send.mutate('copy')} />
<Button title={t('social.external')} variant="secondary" disabled={send.isPending} onPress={() => send.mutate('external')} />
<H1>{t('social.send_friend')}</H1>
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <Muted>{t('social.no_friends_share')}</Muted>}
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} title={`${p.display_name} · ${t(sent.includes(p.id) ? 'social.sent' : 'social.send')}`} variant="secondary" disabled={send.isPending || sent.includes(p.id)} onPress={() => send.mutate(p.id)} />)}
<ErrorBox>{send.error || inbox.error ? errorText(send.error ?? inbox.error) : null}</ErrorBox>
</ScrollView></Screen></Modal>;
}
export function SocialScreen({ root = false }: { root?: boolean }) {
const dock = useContext(ChatDockContext);
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [peer, setPeer] = useState('');
const [notifications, setNotifications] = useState(false);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
const act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Muted>{t('social.activity_visibility')}</Muted>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
<Muted>{t('social.activity_privacy_hint')}</Muted>
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
</View>
<ListeningPanel />
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
</View>
<ErrorBox>{inbox.error || results.error || act.error ? errorText(inbox.error ?? results.error ?? act.error) : null}</ErrorBox>
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
{notifications ? <>
{inbox.data?.notifications.length === 0 && <Muted>{t('social.no_notifications')}</Muted>}
{inbox.data?.notifications.map(n => <View key={n.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10, borderWidth: 1, borderColor: n.read ? colors.line : colors.accent }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{n.actor_name}</Text><Muted>{t(`social.kind_${n.kind}`)}</Muted><Muted>{new Date(n.created_at).toLocaleString()}</Muted>
<Button title={t('social.open')} variant="secondary" onPress={() => { setNotifications(false); setSearch(''); setQ(''); setPeer(n.actor_id); }} />
{!n.read && <Button title={t('social.mark_read')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'read_notification', target_id: n.id })} />}
</View>)}
</> : <>
<Field label={t('social.search')} value={search} onChangeText={setSearch} maxLength={80} />
{q.length >= 2 && results.isFetching && <Muted>{t('social.loading')}</Muted>}
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
{p.relationship === 'friend' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t(p.online ? 'social.online' : 'social.offline')}</Text>}
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
</View>}
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
<Button title={t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: p.relationship === 'blocked' ? 'unblock' : 'block', target_id: p.id })} />
</View>)}
</>}
</ScrollView></Screen>;
}
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
const [focused,setFocused]=useState(false);
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
}
+14
View File
@@ -0,0 +1,14 @@
import * as FileSystem from 'expo-file-system';
import { sha256 } from 'js-sha256';
import { chatDraftKey } from '@pipu/core';
const directory = `${FileSystem.documentDirectory}chat-drafts/`;
const path = (server: string, user: string, peer: string) => `${directory}${sha256(chatDraftKey(server,user,peer))}.txt`;
export async function readDraft(server: string, user: string, peer: string) {
try { return await FileSystem.readAsStringAsync(path(server,user,peer)); } catch { return ''; }
}
export async function saveDraft(server: string, user: string, peer: string, text: string) {
if (!text) { await FileSystem.deleteAsync(path(server,user,peer), { idempotent: true }); return; }
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
await FileSystem.writeAsStringAsync(path(server,user,peer),text);
}
+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);
},
};
+7
View File
@@ -0,0 +1,7 @@
export type ReleaseNeed = 'current' | 'optional' | 'required';
/** Нужно ли обновление: обязательное, если установленная версия ниже min_supported. */
export function releaseNeed(installed: number, versionCode: number, minSupported: number): ReleaseNeed {
if (versionCode <= installed) return 'current';
return installed < minSupported ? 'required' : 'optional';
}
+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);
}
+187
View File
@@ -0,0 +1,187 @@
import { unwrap, type AuthSession } from '@pipu/core';
import type { components } from '@pipu/api-client';
import Constants from 'expo-constants';
import * as FileSystem from 'expo-file-system';
import * as IntentLauncher from 'expo-intent-launcher';
import * as SecureStore from 'expo-secure-store';
import { sha256 } from 'js-sha256';
import { releaseNeed } from './releaseNeed';
export type AndroidRelease = components['schemas']['AndroidRelease'];
/** Состояние самообновления (docs/mobile.md#самообновление). */
export type UpdateState =
| { status: 'idle' }
| { status: 'checking' }
| { status: 'latest' }
| {
status: 'available';
release: AndroidRelease;
required: boolean;
dismissed: boolean;
}
| {
status: 'downloading';
release: AndroidRelease;
required: boolean;
progress: number;
}
| { status: 'verifying'; release: AndroidRelease; required: boolean }
| {
status: 'ready';
release: AndroidRelease;
required: boolean;
file: string;
}
| {
status: 'error';
release: AndroidRelease;
required: boolean;
error: 'bad_checksum' | 'failed';
};
const CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
const CHECKED_KEY = 'pipu.update.checked';
const HASH_CHUNK = 1 << 20;
const APK_MIME = 'application/vnd.android.package-archive';
export function installedVersionCode(): number {
return Number((Constants.expoConfig?.extra as { versionCode?: number } | undefined)?.versionCode ?? 0);
}
/** SHA-256 файла кусками по 1 МБ: целиком APK в память JS не читаем. */
async function fileSha256(uri: string, size: number): Promise<string> {
const hash = sha256.create();
for (let pos = 0; pos < size; pos += HASH_CHUNK) {
const b64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
position: pos,
length: Math.min(HASH_CHUNK, size - pos),
});
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
hash.update(bytes);
}
return hash.hex();
}
class Updater {
private current: UpdateState = { status: 'idle' };
private readonly listeners = new Set<() => void>();
private download: FileSystem.DownloadResumable | null = null;
get state(): UpdateState {
return this.current;
}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
private set(s: UpdateState) {
this.current = s;
for (const fn of this.listeners) fn();
}
/** Проверка: автоматически — не чаще раза в 6 часов, вручную (профиль) — всегда. */
async check(session: AuthSession, manual = false): Promise<void> {
if (['checking', 'downloading', 'verifying'].includes(this.current.status)) return;
if (!manual) {
const last = Number((await SecureStore.getItemAsync(CHECKED_KEY)) ?? 0);
if (Date.now() - last < CHECK_EVERY_MS) return;
}
if (manual) this.set({ status: 'checking' });
try {
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
const installed = installedVersionCode();
const need = releaseNeed(installed, release.version_code, release.min_supported);
if (need !== 'current') {
this.set({
status: 'available',
release,
required: need === 'required',
dismissed: false,
});
} else {
this.set(manual ? { status: 'latest' } : { status: 'idle' });
}
} catch {
// Нет релиза или сервер без Gitea — молча; при ручной проверке показываем «последняя версия».
this.set(manual ? { status: 'latest' } : { status: 'idle' });
}
}
dismiss() {
const s = this.current;
if (s.status === 'available' && !s.required) this.set({ ...s, dismissed: true });
else if (s.status === 'latest') this.set({ status: 'idle' });
}
/** Скачивает APK с сервера (с Bearer), сверяет SHA-256 и запускает установщик. */
async downloadAndInstall(session: AuthSession, server: string): Promise<void> {
const s = this.current;
if (s.status !== 'available' && s.status !== 'error' && s.status !== 'ready') return;
const { release, required } = s;
if (s.status === 'ready') return this.install(s.file);
// Любой запрос через client обновит access-токен, если он истёк.
await session.client.GET('/api/v1/app/android/latest').catch(() => undefined);
const file = `${FileSystem.cacheDirectory}pipu-music-${release.version}.apk`;
this.set({ status: 'downloading', release, required, progress: 0 });
try {
await FileSystem.deleteAsync(file, { idempotent: true });
this.download = FileSystem.createDownloadResumable(
server + release.download_url,
file,
{ headers: { Authorization: `Bearer ${session.accessToken ?? ''}` } },
(p) => {
const total = p.totalBytesExpectedToWrite > 0 ? p.totalBytesExpectedToWrite : release.size;
this.set({
status: 'downloading',
release,
required,
progress: total ? p.totalBytesWritten / total : 0,
});
},
);
const res = await this.download.downloadAsync();
this.download = null;
if (!res || res.status !== 200) throw new Error(`status ${res?.status}`);
this.set({ status: 'verifying', release, required });
const info = await FileSystem.getInfoAsync(file);
const size = info.exists ? info.size : 0;
if ((await fileSha256(file, size)) !== release.sha256.toLowerCase()) {
await FileSystem.deleteAsync(file, { idempotent: true });
this.set({ status: 'error', release, required, error: 'bad_checksum' });
return;
}
this.set({ status: 'ready', release, required, file });
await this.install(file);
} catch {
this.download = null;
this.set({ status: 'error', release, required, error: 'failed' });
}
}
private async install(file: string) {
const uri = await FileSystem.getContentUriAsync(file);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: uri,
type: APK_MIME,
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
});
}
/** Экран «Установка неизвестных приложений» для нашего пакета. */
async openInstallSettings() {
const pkg = Constants.expoConfig?.android?.package ?? 'app.pipu.music';
await IntentLauncher.startActivityAsync('android.settings.MANAGE_UNKNOWN_APP_SOURCES', { data: `package:${pkg}` });
}
}
export const updater = new Updater();
+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;
}
+294
View File
@@ -0,0 +1,294 @@
import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
import { unwrap, type AuthSession, type Track } from '@pipu/core';
import * as FileSystem from 'expo-file-system';
import * as SecureStore from 'expo-secure-store';
import { qualityPrefs } from '../lib/prefs';
import { shortHash } from '../lib/storage';
import { getDb } from './db';
export type DownloadStatus = 'queued' | 'downloading' | 'done' | 'error';
export interface DownloadItem {
track: Track;
status: DownloadStatus;
progress: number;
bytes: number;
addedAt: number;
}
export interface DownloadsState {
items: ReadonlyMap<string, DownloadItem>;
wifiOnly: boolean;
autoLikes: boolean;
/** Сеть разрешает загрузку прямо сейчас (с учётом «только Wi-Fi»). */
allowed: boolean;
}
const PARALLEL = 2;
const WIFI_KEY = 'pipu.dl.wifi';
const AUTO_LIKES_KEY = 'pipu.dl.autolikes';
/**
* Загрузки для офлайна: файлы — в documentDirectory/offline/<owner>/, метаданные (DTO трека) — в SQLite.
* Не более двух загрузок параллельно; по умолчанию только по Wi-Fi; прерванные продолжаются при запуске.
*/
class Downloads {
private session: AuthSession | null = null;
private server = '';
private owner = '';
private active = new Map<string, FileSystem.DownloadResumable | null>();
private net: NetInfoState | null = null;
private unsubscribeNet: (() => void) | null = null;
private listeners = new Set<() => void>();
private current: DownloadsState = { items: new Map(), wifiOnly: true, autoLikes: false, allowed: false };
get state(): DownloadsState {
return this.current;
}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
private set(patch: Partial<DownloadsState>) {
this.current = { ...this.current, ...patch };
for (const fn of this.listeners) fn();
}
private setItem(id: string, patch: Partial<DownloadItem> | null) {
const items = new Map(this.current.items);
if (patch === null) items.delete(id);
else {
const prev = items.get(id);
if (!prev && !patch.track) return;
items.set(id, { ...(prev as DownloadItem), ...patch });
}
this.set({ items });
}
private get dir() {
return `${FileSystem.documentDirectory}offline/${this.owner}/`;
}
private file(trackId: string) {
return `${this.dir}${trackId}.opus`;
}
private cover(albumId: string) {
return `${this.dir}covers/${albumId}.webp`;
}
/** Привязка к пользователю: загружает список скачанного и продолжает незавершённые загрузки. */
async attach(session: AuthSession | null, server: string, userId: string | null) {
const owner = session && userId ? shortHash(`${server}|${userId}`) : '';
if (owner === this.owner && session === this.session) return;
for (const d of this.active.values()) void d?.pauseAsync().catch(() => undefined);
this.active.clear();
this.session = session;
this.server = server;
this.owner = owner;
if (!owner) {
this.set({ items: new Map() });
return;
}
const [wifi, autoLikes] = await Promise.all([SecureStore.getItemAsync(WIFI_KEY), SecureStore.getItemAsync(AUTO_LIKES_KEY)]);
await FileSystem.makeDirectoryAsync(`${this.dir}covers/`, { intermediates: true }).catch(() => undefined);
const db = await getDb();
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'downloading'`, owner);
const rows = await db.getAllAsync<{ track_id: string; track: string; status: DownloadStatus; bytes: number; added_at: number }>(
'SELECT track_id, track, status, bytes, added_at FROM downloads WHERE owner = ? ORDER BY added_at',
owner,
);
const items = new Map<string, DownloadItem>();
for (const r of rows) {
items.set(r.track_id, { track: JSON.parse(r.track) as Track, status: r.status, progress: r.status === 'done' ? 1 : 0, bytes: r.bytes, addedAt: r.added_at });
}
this.set({ items, wifiOnly: wifi !== '0', autoLikes: autoLikes === '1' });
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
this.net = st;
this.set({ allowed: this.networkAllowed() });
this.pump();
});
this.net = await NetInfo.fetch();
this.set({ allowed: this.networkAllowed() });
this.pump();
}
private networkAllowed(): boolean {
if (!this.net?.isConnected) return false;
return !this.current.wifiOnly || this.net.type === 'wifi' || this.net.type === 'ethernet';
}
async setWifiOnly(v: boolean) {
await SecureStore.setItemAsync(WIFI_KEY, v ? '1' : '0');
this.set({ wifiOnly: v });
this.set({ allowed: this.networkAllowed() });
this.pump();
}
async setAutoLikes(v: boolean) {
await SecureStore.setItemAsync(AUTO_LIKES_KEY, v ? '1' : '0');
this.set({ autoLikes: v });
}
isDownloaded(trackId: string): boolean {
return this.current.items.get(trackId)?.status === 'done';
}
/** Локальный файл трека, если он скачан. */
localTrack(trackId: string): string | undefined {
return this.isDownloaded(trackId) ? this.file(trackId) : undefined;
}
localCover(track: Track): string | undefined {
return this.isDownloaded(track.id) && track.album.cover_url ? this.cover(track.album.id) : undefined;
}
/** Обложка альбома на устройстве, если из него что-то скачано. */
localAlbumCover(albumId: string): string | undefined {
for (const it of this.current.items.values()) {
if (it.status === 'done' && it.track.album.id === albumId && it.track.album.cover_url) return this.cover(albumId);
}
return undefined;
}
/** Скачанные треки в порядке добавления. */
downloaded(): Track[] {
return [...this.current.items.values()].filter((i) => i.status === 'done').map((i) => i.track);
}
async enqueue(tracks: Track[]) {
if (!this.owner) return;
const db = await getDb();
const now = Date.now();
for (const [n, t] of tracks.entries()) {
if (!t.available || this.current.items.has(t.id)) continue;
const addedAt = now + n;
await db.runAsync(
`INSERT OR IGNORE INTO downloads (owner, track_id, track, status, added_at) VALUES (?, ?, ?, 'queued', ?)`,
this.owner,
t.id,
JSON.stringify(t),
addedAt,
);
this.setItem(t.id, { track: t, status: 'queued', progress: 0, bytes: 0, addedAt });
}
this.pump();
}
async remove(trackIds: string[]) {
const db = await getDb();
for (const id of trackIds) {
const d = this.active.get(id);
this.active.delete(id);
await d?.pauseAsync().catch(() => undefined);
await FileSystem.deleteAsync(this.file(id), { idempotent: true });
await FileSystem.deleteAsync(`${this.file(id)}.part`, { idempotent: true });
await db.runAsync('DELETE FROM downloads WHERE owner = ? AND track_id = ?', this.owner, id);
this.setItem(id, null);
}
this.pump();
}
/** Реакция в сохранённом DTO: офлайн-волна и списки скачанного видят её без сети. */
async setReaction(trackId: string, reaction: Track['reaction']) {
const it = this.current.items.get(trackId);
if (!it || it.track.reaction === reaction) return;
const track = { ...it.track, reaction };
this.setItem(trackId, { track });
const db = await getDb();
await db.runAsync('UPDATE downloads SET track = ? WHERE owner = ? AND track_id = ?', JSON.stringify(track), this.owner, trackId);
}
/** Повторить загрузки с ошибкой. */
async retry() {
const db = await getDb();
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'error'`, this.owner);
for (const [id, it] of this.current.items) if (it.status === 'error') this.setItem(id, { status: 'queued', progress: 0 });
this.pump();
}
/** Занято на устройстве и свободно в телефоне (байты). */
async usage(): Promise<{ used: number; free: number }> {
let used = 0;
for (const it of this.current.items.values()) used += it.bytes;
const free = await FileSystem.getFreeDiskStorageAsync().catch(() => 0);
return { used, free };
}
private pump() {
if (!this.owner || !this.current.allowed) return;
for (const [id, it] of this.current.items) {
if (this.active.size >= PARALLEL) return;
if (it.status !== 'queued' || this.active.has(id)) continue;
this.active.set(id, null);
void this.run(id).finally(() => {
this.active.delete(id);
this.pump();
});
}
}
private async run(id: string) {
const session = this.session;
const owner = this.owner;
if (!session) return;
const db = await getDb();
const status = async (s: DownloadStatus, patch: Partial<DownloadItem> = {}, track?: Track) => {
if (this.owner !== owner) return;
await db.runAsync(
'UPDATE downloads SET status = ?, bytes = ?, track = COALESCE(?, track) WHERE owner = ? AND track_id = ?',
s,
patch.bytes ?? 0,
track ? JSON.stringify(track) : null,
owner,
id,
);
this.setItem(id, { status: s, ...patch, ...(track ? { track } : {}) });
};
try {
await status('downloading', { progress: 0 });
// Подписанные ссылки живут 12 ч — перед загрузкой берём свежий DTO.
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: [id] } }));
const track = res.items[0];
if (!track?.available || !track.stream_url) {
await status('error');
return;
}
const part = `${this.file(id)}.part`;
const q = (await qualityPrefs.load()).download;
const dl = FileSystem.createDownloadResumable(`${this.abs(track.stream_url)}&q=${q}`, part, {}, (p) => {
if (p.totalBytesExpectedToWrite > 0) this.setItem(id, { progress: p.totalBytesWritten / p.totalBytesExpectedToWrite });
});
this.active.set(id, dl);
const out = await dl.downloadAsync();
if (this.active.get(id) !== dl) return; // отменено (удалили или сменился пользователь)
if (!out || out.status !== 200) throw new Error(`status ${out?.status}`);
await FileSystem.moveAsync({ from: part, to: this.file(id) });
if (track.album.cover_url) {
const cover = this.cover(track.album.id);
const info = await FileSystem.getInfoAsync(cover);
if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
}
const info = await FileSystem.getInfoAsync(this.file(id));
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
} catch {
await status('error', { progress: 0 });
}
}
private abs(path: string) {
return /^https?:/.test(path) ? path : this.server + path;
}
}
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
export function sizedCover(url: string, size: number): string {
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
}
export const downloads = new Downloads();
+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 без ответа сервера
}
+537
View File
@@ -0,0 +1,537 @@
import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
createActivityPublisher,
type ActivityPublisher,
ListeningCoordinator,
type ListeningRoom,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveInfo,
type WaveOutcome,
type WaveFeedback,
PlayAttempt,
normalizeGain,
unwrap,
type AuthSession,
type PlayContext,
type Track,
} from '@pipu/core';
import TrackPlayer, {
AppKilledPlaybackBehavior,
Capability,
Event,
State,
type Track as RntpTrack,
} from 'react-native-track-player';
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads, sizedCover } from '../offline/downloads';
import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave';
import { eventId } from './eventId';
export { offlineWave };
export type { WaveInfo } from '@pipu/core';
export interface PlayerState {
listening?: import('@pipu/core').ListeningClientState;
ready: boolean;
track: Track | null;
queue: Track[];
index: number;
playing: boolean;
buffering: boolean;
positionMs: number;
durationMs: number;
context: PlayContext | null;
wave: WaveInfo | null;
}
const SAVE_EVERY_MS = 10_000;
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
export function absolute(server: string, path: string | null | undefined): string | undefined {
if (!path) return undefined;
return /^https?:/.test(path) ? path : server + path;
}
/** Качество по сети (docs/playback.md#качество): Wi-Fi — high, сотовая — medium. */
/** Качество стрима по типу сети — из настроек профиля. */
async function quality(): Promise<Quality> {
const prefs = await qualityPrefs.load();
try {
const net = await NetInfo.fetch();
return net.type === 'cellular' ? prefs.cellular : prefs.wifi;
} catch {
return prefs.wifi;
}
}
/**
* Плеер Android. Очередь живёт в ExoPlayer (react-native-track-player) — играет и с выгруженным UI;
* здесь — зеркало очереди с DTO треков, учёт прослушиваний (PlayAttempt из @pipu/core), события,
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
*/
class Controller {
private activity: ActivityPublisher | null = null;
private listening: ListeningCoordinator | null = null;
private interrupted = false;
private remoteRoom: string | null = null;
private queueVersion = 0;
private session: AuthSession | null = null;
private server = '';
private events: EventBuffer | null = null;
private persistent: PersistentEvents | null = null;
private unsubscribeNet: (() => void) | null = null;
private attempt: PlayAttempt | null = null;
private lastPositionMs = 0;
private lastSave = 0;
private readonly wavePolicy = new WaveCoordinator({
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id } }, body: { feedback },
}),
),
});
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>();
private state: PlayerState = {
ready: false, track: null, queue: [], index: 0, playing: false, buffering: false, positionMs: 0, durationMs: 0,
context: null, wave: null,
};
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getState = () => this.state;
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
this.activity?.update(this.state);
this.listening?.update();
for (const fn of this.listeners) fn();
}
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
attach(session: AuthSession | null, server: string, userId: string | null) {
if (this.session === session) return;
this.activity?.dispose();
this.listening?.dispose();
this.listening = null;
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
if (session && this.activity) {
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
read: this.getState,
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }),
});
this.listening.start();
}
void this.events?.flush();
this.session = session;
this.server = server;
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
const sink = this.persistent;
this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
void sink?.drain();
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
if (st.isConnected) void this.persistent?.drain();
});
if (!session) void this.reset();
}
setup(): Promise<void> {
this.setupDone ??= (async () => {
// Очистка кэша из профиля — только пока сервис плеера не запущен (иначе SimpleCache держит файлы).
if (!(await TrackPlayer.isServiceRunning().catch(() => true))) await playerCache.clearIfRequested();
try {
// 500 МБ кэша ExoPlayer для недавних стримов (значение — в КБ).
await TrackPlayer.setupPlayer({ maxCacheSize: 500 * 1024, autoHandleInterruptions: true });
} catch {
// уже настроен (повторный запуск JS при живом сервисе)
}
await TrackPlayer.updateOptions({
capabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext, Capability.SkipToPrevious, Capability.SeekTo],
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
progressUpdateEventInterval: 1,
android: { appKilledPlaybackBehavior: AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification },
});
this.bind();
this.update({ ready: true });
})();
return this.setupDone;
}
private bind() {
TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => {
this.update({ playing: state === State.Playing, buffering: state === State.Buffering || state === State.Loading });
if (state === State.Paused) this.save();
});
TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ position, duration }) => {
const ms = Math.round(position * 1000);
this.attempt?.tick(ms);
this.lastPositionMs = ms;
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
const cf = Math.min(6, Math.max(0, this.userPrefs()?.crossfade_s ?? 0));
const left = duration - position;
const fade = cf > 0 && left < cf ? Math.max(0, left / cf) : 1;
void this.applyVolume(this.state.track, fade);
});
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
this.finishAttempt(lastPosition);
if (index === undefined || index === null) return;
const track = this.state.queue[index];
if (!track) return;
this.attempt = new PlayAttempt(track, 0);
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
void this.applyVolume(track, 1);
this.events?.push({
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
context_id: this.state.context?.id,
});
this.save();
void this.refillWave();
});
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => {
this.finishAttempt(this.lastPositionMs / 1000, true);
void this.events?.flush();
});
TrackPlayer.addEventListener(Event.PlaybackError, () => {
// Ссылка могла устареть или трек пропал — переходим дальше, ExoPlayer не застрянет на ошибке.
void TrackPlayer.skipToNext().catch(() => undefined);
});
}
/** Завершение попытки: доиграно (позиция у конца) или пропущено. */
private finishAttempt(lastPositionS: number, ended = false) {
const a = this.attempt;
if (!a) return;
this.attempt = null;
const pos = Math.round(lastPositionS * 1000);
if (this.neutralSwitch) {
this.neutralSwitch = false;
if (a.playedMs >= EARLY_SKIP_MS) {
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush();
}
return;
}
const kind = ended ? 'play_end' : a.outcome(pos);
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush();
if (kind === 'play_end') this.noteWave(a.track.id, 'complete');
else {
const early = a.playedMs < EARLY_SKIP_MS;
this.noteWave(a.track.id, early ? 'skip_early' : 'skip');
if (early) void this.refillWave(true);
}
}
private userPrefs() {
const st = this.session?.state;
return st?.status === 'signed_in' ? st.user.ui_prefs : undefined;
}
/** Громкость 0..1: нормализация вниз к −14 LUFS и затухание кроссфейда в конце трека. */
private async applyVolume(track: Track | null, fade: number) {
const gain = Math.min(1, normalizeGain(track?.features?.loudness_lufs, this.userPrefs()?.normalize !== false) * fade);
await TrackPlayer.setVolume(gain).catch(() => undefined);
}
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
return tracks.map((t) => {
const cover = absolute(this.server, t.album.cover_url);
return {
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
duration: t.duration_ms / 1000,
};
});
}
async playList(tracks: Track[], index: number, context: PlayContext) {
if (this.listening?.locked) return;
const version = ++this.queueVersion;
const current = () => version === this.queueVersion && !this.listening?.locked;
const list = tracks.filter(playable);
const target = tracks[index];
if (!target || !list.length) return;
await this.setup();
if (!current()) return;
const native = await this.toRntp(list);
if (!current()) return;
this.leaveWave();
this.finishAttempt(this.lastPositionMs / 1000);
const start = Math.max(list.indexOf(target), 0);
this.update({ queue: list, context, index: start, track: list[start] ?? null });
await TrackPlayer.reset();
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
await TrackPlayer.skip(start);
if (!current()) return;
await TrackPlayer.play();
}
toggle() {
if (this.listening?.locked) return;
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
}
next() {
if (this.listening?.locked) return;
void TrackPlayer.skipToNext().catch(() => undefined);
}
async prev() {
if (this.listening?.locked) return;
const { position } = await TrackPlayer.getProgress();
if (this.listening?.locked) return;
if (position > 3) await TrackPlayer.seekTo(0);
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
}
seek(ms: number) {
if (this.listening?.locked) return;
const t = this.state.track;
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
this.attempt?.seek(ms);
this.lastPositionMs = ms;
void TrackPlayer.seekTo(ms / 1000);
}
/** Реакция на трек: сразу в UI; без сети запоминается и уходит на сервер при появлении связи. */
async react(track: Track, reaction: 'like' | 'dislike' | null) {
if (!this.persistent) return;
const prev = track.reaction;
const apply = (r: Track['reaction']) => {
this.update({
queue: this.state.queue.map((t) => (t.id === track.id ? { ...t, reaction: r } : t)),
track: this.state.track?.id === track.id ? { ...this.state.track, reaction: r } : this.state.track,
});
void downloads.setReaction(track.id, r);
};
apply(reaction);
this.events?.push({ type: reaction ?? (prev === 'like' ? 'unlike' : 'undislike'), track_id: track.id });
try {
await this.persistent.react(track.id, reaction);
} catch (e) {
apply(prev);
throw e;
}
if (reaction === 'like' && downloads.state.autoLikes) void downloads.enqueue([{ ...track, reaction }]);
if (reaction) {
this.noteWave(track.id, reaction);
void this.refillWave(true);
}
if (reaction === 'dislike' && this.state.track?.id === track.id) this.next();
}
// --- «Моя волна» ---
async startWave(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
if (!this.session) return;
await this.setup();
this.leaveWave();
const version = this.wavePolicy.version;
let res;
try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
} catch (e) {
if (!this.wavePolicy.isCurrent(version)) return;
// Нет связи с сервером — офлайн-волна из скачанного.
const local = offlineWave(downloads.downloaded());
if (!local.length) throw e;
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
}
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter(playable);
if (!tracks.length) return;
this.finishAttempt(this.lastPositionMs / 1000);
this.wavePolicy.activate(res.session_id);
this.update({
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
});
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(tracks));
await TrackPlayer.play();
}
async setWaveContext(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood);
const version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
if (!this.wavePolicy.isCurrent(version)) return;
await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version));
if (!this.wavePolicy.isCurrent(version)) return;
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
if (res.tracks.some(playable)) {
this.neutralSwitch = true;
await TrackPlayer.skipToNext().catch(() => {
this.neutralSwitch = false;
});
}
}
private leaveWave() {
const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return;
this.update({ wave: null });
void this.session?.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
.catch(() => undefined);
}
private noteWave(trackId: string, outcome: WaveOutcome) {
this.wavePolicy.note(trackId, outcome);
}
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
const keep = this.state.queue.slice(0, this.state.index + 1);
const add = tracks.filter(playable);
const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.removeUpcomingTracks();
if (!current()) return;
await TrackPlayer.add(native);
if (current()) this.update({ queue: [...keep, ...add] });
}
private async refillWave(refresh = false) {
if (!this.session) return;
const remaining = this.state.queue.length - this.state.index - 1;
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
if (refresh) await this.replaceUpcoming(res.tracks, current);
else {
const add = res.tracks.filter(playable);
const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
this.update({ queue: [...this.state.queue, ...add] });
}
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
});
}
// --- состояние ---
private save() {
const t = this.state.track;
if (!t || !this.session) return;
this.lastSave = Date.now();
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: t.id,
position_ms: this.lastPositionMs,
queue: this.state.queue.slice(0, 500).map((x) => x.id),
queue_index: Math.min(this.state.index, 499),
context: this.state.context ? { type: this.state.context.type, id: this.state.context.id } : undefined,
device_id: 'android',
},
})
.catch(() => undefined);
}
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
async restore() {
if (!this.session || this.state.track || this.listening?.locked) return;
const session = this.session;
const initial = this.state;
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
await this.setup();
if (!current()) return;
try {
const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
if (!current()) return;
if (!st.track_id || !st.queue.length) return;
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
if (!current()) return;
const tracks = res.items.filter(playable);
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
if (!tracks.length) return;
const native = await this.toRntp(tracks);
if (!current()) return;
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
await TrackPlayer.reset();
if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.add(native);
if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
} catch {
// офлайн или нет сохранённого состояния
}
}
async reset() {
this.queueVersion++;
this.wavePolicy.activate(null);
this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
}
flush() {
this.save();
void this.events?.flush();
}
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
setInterrupted(paused: boolean) { this.interrupted = paused; }
beginListeningAudio() { this.listening?.beginAudio(); }
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { await TrackPlayer.pause(); return; }
await this.setup();
if (!current()) return;
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
const track = unwrap(await this.session!.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
const native = await this.toRntp([track]);
if (!current()) return;
this.remoteRoom = room.id;
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
await TrackPlayer.pause();
if (!current()) return;
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
await TrackPlayer.reset();
if (!current()) return;
await TrackPlayer.add(native);
}
if (!current()) return;
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
if (!current()) { await TrackPlayer.pause(); return; }
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
else if (this.state.playing) await TrackPlayer.pause();
}
}
export const player = new Controller();
+9
View File
@@ -0,0 +1,9 @@
import { uuidv7 } from '@pipu/core';
/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */
export function eventId(now: number): string {
return uuidv7(now, () => {
const hex = globalThis.expo.uuidv4().replace(/-/g, '');
return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
});
}
+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 };
+10
View File
@@ -0,0 +1,10 @@
import type { Track } from '@pipu/core';
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
return tracks
.filter((t) => t.reaction !== 'dislike')
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
.sort((a, b) => a.key - b.key)
.map((x) => x.t);
}
+22
View File
@@ -0,0 +1,22 @@
import TrackPlayer, { Event } from 'react-native-track-player';
import { player } from './controller';
/**
* Фоновый сервис: кнопки уведомления, экрана блокировки и гарнитуры. Работает и без UI —
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
*/
export async function PlaybackService(): Promise<void> {
TrackPlayer.addEventListener(Event.RemotePlay, () => player.play());
TrackPlayer.addEventListener(Event.RemotePause, () => player.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => player.pause());
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
player.setInterrupted(paused || permanent);
if (permanent) void TrackPlayer.pause();
else if (paused) void TrackPlayer.pause();
});
}
+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;
+87
View File
@@ -0,0 +1,87 @@
import type { components } from '@pipu/api-client';
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { FriendRecommendations } from '../features/social/Social';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
import { useColors } from '../theme';
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit';
type Options = components['schemas']['Onboarding'];
type Choice = components['schemas']['OnboardingChoice'];
function useOnboarding() {
const session = useSession();
const user = useUser();
const { server } = useSessionCtx();
return useQuery({ queryKey: ['onboarding', server, user.id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: 0, refetchInterval: 30_000 });
}
export function OnboardingGate({ children }: { children: ReactNode }) {
const query = useOnboarding();
const client = useQueryClient();
if (query.isPending) return <Screen><ActivityIndicator /></Screen>;
if (!query.data?.required) return <>{children}</>;
return <Screen><ScrollView contentContainerStyle={{ padding: 16 }}>
<Onboarding options={query.data} onDone={() => void client.invalidateQueries({ queryKey: ['onboarding'] })} />
</ScrollView></Screen>;
}
export function RecommendationSettings() {
const { t } = useTranslation();
const query = useOnboarding();
const client = useQueryClient();
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
return <View>
<FriendRecommendations />
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
<ErrorBox>{query.error ? errorText(query.error) : null}</ErrorBox>
</View>;
}
function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [choice, setChoice] = useState<Choice>(options.choice ?? { artist_ids: [], genres: [], gender: null, age_group: null });
const save = useMutation({ mutationFn: async (skip: boolean) => unwrap(await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : choice,
})), onSuccess: onDone });
const toggle = (key: 'artist_ids' | 'genres', value: string) => setChoice((old) => ({ ...old,
[key]: old[key].includes(value) ? old[key].filter((x) => x !== value) : [...old[key], value],
}));
return <View style={{ gap: 20 }}>
<H1>{t('onboarding.title')}</H1><Muted>{t('onboarding.subtitle')}</Muted>
<Choices title={t('onboarding.gender')} items={[{ value: '', label: t('onboarding.unspecified') }, ...GENDERS.map((g) => ({ value: g, label: t(`onboarding.genders.${g}`) }))]}
selected={[choice.gender ?? '']} onPick={(v) => setChoice((old) => ({ ...old, gender: (v || null) as Choice['gender'] }))} />
<Choices title={t('onboarding.age_group')} items={[{ value: '', label: t('onboarding.unspecified') }, ...AGE_GROUPS.map((g) => ({ value: g, label: t(`onboarding.ages.${g}`) }))]}
selected={[choice.age_group ?? '']} onPick={(v) => setChoice((old) => ({ ...old, age_group: (v || null) as Choice['age_group'] }))} />
<Muted>{t('onboarding.age_hint')}</Muted>
<Choices title={t('onboarding.artists')} items={options.artists.map((a) => ({ value: a.id, label: a.name }))} selected={choice.artist_ids} onPick={(v) => toggle('artist_ids', v)} />
<Choices title={t('onboarding.genres')} items={options.genres.map((g) => ({ value: g.name, label: g.name }))} selected={choice.genres} onPick={(v) => toggle('genres', v)} />
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
<Button title={t(editing ? 'settings.save' : 'onboarding.done')} busy={save.isPending} onPress={() => save.mutate(false)} />
<Button title={t(editing ? 'form.cancel' : 'onboarding.skip')} variant="ghost" disabled={save.isPending} onPress={() => editing ? onDone() : save.mutate(true)} />
</View>;
}
function Choices({ title, items, selected, onPick }: { title: string; items: { value: string; label: string }[]; selected: string[]; onPick: (value: string) => void }) {
const colors = useColors();
return <View style={{ gap: 10 }}><Text style={{ color: colors.text, fontWeight: '600' }}>{title}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>{items.map((item) => {
const on = selected.includes(item.value);
return <Pressable key={item.value} accessibilityRole="button" accessibilityState={{ selected: on }} onPress={() => onPick(item.value)}
style={{ padding: 12, borderRadius: 20, backgroundColor: on ? colors['accent-fill'] : colors.surface2 }}>
<Text style={{ color: on ? '#fff' : colors.text }}>{item.label}</Text>
</Pressable>;
})}</View>
</View>;
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useSyncExternalStore } from 'react';
import { releaseNotesText } from '@pipu/core';
import { useTranslation } from 'react-i18next';
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx } from '../lib/session';
import { updater } from '../lib/update';
import { radius, useColors } from '../theme';
import { Button } from './kit';
export function useUpdateState() {
return useSyncExternalStore(updater.subscribe, () => updater.state);
}
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
export function UpdateDialog() {
const colors = useColors();
const { t } = useTranslation();
const session = useSession();
const { server } = useSessionCtx();
const st = useUpdateState();
useEffect(() => {
void updater.check(session);
const sub = AppState.addEventListener('change', (s) => {
if (s === 'active') void updater.check(session);
});
return () => sub.remove();
}, [session]);
if (st.status === 'idle' || st.status === 'checking' || st.status === 'latest') return null;
if (st.status === 'available' && st.dismissed) return null;
const { release, required } = st;
const busy = st.status === 'downloading' || st.status === 'verifying';
const notes = releaseNotesText(release.notes_md);
const action = () => void updater.downloadAndInstall(session, server ?? '');
return (
<Modal transparent animationType="fade" visible onRequestClose={() => updater.dismiss()}>
<View
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
padding: 24,
}}
>
<View
style={{
backgroundColor: colors.surface,
borderRadius: radius.lg,
padding: 20,
gap: 12,
maxHeight: '80%',
}}
>
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }}>
{t('mobile.update_title', { version: release.version })}
</Text>
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
{notes !== '' && (
<ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
</ScrollView>
)}
<Text style={{ color: colors.text3, fontSize: 12 }}>
{t('mobile.update_size', {
size: (release.size / 1048576).toFixed(1),
})}
</Text>
{st.status === 'downloading' && (
<View style={{ gap: 6 }}>
<View
style={{
height: 4,
borderRadius: 2,
backgroundColor: colors.surface3,
}}
>
<View
style={{
height: 4,
borderRadius: 2,
width: `${Math.round(st.progress * 100)}%`,
backgroundColor: colors.accent2,
}}
/>
</View>
<Text style={{ color: colors.text2, fontSize: 12 }}>
{t('mobile.update_downloading', {
percent: Math.round(st.progress * 100),
})}
</Text>
</View>
)}
{st.status === 'verifying' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_verifying')}</Text>}
{st.status === 'error' && (
<Text style={{ color: colors.danger }}>
{t(st.error === 'bad_checksum' ? 'mobile.update_bad_checksum' : 'mobile.update_failed')}
</Text>
)}
{st.status === 'ready' && (
<View style={{ gap: 4 }}>
<Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_allow')}</Text>
<Pressable onPress={() => void updater.openInstallSettings()} accessibilityRole="link">
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.update_open_settings')}</Text>
</Pressable>
</View>
)}
<Button title={t('mobile.update_install')} icon="system-update" busy={busy} onPress={action} />
{!required && !busy && <Button title={t('mobile.update_later')} variant="ghost" onPress={() => updater.dismiss()} />}
</View>
</View>
</Modal>
);
}
+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>
);
}
+336
View File
@@ -0,0 +1,336 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useRouter } from 'expo-router';
import { ApiError, type Track } from '@pipu/core';
import type { ComponentProps, ReactNode } from 'react';
import { useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Image,
Modal,
Pressable,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session';
import { downloads, sizedCover } from '../offline/downloads';
import { absolute } from '../player/controller';
import { radius, useColors, type Colors } from '../theme';
export type IconName = ComponentProps<typeof MaterialIcons>['name'];
export function Icon({ name, size = 22, color }: { name: IconName; size?: number; color?: string }) {
const colors = useColors();
return <MaterialIcons name={name} size={size} color={color ?? colors.text2} />;
}
export function Screen({ children, scroll = false, style }: { children: ReactNode; scroll?: boolean; style?: StyleProp<ViewStyle> }) {
const s = useStyles();
void scroll;
return (
<SafeAreaView style={[s.screen, style]} edges={['top', 'left', 'right']}>
{children}
</SafeAreaView>
);
}
export function H1({ children }: { children: ReactNode }) {
const s = useStyles();
return <Text style={s.h1}>{children}</Text>;
}
export function Muted({ children, style }: { children: ReactNode; style?: StyleProp<ViewStyle> }) {
const s = useStyles();
return <Text style={[s.muted, style as object]}>{children}</Text>;
}
export function Field({
label,
error,
hint,
secure,
...rest
}: TextInputProps & { label: string; error?: string; hint?: string; secure?: boolean }) {
const s = useStyles();
const colors = useColors();
const { t } = useTranslation();
const [shown, setShown] = useState(false);
return (
<View style={{ gap: 6 }}>
<Text style={s.label}>{label}</Text>
<View>
<TextInput
placeholderTextColor={colors.text3}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={secure && !shown}
accessibilityLabel={label}
style={[s.input, error ? { borderColor: colors.danger } : null, secure ? { paddingRight: 48 } : null]}
{...rest}
/>
{secure && (
<Pressable
onPress={() => setShown((v) => !v)}
style={s.reveal}
accessibilityRole="button"
accessibilityLabel={shown ? t('form.hide_password') : t('form.show_password')}
>
<Icon name={shown ? 'visibility-off' : 'visibility'} size={20} color={colors.text3} />
</Pressable>
)}
</View>
{(error ?? hint) && <Text style={{ color: error ? colors.danger : colors.text3, fontSize: 12 }}>{error ?? hint}</Text>}
</View>
);
}
export function Button({
title,
onPress,
variant = 'primary',
busy,
disabled,
icon,
}: {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
busy?: boolean;
disabled?: boolean;
icon?: IconName;
}) {
const s = useStyles();
const colors = useColors();
const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
return (
<Pressable
onPress={onPress}
disabled={disabled || busy}
accessibilityRole="button"
accessibilityState={{ disabled: disabled || busy, busy }}
style={({ pressed }) => [s.button, { backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.75 : 1 }]}
>
{busy ? <ActivityIndicator color={fg} /> : icon ? <Icon name={icon} size={20} color={fg} /> : null}
<Text style={{ color: fg, fontWeight: '600', fontSize: 15 }}>{title}</Text>
</Pressable>
);
}
export function ErrorBox({ children }: { children?: ReactNode }) {
const s = useStyles();
const colors = useColors();
if (!children) return null;
return (
<View style={s.error} accessibilityRole="alert">
<Text style={{ color: colors.danger, fontWeight: '500' }}>{children}</Text>
</View>
);
}
/** Текст ошибки API: перевод errors:<code>. */
export function useErrorText(): (err: unknown) => string {
const { t, i18n } = useTranslation();
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
return (err) => {
const code = err instanceof ApiError ? err.code : 'common.internal';
const key = `errors:${code}`;
return i18n.exists(key) ? tr(key, err instanceof ApiError ? err.params : {}) : tr('errors:common.internal');
};
}
export function Cover({
album,
size,
round,
}: {
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
size: number;
round?: boolean;
}) {
const colors = useColors();
const { server } = useSessionCtx();
const remote = server ? absolute(server, album.cover_url) : undefined;
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
const bg = album.cover_colors?.[0] ?? colors.surface3;
return (
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
{uri ? (
<Image source={{ uri }} style={{ width: size, height: size }} />
) : (
<Icon name={round ? 'person' : 'album'} size={size * 0.45} color="rgba(255,255,255,0.8)" />
)}
</View>
);
}
export function formatDuration(ms: number): string {
const sec = Math.max(0, Math.round(ms / 1000));
return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
}
export function TrackRow({
track,
current,
onPress,
onLike,
numbered,
meta,
trailing,
}: {
track: Track;
current?: boolean;
onPress: () => void;
onLike?: () => void;
numbered?: number;
meta?: string;
trailing?: ReactNode;
}) {
const s = useStyles();
const colors = useColors();
const { t } = useTranslation();
const liked = track.reaction === 'like';
const router = useRouter();
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
return (
<Pressable
onPress={onPress}
disabled={!track.available}
accessibilityRole="button"
accessibilityLabel={`${track.title}, ${track.artists.map((a) => a.name).join(', ')}`}
style={({ pressed }) => [s.row, current && { backgroundColor: colors['accent-soft'] }, pressed && { backgroundColor: colors.hover }, !track.available && { opacity: 0.4 }]}
>
{numbered !== undefined ? (
<Text style={s.num}>{numbered}</Text>
) : (
<Cover album={track.album} size={44} />
)}
<View style={{ flex: 1, minWidth: 0 }}>
<Text numberOfLines={1} style={[s.rowTitle, current && { color: colors.accent2 }]}>
{track.title}
</Text>
<Text numberOfLines={1} style={s.rowSub}>
{track.artists.map((a) => a.name).join(', ')}
{meta ? ` · ${meta}` : ''}
</Text>
</View>
{onLike && (
<Pressable
onPress={onLike}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t(liked ? 'player.unlike' : 'player.like')}
>
<Icon name={liked ? 'favorite' : 'favorite-border'} size={20} color={liked ? colors.accent2 : colors.text3} />
</Pressable>
)}
{trailing}
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
<Icon name="playlist-add" size={22} />
</Pressable>
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
</Pressable>
);
}
/** Нижний лист: затемнение, заголовок, содержимое; закрывается кнопкой «назад» и тапом по фону. */
export function Sheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: ReactNode }) {
const colors = useColors();
const { t } = useTranslation();
return (
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
<Pressable style={{ flex: 1, backgroundColor: colors.overlay }} onPress={onClose} accessibilityLabel={title} />
<SafeAreaView edges={['bottom']} style={{ backgroundColor: colors.surface, borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg }}>
<View style={{ padding: 20, gap: 16 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }} accessibilityRole="header">
{title}
</Text>
<Pressable onPress={onClose} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('form.close')}>
<Icon name="close" color={colors.text2} />
</Pressable>
</View>
{children}
</View>
</SafeAreaView>
</Modal>
);
}
export function Segmented<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
}) {
const s = useStyles();
const colors = useColors();
return (
<View style={s.segmented} accessibilityRole="tablist">
{options.map((o) => (
<Pressable
key={o.value}
onPress={() => onChange(o.value)}
accessibilityRole="tab"
accessibilityState={{ selected: value === o.value }}
style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
>
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
</Pressable>
))}
</View>
);
}
const styles = new Map<Colors, ReturnType<typeof makeStyles>>();
/** Стили кита под активную тему (создаются один раз на тему). */
function useStyles() {
const colors = useColors();
let st = styles.get(colors);
if (!st) styles.set(colors, (st = makeStyles(colors)));
return st;
}
const makeStyles = (colors: Colors) =>
StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.bg },
h1: { color: colors.text, fontSize: 26, fontWeight: '700', letterSpacing: -0.3 },
muted: { color: colors.text2, lineHeight: 21 },
label: { color: colors.text2, fontSize: 13, fontWeight: '600' },
input: {
height: 48,
paddingHorizontal: 14,
borderRadius: radius.md,
backgroundColor: colors.surface2,
borderWidth: 1,
borderColor: colors.line2,
color: colors.text,
fontSize: 15,
},
reveal: { position: 'absolute', right: 6, top: 6, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
button: { height: 48, borderRadius: radius.md, flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },
error: { padding: 12, borderRadius: 10, backgroundColor: colors['danger-soft'] },
row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8, minHeight: 60 },
num: { width: 44, textAlign: 'center', color: colors.text3, fontVariant: ['tabular-nums'] },
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
rowSub: { color: colors.text2, fontSize: 13, marginTop: 2 },
duration: { color: colors.text3, fontSize: 12, fontVariant: ['tabular-nums'] },
segmented: { flexDirection: 'row', gap: 2, padding: 3, borderRadius: 10, backgroundColor: colors.surface2, alignSelf: 'flex-start' },
segment: { paddingHorizontal: 14, height: 32, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
});
+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, wavePalette, waveScale, type WaveState } from '@pipu/core';
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
import { useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { AppState, Pressable, View } from 'react-native';
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
import { useThemeName } from '../../theme';
import { drawLiquid, drawOrb } from './draw';
export interface WaveOrbProps {
variant: 'orb' | 'liquid';
playing: boolean;
bpm?: number;
energy?: number;
colors?: readonly string[];
size: number;
label?: string;
onPress?: () => void;
children?: ReactNode;
}
/**
* «Моя волна» на Skia (docs/design.md#3): в покое «дышит», при игре пульсирует в такт BPM с силой от energy,
* при нажатии сжимается с отскоком. Кадры считаются в UI-потоке; вне экрана и в фоне анимация стоит.
* Без `onPress` — декоративная визуализация (полноэкранный плеер в виде «волна»).
*/
export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, size, label, onPress, children }: WaveOrbProps) {
const dark = useThemeName() === 'dark';
const reduced = useReducedMotion();
const [focused, setFocused] = useState(true);
useFocusEffect(
useCallback(() => {
setFocused(true);
return () => setFocused(false);
}, []),
);
const [foreground, setForeground] = useState(AppState.currentState === 'active');
const palette = useMemo(() => wavePalette(colors), [colors]);
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
const state = useSharedValue<WaveState>(initialWaveState(size));
const pressT = useSharedValue(0);
// Новый объект на каждом кадре — пересчёт картинки (одно и то же число могло бы не вызвать его).
const time = useSharedValue({ ts: 0, dt: 0 });
useEffect(() => {
props.value = { variant, playing, bpm, energy, colors: palette, dark, reduced };
}, [variant, playing, bpm, energy, palette[0], palette[1], palette[2], dark, reduced]);
useEffect(() => {
const sub = AppState.addEventListener('change', (st) => setForeground(st === 'active'));
return () => sub.remove();
}, []);
const frame = useFrameCallback((info) => {
time.value = { ts: info.timestamp, dt: Math.min(0.05, (info.timeSincePreviousFrame ?? 16) / 1000) };
}, false);
useEffect(() => {
frame.setActive(focused && foreground);
}, [focused, foreground]);
const S = size * 1.6;
const picture = useDerivedValue(() => {
const dt = time.value.dt;
const s = state.value;
Object.assign(s, props.value);
s.size = size;
if (pressT.value === 0 && s.pressT === 1) s.pressV -= 0.12; // отпустили — отскок
s.pressT = pressT.value;
step(s, dt);
const r = size * 0.5 * waveScale(s);
return createPicture(
(canvas) => {
if (s.variant === 'liquid') drawLiquid(canvas, S / 2, S / 2, r, s);
else drawOrb(canvas, S / 2, S / 2, r, s);
},
{ width: S, height: S },
);
});
const labelStyle = useAnimatedStyle(() => {
void time.value.ts; // пересчёт на каждом кадре
const s = state.value;
return { transform: [{ scale: waveScale(s) + s.pulse * 0.03 }] };
});
const body = (
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
<Canvas style={{ position: 'absolute', width: S, height: S, left: (size - S) / 2, top: (size - S) / 2 }} pointerEvents="none">
<Picture picture={picture} />
</Canvas>
<Animated.View style={[{ alignItems: 'center', justifyContent: 'center', gap: 4 }, labelStyle]} pointerEvents="none">
{children}
</Animated.View>
</View>
);
if (!onPress) return <View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">{body}</View>;
return (
<Pressable
onPress={onPress}
onPressIn={() => (pressT.value = 1)}
onPressOut={() => (pressT.value = 0)}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected: playing }}
>
{body}
</Pressable>
);
}
+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);
}
}
+87
View File
@@ -0,0 +1,87 @@
import type { AuthSession, Track } from '@pipu/core';
import { beforeEach, expect, it, vi } from 'vitest';
vi.mock('@react-native-community/netinfo', () => ({ default: {
fetch: async () => ({ type: 'wifi' }), addEventListener: () => () => {},
} }));
vi.mock('../src/lib/prefs', () => ({
qualityPrefs: { load: async () => ({ wifi: 'high', cellular: 'medium' }) },
playerCache: { clearIfRequested: async () => {} },
}));
vi.mock('../src/offline/events', () => ({ PersistentEvents: class {} }));
vi.mock('../src/offline/downloads', () => ({
downloads: { isDownloaded: () => false, localTrack: () => undefined, localCover: () => undefined, downloaded: () => [] },
sizedCover: (s: string) => s,
}));
vi.mock('react-native-track-player', () => ({
default: {
isServiceRunning: async () => true, setupPlayer: async () => {}, updateOptions: async () => {},
addEventListener: vi.fn(), reset: vi.fn().mockResolvedValue(undefined),
add: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined),
skip: vi.fn().mockResolvedValue(undefined),
},
AppKilledPlaybackBehavior: {}, Capability: {}, Event: {}, State: {},
}));
function track(id: string): Track {
return { id, title: id, duration_ms: 1000, artists: [],
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
genres: [], disc_no: 1, track_no: 1, available: true, stream_url: `/s/${id}?sig=x`, reaction: null, features: null };
}
function deferred<T>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((yes) => { resolve = yes; });
return { promise, resolve };
}
const reply = (id: string) => ({ data: { session_id: id, activity: null, mood: null, fallback: false, tracks: [track(id)] }, response: new Response() });
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); });
it('ignores a wave creation response after the player is reset', async () => {
const { player } = await import('../src/player/controller');
const native = (await import('react-native-track-player')).default;
const pending = deferred<ReturnType<typeof reply>>();
const post = vi.fn().mockReturnValue(pending.promise);
player.attach({ client: { POST: post } } as unknown as AuthSession, 'http://localhost', null);
const start = player.startWave(null, null);
await vi.waitFor(() => expect(post).toHaveBeenCalled());
await player.reset();
pending.resolve(reply('old'));
await start;
expect(player.getState().wave).toBeNull();
expect(player.getState().queue).toEqual([]);
expect(native.add).not.toHaveBeenCalled();
});
it('does not let a late context response replace a manually selected playlist', async () => {
const { player } = await import('../src/player/controller');
const pending = deferred<ReturnType<typeof reply>>();
const session = { client: {
POST: vi.fn().mockResolvedValue(reply('wave')),
PATCH: vi.fn().mockReturnValue(pending.promise), DELETE: vi.fn().mockResolvedValue({}),
} };
player.attach(session as unknown as AuthSession, 'http://localhost', null);
await player.startWave(null, null);
const change = player.setWaveContext('focus', null);
await player.playList([track('manual')], 0, { type: 'album', id: 'album' });
pending.resolve(reply('stale'));
await change;
expect(player.getState().wave).toBeNull();
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
expect(player.getState().context?.type).toBe('album');
});
it('does not restore a stale saved queue after manual playback starts', async () => {
const { player } = await import('../src/player/controller');
const saved = deferred<unknown>();
const get = vi.fn().mockReturnValue(saved.promise);
const post = vi.fn();
player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null);
const restoring = player.restore();
await vi.waitFor(() => expect(get).toHaveBeenCalled());
await player.playList([track('chosen')], 0, { type: 'queue' });
saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() });
await restoring;
expect(player.getState().track?.id).toBe('chosen');
expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything());
});
+19
View File
@@ -0,0 +1,19 @@
import { afterEach, expect, it, vi } from 'vitest';
import { EventBuffer } from '@pipu/core';
import { eventId } from '../src/player/eventId';
afterEach(() => vi.unstubAllGlobals());
it('queues restored-track events on Hermes without Web Crypto', () => {
vi.stubGlobal('crypto', undefined);
const nativeUuid = vi.fn()
.mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff')
.mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe');
vi.stubGlobal('expo', { uuidv4: nativeUuid });
const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId);
buffer.push({ type: 'play_start', track_id: 'restored-track' });
buffer.push({ type: 'skip', track_id: 'restored-track' });
expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff');
expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id);
expect(nativeUuid).toHaveBeenCalledTimes(2);
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { releaseNeed } from '../src/lib/releaseNeed';
import { offlineWave } from '../src/player/offlineWave';
import type { Track } from '@pipu/core';
function track(id: string, reaction: Track['reaction']): Track {
return {
id,
title: id,
duration_ms: 1000,
artists: [],
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
genres: [],
disc_no: 1,
track_no: 1,
available: true,
stream_url: null,
reaction,
features: null,
};
}
describe('offline wave', () => {
it('drops dislikes and keeps likes ahead of the rest', () => {
const tracks = [track('d', 'dislike'), track('n', null), track('l', 'like')];
const order = offlineWave(tracks, () => 0.5).map((t) => t.id);
expect(order).toEqual(['l', 'n']);
});
});
describe('update dialog decision', () => {
it('marks an older install below the minimum as required', () => {
expect(releaseNeed(10, 12, 11)).toBe('required');
expect(releaseNeed(11, 12, 11)).toBe('optional');
expect(releaseNeed(12, 12, 11)).toBe('current');
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
},
});
+8
View File
@@ -0,0 +1,8 @@
# Web и desktop UI
- Маршруты: `src/app/router.tsx`; сессия: `src/app/session.tsx`; страницы собирают возможности, `src/features` владеет сценариями.
- Воспроизведение: `src/player/player.ts` — фасад; адаптеры рядом. Общие правила очереди, событий и волны — `packages/core/src`.
- Загрузки: `src/features/uploads`; страница очереди — `src/pages/admin/UploadQueuePage.tsx`. Сохраняй query keys/invalidation, права и поведение диалогов.
- `pnpm --filter @pipu/web typecheck`; `pnpm --filter @pipu/web test`. Для локальной проверки можно передать путь теста после `test`.
- UI/навигация/диалоги: `pnpm --filter @pipu/web test:e2e`. Проверки используют fake API; реальная серверная авторизация покрывается Go отдельно.
- Restore не запускает музыку и не создаёт событие прослушивания. Ошибка preload не пропускает трек. Dispose должен освобождать обработчики.
+12 -1
View File
@@ -5,9 +5,20 @@
FROM node:22-alpine AS build
RUN npm install -g pnpm@11.9.0
WORKDIR /repo
COPY . .
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает)
COPY patches/ patches/
COPY apps/web/package.json apps/web/
COPY apps/mobile/package.json apps/mobile/
COPY packages/api-client/package.json packages/api-client/
COPY packages/core/package.json packages/core/
COPY packages/design-tokens/package.json packages/design-tokens/
COPY packages/i18n/package.json packages/i18n/
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter @pipu/web...
COPY . .
# Сгенерированные клиенты и токены лежат в репозитории (их актуальность проверяет CI), поэтому generate здесь не нужен.
RUN pnpm --filter @pipu/web build
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
test('desktop files survive restart, isolate owners and clear only recent cache', async ({ page }) => {
await page.goto('/login');
const saved = await page.evaluate(async () => {
const source = '/src/features/desktop/offline.ts';
const { DesktopOffline } = await import(source);
const track = { id: 'cached-track', title: 'Offline song', available: true, stream_url: 'data:audio/wav;base64,UklGRg==', duration_ms: 1000, artists: [], album: { id: 'album', title: 'Album', cover_url: null }, genres: [], reaction: null };
const session = { client: { POST: async () => ({ data: { items: [track] }, response: new Response('{}') }) } };
const offline = new DesktopOffline('test-server/alice', session);
await offline.ready;
await offline.save(track, true);
await offline.save({ ...track, id: 'recent-track' });
const tracks = offline.tracks();
const result = { count: tracks.length, blob: tracks[0].stream_url.startsWith('blob:'), pinned: offline.pinned(track.id) };
offline.dispose();
return result;
});
expect(saved).toEqual({ count: 2, blob: true, pinned: true });
await page.reload();
const restored = await page.evaluate(async () => {
const source = '/src/features/desktop/offline.ts';
const { DesktopOffline } = await import(source);
const session = { client: { POST: async () => { throw new TypeError('offline'); } } };
const alice = new DesktopOffline('test-server/alice', session);
const bob = new DesktopOffline('test-server/bob', session);
await Promise.all([alice.ready, bob.ready]);
const before = alice.tracks().length;
const body = await (await fetch(alice.tracks()[0].stream_url)).blob();
await alice.clearRecent();
const result = { before, bytes: body.size, after: alice.tracks().map((t: { id: string }) => t.id), other: bob.tracks().length };
alice.dispose(); bob.dispose();
return result;
});
expect(restored).toEqual({ before: 2, bytes: 4, after: ['cached-track'], other: 0 });
});
test('desktop update dialog hides build reports and launches installer', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('pipu.locale', 'ru');
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
core: { invoke: async (command: string) => {
if (command === 'bootstrap') return { proxyBase: location.origin, server: 'https://music.example' };
if (command === 'update_check') return { build: { version: '1.8.0', size: 1048576 }, notes: '<!-- android-status -->\n> APK SHA-256 abc\n<!-- /android-status -->\n## Изменения\n- **Офлайн-музыка**' };
if (command === 'update_install') document.documentElement.dataset.installer = 'opened';
return null;
} },
event: { listen: async () => () => undefined },
};
});
await page.goto('/login');
await page.getByLabel('Логин').fill('alice');
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
await page.getByRole('button', { name: 'Войти' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('Доступна версия 1.8.0');
await expect(dialog).toContainText('• Офлайн-музыка');
await expect(dialog).not.toContainText('android-status');
await expect(dialog).not.toContainText('SHA-256');
await dialog.getByRole('button', { name: 'Скачать и установить' }).click();
await expect(dialog).toContainText('Установщик открыт');
await expect(page.locator('html')).toHaveAttribute('data-installer', 'opened');
});
Loaded 100 of 520 files, more files were not shown because too many files have changed in this diff. Show more