Compare commits

..
83 Commits
Author SHA1 Message Date
mrleo1nidandClaude Sonnet 5.5 75b0f7bec3 Hide full-screen player controls after mouse click
ci / ts / ts (packages + web) (push) Successful in 5m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:34:26 +03:00
mrleo1nidandClaude Sonnet 5.5 3edb1af037 Fix indentation in full-screen player
ci / ts / ts (packages + web) (push) Successful in 4m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:51:31 +03:00
mrleo1nidandClaude Sonnet 5.5 0458cbc46c Reveal full-screen player controls on hover and balance their layout
ci / ts / ts (packages + web) (push) Canceled after 14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:51:12 +03:00
mrleo1nidandClaude Sonnet 5.5 f7ffa5d742 Fix track page hero layout and add full-screen player
ci / ts / ts (packages + web) (push) Successful in 5m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:17:55 +03:00
mrleo1nidandClaude Sonnet 5.5 5d0decbb2a Use the primary artist for upload destination folders
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 33s
ci / db / db (push) Successful in 1m47s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 23:14:48 +03:00
mrleo1nidandClaude Sonnet 5.5 3edbf489a3 Place cover and artist images in library folders on upload import
ci / api / api (push) Successful in 36s
ci / module boundaries / boundaries (push) Successful in 8s
ci / db / db (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 01:04:24 +03:00
mrleo1nid 82f3eed9a0 Improve search relevance and artist transliteration
ci / api / api (push) Successful in 56s
ci / db / db (push) Successful in 1m13s
2026-10-30 12:09:24 +03:00
mrleo1nid db4548b115 Omit missing years from upload destination paths
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m10s
ci / db / db (push) Successful in 2m33s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m45s
ci / ts / ts (packages + web) (push) Successful in 6m29s
2026-10-30 11:40:42 +03:00
mrleo1nid 3309a79a98 Wait for all search indexes in API user journey
release / release (версия и заметки) (push) Successful in 6s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m16s
release / prune (старые файлы и прогоны) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 4m55s
ci / api / api (push) Successful in 31s
ci / db / db (push) Successful in 53s
2026-10-30 11:24:44 +03:00
mrleo1nid bbd629dcf8 Isolate CI database journey and stabilize E2E dependency cache
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Failing after 29s
ci / api / api (push) Successful in 52s
ci / ts / ts (packages + web) (push) Successful in 4m22s
2026-10-30 11:05:13 +03:00
mrleo1nid 82bf015283 Import missing artist portraits from uploaded album folders
ci / module boundaries / boundaries (push) Successful in 6s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m1s
ci / api / api (push) Successful in 41s
ci / db / db (push) Failing after 1m33s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m25s
ci / ts / ts (packages + web) (push) Failing after 5m13s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m19s
2026-10-30 10:46:07 +03:00
mrleo1nid fd17c06397 Fix release matching for featured artists and credit annotations
ci / module boundaries / boundaries (push) Successful in 1m11s
ci / api / api (push) Successful in 1m54s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 6m50s
ci / ts / ts (packages + web) (push) Failing after 6m19s
ci / db / db (push) Failing after 11s
2026-10-30 10:23:12 +03:00
mrleo1nid f4681da876 Stabilize E2E server and retain failure diagnostics
ci / ts / ts (packages + web) (push) Successful in 6m9s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m48s
release / prune (старые файлы и прогоны) (push) Successful in 6s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m35s
2026-10-29 23:17:48 +03:00
mrleo1nid 5248074964 fix(web): stabilize navigation labels and upload browser tests
ci / ts / ts (packages + web) (push) Failing after 10m23s
2026-10-29 22:54:22 +03:00
mrleo1nid 6a1382dad5 fix(player): toggle current track without restarting playback
ci / ts / ts (packages + web) (push) Failing after 7m39s
release / android (APK в релиз) (push) Successful in 6m47s
release / desktop (Linux и Windows в релиз) (push) Successful in 15m18s
release / release (версия и заметки) (push) Successful in 9s
release / prune (старые файлы и прогоны) (push) Successful in 10s
2026-10-29 22:44:54 +03:00
mrleo1nid 5876ae29cc feat(uploads): show artist and album in batch headers
ci / module boundaries / boundaries (push) Successful in 19s
ci / ts / ts (packages + web) (push) Failing after 6m0s
2026-10-29 22:28:42 +03:00
mrleo1nid 39bf7d4b70 feat(uploads): match release groups with MusicBrainz and AcoustID
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / ts / ts (packages + web) (push) Failing after 6m3s
ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / api / api (push) Successful in 58s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
2026-10-29 20:44:59 +03:00
mrleo1nid 1e3a149aef feat(uploads): show batch format counts and quality ranges
ci / ts / ts (packages + web) (push) Failing after 5m56s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m39s
ci / module boundaries / boundaries (push) Successful in 5s
2026-10-29 17:41:28 +03:00
mrleo1nid e78cab6ce6 fix(uploads): serialize status filters and resize oversized covers
ci / module boundaries / boundaries (push) Successful in 9s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m37s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / ts / ts (packages + web) (push) Failing after 27m2s
ci / api / api (push) Successful in 58s
ci / db / db (push) Successful in 6m19s
2026-10-29 12:14:49 +03:00
mrleo1nid ca2a50b515 fix(uploads): load audio previews only on play
ci / module boundaries / boundaries (push) Successful in 7s
ci / ts / ts (packages + web) (push) Failing after 6m33s
2026-10-29 11:45:09 +03:00
mrleo1nid af06cf1e75 fix(uploads): pause previous audio preview when another starts
ci / ts / ts (packages + web) (push) Canceled after 3m50s
ci / module boundaries / boundaries (push) Successful in 8s
2026-10-29 11:41:11 +03:00
mrleo1nid 7d62531c02 fix(uploads): serve private previews through API and show batch cover status
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Canceled after 4m13s
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m7s
2026-10-29 11:34:55 +03:00
mrleo1nid 37e33c656b fix(uploads): allow signed audio previews and compact destination details
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 47s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m42s
ci / ts / ts (packages + web) (push) Failing after 5m24s
ci / api / api (push) Successful in 42s
2026-10-29 11:21:48 +03:00
mrleo1nid 30c3c85f89 fix(uploads): refresh destination previews and improve queue layout
ci / api / api (push) Successful in 50s
ci / module boundaries / boundaries (push) Successful in 8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Failing after 4m28s
ci / db / db (push) Successful in 56s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
2026-10-29 11:10:54 +03:00
mrleo1nid eb75437708 fix(uploads): separate withdrawn proposals from default filters
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m44s
ci / ts / ts (packages + web) (push) Failing after 1m56s
2026-10-29 10:44:10 +03:00
mrleo1nid 64dd8ed05e feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / module boundaries / boundaries (push) Successful in 7s
ci / ts / ts (packages + web) (push) Failing after 4m4s
2026-10-29 10:28:29 +03:00
mrleo1nid 5e2d1e902b fix(uploads): retry failed files and report full storage
ci / db / db (push) Successful in 1m51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m40s
ci / module boundaries / boundaries (push) Successful in 7s
ci / ts / ts (packages + web) (push) Failing after 5m16s
ci / api / api (push) Successful in 33s
2026-10-29 00:39:03 +03:00
mrleo1nid b39e4dc28f fix(uploads): hash files without WebAssembly under strict CSP
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 3m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m12s
ci / ts / ts (packages + web) (push) Failing after 4m37s
ci / desktop / rust (desktop proxy) (push) Successful in 6m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m42s
ci / module boundaries / boundaries (push) Successful in 5s
2026-10-29 00:05:49 +03:00
mrleo1nid f59d1e8d03 fix(search): skip deletion of missing temporary indexes
ci / db / db (push) Successful in 52s
ci / api / api (push) Successful in 42s
2026-10-28 23:56:46 +03:00
mrleo1nid 66d3897f66 feat(admin): group navigation into media, uploads and system
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m14s
release / prune (старые файлы и прогоны) (push) Successful in 12s
ci / ts / ts (packages + web) (push) Failing after 4m33s
release / android (APK в релиз) (push) Successful in 4m45s
release / release (версия и заметки) (push) Successful in 8s
2026-10-28 23:47:17 +03:00
mrleo1nid 17bb0e1846 fix(uploads): allow WebAssembly hashing under CSP
ci / desktop / rust (desktop proxy) (push) Successful in 5m54s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 3m23s
ci / ts / ts (packages + web) (push) Failing after 4m29s
2026-10-28 23:34:42 +03:00
mrleo1nidandClaude Sonnet 5.5 17c85493b7 fix(library): always split comma-separated artists even if glued name exists
ci / api / api (push) Successful in 34s
ci / db / db (push) Successful in 42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:26:49 +03:00
mrleo1nidandClaude Sonnet 5.5 75f9c57eca fix(library): split compound album artist and re-split artists on rescan
ci / api / api (push) Successful in 37s
ci / db / db (push) Successful in 55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:29:50 +03:00
mrleo1nidandClaude Sonnet 5.5 8a007be036 feat(web): show spinner on play button while track is loading
ci / ts / ts (packages + web) (push) Successful in 3m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:23:19 +03:00
mrleo1nidandClaude Sonnet 5.5 f77148df2c fix(web): show notification count as menu badge and mark read on open
ci / ts / ts (packages + web) (push) Successful in 2m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:56:05 +03:00
mrleo1nidandClaude Sonnet 5.5 139dd226fa feat(genius): fetch artist avatars by name and match names with transliteration suffix
ci / api / api (push) Successful in 27s
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:50:55 +03:00
mrleo1nidandClaude Sonnet 5.5 795bb7de46 fix(library): split comma-separated main artists
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 3m43s
ci / db / db (push) Canceled after 11m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:39:32 +03:00
mrleo1nid 11b890c2e7 docs: record architecture and correctness review 2026-10-24 02:16:05 +03:00
mrleo1nid 59983ed1b3 fix(social): complete shared listening request lifecycle 2026-10-24 02:16:00 +03:00
mrleo1nid cb22f15912 fix(web): polish social UI and prevent doubled shared audio
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m20s
ci / ts / ts (packages + web) (push) Successful in 3m9s
release / release (версия и заметки) (push) Successful in 10s
release / prune (старые файлы и прогоны) (push) Successful in 11s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m22s
release / android (APK в релиз) (push) Successful in 4m7s
2026-10-23 19:54:10 +03:00
mrleo1nid 6450027b88 feat(web): add desktop mini chat and move social controls to profile
ci / ts / ts (packages + web) (push) Successful in 4m55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m37s
2026-10-23 18:37:32 +03:00
mrleo1nid f087ec9f88 fix(ci): serialize integration suites sharing a database
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m59s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m17s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / db / db (push) Successful in 1m6s
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 / ts / ts (packages + web) (push) Successful in 3m15s
ci / db / db (push) Failing after 23s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m29s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m27s
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 / db / db (push) Failing after 18s
ci / api / api (push) Successful in 19m12s
ci / module boundaries / boundaries (push) Successful in 18m23s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
2026-10-23 11:27:47 +03:00
mrleo1nid 11a16cd023 feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / ts / ts (packages + web) (push) Canceled after 1s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / recommender / recommender (push) Canceled after 6m47s
2026-10-23 10:45:40 +03:00
mrleo1nid 0a05bfe2cd feat(social): add friends, private chats, inbox and music sharing
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
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
2026-10-23 03:19:29 +03:00
mrleo1nid f1528de948 feat(admin): transfer user accounts and show local cover coverage
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
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 / api / api (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m0s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m39s
ci / ts / ts (packages + web) (push) Successful in 2m40s
ci / db / db (push) Successful in 58s
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 / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
release / release (версия и заметки) (push) Successful in 5s
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
ci / module boundaries / boundaries (push) Successful in 7s
ci / ts / ts (packages + web) (push) Successful in 3m8s
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
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 / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / recommender / recommender (push) Failing after 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / ts / ts (packages + web) (push) Successful in 7m20s
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 / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
ci / api / api (push) Successful in 55s
ci / db / db (push) Successful in 52s
release / android (APK в релиз) (push) Successful in 3m50s
release / release (версия и заметки) (push) Successful in 5s
release / prune (старые файлы и прогоны) (push) Successful in 7s
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
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 59s
ci / api / api (push) Successful in 55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
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
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m54s
ci / recommender / recommender (push) Successful in 37s
release / prune (старые файлы и прогоны) (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 / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
2026-10-22 14:19:52 +03:00
mrleo1nid e158558da3 refactor: isolate playback and upload use cases
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
ci / module boundaries / boundaries (push) Successful in 6s
ci / db / db (push) Successful in 1m4s
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 / prune (старые файлы и прогоны) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:53:37 +03:00
mrleo1nidandCursor 39aaaed969 feat(web): list previous app builds on the download page
ci / 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
release / prune (старые файлы и прогоны) (push) Successful in 8s
ci / ts / ts (packages + web) (push) Successful in 3m30s
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 / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Canceled after 2m4s
ci / desktop / rust (desktop proxy) (push) Successful in 5m33s
release / release (версия и заметки) (push) Successful in 9s
ci / api / api (push) Successful in 50s
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 / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:10:03 +03:00
mrleo1nidandCursor efe78d7e07 feat(desktop): wrap the web client in a Tauri app
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
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 / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m8s
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 / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m43s
2026-10-21 22:49:10 +03:00
mrleo1nid 20abe20686 fix(mobile): prevent crash when restoring a saved playback queue
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m32s
release / android (APK в релиз) (push) Successful in 4m20s
ci / ts / ts (packages + web) (push) Successful in 2m49s
release / release (версия и заметки) (push) Successful in 9s
2026-10-21 18:41:48 +03:00
mrleo1nid c34272d508 fix(auth): allow admins to change any username including their own
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / ts / ts (packages + web) (push) Successful in 2m59s
ci / api / api (push) Successful in 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / db / db (push) Successful in 1m17s
2026-10-21 17:33:19 +03:00
428 changed files with 38094 additions and 3316 deletions

No files matched your search

+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
+7 -1
View File
@@ -48,6 +48,7 @@ jobs:
TEST_REDIS_URL: redis://redis:6379/0
TEST_MEILI_URL: http://meilisearch:7700
TEST_MEILI_KEY: ci-master-key-0123456789
MUSICBRAINZ_ENABLED: "false"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -71,7 +72,12 @@ jobs:
done
- name: Go — миграции и запросы store
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/
# 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/ ./internal/uploads/
- name: Go — релевантность поиска на Meilisearch
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/search/
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
working-directory: services/api
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
+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
+14 -1
View File
@@ -57,5 +57,18 @@ jobs:
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
- run: |
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
pnpm --filter @pipu/web exec playwright install chromium
- run: pnpm --filter @pipu/web test:e2e
- name: E2E failure diagnostics
if: failure()
continue-on-error: true
uses: actions/upload-artifact@v3
with:
name: web-e2e-failure
path: apps/web/test-results/
if-no-files-found: ignore
retention-days: 3
+2
View File
@@ -47,6 +47,8 @@ jobs:
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`.
+147 -5
View File
@@ -1,6 +1,8 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs).
# Релиз по тегу 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):
@@ -132,9 +134,10 @@ jobs:
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
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
@@ -196,3 +199,142 @@ jobs:
- 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
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
+9
View File
@@ -20,6 +20,8 @@ __pycache__/
.ruff_cache/
# env / secrets
deploy/config.local
deploy/.management.lock
.env
*.pem
*.keystore
@@ -37,3 +39,10 @@ Thumbs.db
.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`.
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
+4 -1
View File
@@ -1,10 +1,11 @@
# pipu-music
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
- Документация: [docs/README.md](docs/README.md)
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
## Структура
@@ -15,6 +16,7 @@ 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, скрипты установки и обновления |
@@ -46,6 +48,7 @@ pnpm --filter @pipu/web dev
```bash
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
pnpm check:boundaries
```
```bash
+1470 -6
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 } }
}
}
+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-тесты не заменяют эту проверку.
+6
View File
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
name: 'Pipu Music',
slug: 'pipu-music',
scheme: 'pipu',
icon: './assets/icon.png',
version,
orientation: 'portrait',
userInterfaceStyle: 'automatic',
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
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,
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

+17 -5
View File
@@ -8,32 +8,38 @@ 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', profile: 'person' };
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>
@@ -42,10 +48,11 @@ export default function TabsLayout() {
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="friends" />
<Tabs.Screen name="profile" />
</Tabs>
<UpdateDialog />
</>
</NativeChatProvider>
);
}
@@ -53,11 +60,13 @@ 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();
@@ -81,10 +90,13 @@ function TabBar({
onPress={() => onPress(name)}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
accessibilityLabel={labels[name]}
accessibilityLabel={`${labels[name]}${badges[name] ? ` · ${badges[name]}` : ''}`}
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
>
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
<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>
@@ -1,4 +1,5 @@
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';
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
const session = useSession();
const current = usePlayerState().track;
const [tab, setTab] = useState<Tab>('likes');
const router = useRouter();
const list = useInfiniteQuery({
queryKey: ['m-collection', tab],
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
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}
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
{ value: 'dislikes', label: t('collection.dislikes') },
]}
/>
</View>
);
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
+2
View File
@@ -0,0 +1,2 @@
import { SocialScreen } from '../../features/social/Social';
export default function FriendsTab() { return <SocialScreen root />; }
+5
View File
@@ -9,6 +9,7 @@ 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. */
@@ -22,6 +23,10 @@ function iconFor(name: string): IconName {
* На 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;
+3
View File
@@ -15,6 +15,7 @@ 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';
@@ -74,6 +75,8 @@ export default function ProfileScreen() {
@{user.username} · {t(`roles.${user.role}`)}
</Muted>
<RecommendationSettings />
<Section>
<Row label={t('language.label')}>
<Segmented<Locale>
+4
View File
@@ -109,6 +109,10 @@ function Screens() {
<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>
+26 -6
View File
@@ -2,8 +2,11 @@ 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 { useUser } from '../lib/session';
import { useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks';
import { useColors } from '../theme';
@@ -20,7 +23,10 @@ export default function PlayerScreen() {
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;
@@ -36,16 +42,24 @@ export default function PlayerScreen() {
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>
<DownloadButton track={track} />
<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' }}>
@@ -72,12 +86,18 @@ export default function PlayerScreen() {
<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)}
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
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) }}
@@ -99,18 +119,18 @@ export default function PlayerScreen() {
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
</Pressable>
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<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
onPress={() => player.toggle()}
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 onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<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')}>
+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>;
}
+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: () => { close(); void 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);
}
+6 -1
View File
@@ -272,7 +272,7 @@ class Downloads {
if (track.album.cover_url) {
const cover = this.cover(track.album.id);
const info = await FileSystem.getInfoAsync(cover);
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
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);
@@ -286,4 +286,9 @@ class Downloads {
}
}
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
export function sizedCover(url: string, size: number): string {
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
}
export const downloads = new Downloads();
+141 -57
View File
@@ -1,6 +1,15 @@
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,
@@ -17,20 +26,17 @@ import TrackPlayer, {
} from 'react-native-track-player';
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads } from '../offline/downloads';
import { downloads, sizedCover } from '../offline/downloads';
import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave';
import { eventId } from './eventId';
export { offlineWave };
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export type { WaveInfo } from '@pipu/core';
export interface PlayerState {
listening?: import('@pipu/core').ListeningClientState;
ready: boolean;
track: Track | null;
queue: Track[];
@@ -43,10 +49,6 @@ export interface PlayerState {
wave: WaveInfo | null;
}
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_000;
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
@@ -75,6 +77,11 @@ async function quality(): Promise<Quality> {
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
*/
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;
@@ -83,8 +90,13 @@ class Controller {
private attempt: PlayAttempt | null = null;
private lastPositionMs = 0;
private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
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>();
@@ -102,18 +114,33 @@ class Controller {
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 }) : null;
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();
@@ -223,47 +250,64 @@ class Controller {
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
return tracks.map((t) => ({
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
duration: t.duration_ms / 1000,
}));
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();
await TrackPlayer.add(await this.toRntp(list));
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);
@@ -301,21 +345,26 @@ class Controller {
// --- «Моя волна» ---
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 },
@@ -326,15 +375,19 @@ class Controller {
}
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 },
}),
);
await this.replaceUpcoming(res.tracks);
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)) {
@@ -347,8 +400,8 @@ class Controller {
private leaveWave() {
const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null });
void this.session?.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
@@ -356,46 +409,36 @@ class Controller {
}
private noteWave(trackId: string, outcome: WaveOutcome) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
this.wavePolicy.note(trackId, outcome);
}
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
private async replaceUpcoming(tracks: Track[]) {
private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
const keep = this.state.queue.slice(0, this.state.index + 1);
await TrackPlayer.removeUpcomingTracks();
const add = tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...keep, ...add] });
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) {
const wave = this.state.wave;
if (!wave || !this.session || this.waveLoading) return;
if (!this.session) return;
const remaining = this.state.queue.length - this.state.index - 1;
if (!refresh && remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return;
if (refresh) await this.replaceUpcoming(res.tracks);
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
if (refresh) await this.replaceUpcoming(res.tracks, current);
else {
const add = res.tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
this.update({ queue: [...this.state.queue, ...add] });
}
this.update({ wave: { ...wave, fallback: res.fallback } });
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback];
} finally {
this.waveLoading = false;
}
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
});
}
// --- состояние ---
@@ -420,18 +463,28 @@ class Controller {
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
async restore() {
if (!this.session || this.state.track) return;
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 this.session.client.GET('/api/v1/me/playback-state'));
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 this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
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();
await TrackPlayer.add(await this.toRntp(tracks));
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 {
// офлайн или нет сохранённого состояния
@@ -439,6 +492,8 @@ class Controller {
}
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);
@@ -448,6 +503,35 @@ class Controller {
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));
});
}
+4 -3
View File
@@ -7,14 +7,15 @@ import { player } from './controller';
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
*/
export async function PlaybackService(): Promise<void> {
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
TrackPlayer.addEventListener(Event.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();
});
+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>;
}
+5 -14
View File
@@ -1,4 +1,5 @@
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';
@@ -10,7 +11,6 @@ import { Button } from './kit';
export function useUpdateState() {
return useSyncExternalStore(updater.subscribe, () => updater.state);
}
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
export function UpdateDialog() {
const colors = useColors();
@@ -32,6 +32,7 @@ export function UpdateDialog() {
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 (
@@ -57,9 +58,9 @@ export function UpdateDialog() {
{t('mobile.update_title', { version: release.version })}
</Text>
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
{release.notes_md.trim() !== '' && (
<ScrollView style={{ maxHeight: 240 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text>
{notes !== '' && (
<ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
</ScrollView>
)}
<Text style={{ color: colors.text3, fontSize: 12 }}>
@@ -115,13 +116,3 @@ export function UpdateDialog() {
</Modal>
);
}
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
function plainNotes(md: string): string {
return md
.replace(/^#{1,6}\s*/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/^\s*[-*]\s+/gm, '• ')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.trim();
}
+8 -2
View File
@@ -1,4 +1,5 @@
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';
@@ -19,7 +20,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { downloads, sizedCover } from '../offline/downloads';
import { absolute } from '../player/controller';
import { radius, useColors, type Colors } from '../theme';
@@ -160,7 +161,7 @@ export function Cover({
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
const 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' }}>
@@ -199,6 +200,7 @@ export function TrackRow({
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
@@ -233,6 +235,10 @@ export function TrackRow({
</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>
+3 -3
View File
@@ -1,7 +1,7 @@
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core';
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, useState, type ReactNode } from 'react';
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';
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
}, []),
);
const [foreground, setForeground] = useState(AppState.currentState === 'active');
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
const palette = useMemo(() => wavePalette(colors), [colors]);
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
const state = useSharedValue<WaveState>(initialWaveState(size));
+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);
});
+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 должен освобождать обработчики.
+73
View File
@@ -0,0 +1,73 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('admin groups related pages and remembers collapsed sections', async ({ page }, info) => {
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
await nav.getByRole('button', { name: 'Админка', exact: true }).click();
await expect(nav.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(nav.getByRole('button', { name: title, exact: true })).toHaveAttribute('aria-expanded', 'false');
}
await nav.getByRole('button', { name: 'Система', exact: true }).click();
const system = nav.getByRole('group', { name: 'Система', exact: true });
await expect(system.getByRole('link')).toHaveCount(4);
for (const title of ['Обновления', 'Конфигурация', 'Настройки', 'Журнал']) {
await expect(system.getByRole('link', { name: title, exact: true })).toBeVisible();
}
await nav.screenshot({ path: info.outputPath('admin-groups.png') });
// The fake API resets authentication on a full document load; sign in again.
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'true');
await nav.getByRole('button', { name: 'Система', exact: true }).click();
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'false');
await login(page, 'mobile-admin', '/admin/audit');
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toBeVisible();
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toHaveAttribute('data-status', 'active');
await nav.getByRole('button', { name: 'Медиатека', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link')).toHaveCount(2);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link')).toHaveCount(3);
});
test('moderator sees only permitted groups and pages', async ({ page }) => {
await login(page, 'moderator', '/admin/library');
const nav = page.getByRole('navigation', { name: 'Навигация' });
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link', { name: 'Библиотека' })).toBeVisible();
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveCount(0);
await expect(nav.getByRole('link', { name: 'Хранилища' })).toHaveCount(0);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
});
test('mobile menu exposes grouped pages and closes after navigation', async ({ page }, info) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Навигация' });
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(dialog.getByRole('heading', { name: title, exact: true })).toBeVisible();
}
await dialog.screenshot({ path: info.outputPath('admin-groups-mobile.png') });
await dialog.getByRole('group', { name: 'Система', exact: true }).getByRole('link', { name: 'Обновления', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/admin\/updates$/);
});
test('tablet icon sidebar keeps all admin destinations accessible', async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 });
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
for (const title of ['Библиотека', 'Хранилища', 'Предложить треки', 'Очередь', 'Журнал']) {
await expect(nav.getByRole('link', { name: title, exact: true })).toBeVisible();
}
});
+62
View File
@@ -0,0 +1,62 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
test('an artist directory uploads each album as a separate batch with a shared portrait', async ({ page }, info) => {
const root = info.outputPath('творожный бизнес');
const albums = ['(2026) творог грехов - творожный бизнес', '(2025) молчанка - творожный бизнес'];
for (const album of albums) {
await mkdir(join(root, album), { recursive: true });
await writeFile(join(root, album, 'song.flac'), Buffer.alloc(128));
}
const portrait = Buffer.from('portrait');
await writeFile(join(root, 'artist.jpg'), portrait);
await writeFile(join(root, albums[0]!, 'cover.jpg'), Buffer.from('album cover'));
type Batch = { artist_image_base64?: string; cover_base64?: string; files: { client_id: string; rel_path: string }[] };
const batches: Batch[] = [];
await page.exposeFunction('recordArtistBatch', (batch: Batch) => { batches.push(batch); });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url).pathname;
const json = (data: unknown) => new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
if (path === '/api/v1/uploads/precheck') {
const body = await req.json();
return json({ files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) });
}
if (path === '/api/v1/uploads' && req.method === 'POST') {
const body = await req.json();
await (window as unknown as { recordArtistBatch: (body: unknown) => Promise<void> }).recordArtistBatch(body);
return json({ batch_id: crypto.randomUUID(), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/api/v1/uploads/files/${f.client_id}` })) });
}
return original(input, init);
};
history.pushState({}, '', '/uploads/new');
window.dispatchEvent(new PopStateEvent('popstate'));
});
await page.route('**/api/v1/uploads/files/*', async (route) => {
await route.fulfill({ status: route.request().method() === 'HEAD' ? 200 : 204,
headers: { 'Upload-Length': '128', 'Upload-Offset': route.request().method() === 'HEAD' ? '0' : '128', 'Tus-Resumable': '1.0.0' } });
});
await page.locator('input[webkitdirectory]').setInputFiles(root);
const send = page.getByRole('button', { name: /^Отправить/ });
await expect(send).toBeEnabled();
await expect(page.getByText(/Пропущен/)).toHaveCount(0);
await send.click();
await expect(page.getByText(/Готово: 2 файла/)).toBeVisible();
expect(batches).toHaveLength(2);
expect(batches.map((batch) => batch.files[0]!.rel_path).sort()).toEqual(albums.map((album) => `творожный бизнес/${album}/song.flac`).sort());
for (const batch of batches) {
expect(batch.files).toHaveLength(1);
expect(batch.artist_image_base64).toBe(portrait.toString('base64'));
}
expect(batches.find((batch) => batch.files[0]!.rel_path.includes(albums[0]!))!.cover_base64).toBe(Buffer.from('album cover').toString('base64'));
expect(batches.find((batch) => batch.files[0]!.rel_path.includes(albums[1]!))!.cover_base64).toBeUndefined();
});
+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');
});
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
for (const scenario of ['pending', 'automatic', 'restricted', 'incoming']) {
test(`listening request lifecycle: ${scenario}`, async ({ page }, info) => {
await page.addInitScript(value => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.listening', value);
}, scenario);
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const banner = page.getByRole('region', { name: 'Слушать вместе', exact: true });
await expect(banner).toHaveCount(0);
if (scenario === 'incoming') {
await expect(page.getByRole('button', { name: 'Принять', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('pending-invite.png'), fullPage: true });
await page.getByRole('button', { name: 'Принять', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Слушать вместе', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Слушать вместе', exact: true });
await expect(dialog.getByRole('radio', { name: 'Подключиться к другу', exact: true })).toBeChecked();
await dialog.getByRole('button', { name: 'Отправить', exact: true }).click();
if (scenario === 'restricted') {
await expect(dialog.getByRole('alert')).toContainText('Совместное прослушивание недоступно');
await expect(dialog.getByRole('alert')).not.toContainText('Чат доступен только друзьям');
await page.screenshot({ path: info.outputPath('request-restricted.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
await expect(dialog).toHaveCount(0);
if (scenario === 'pending') {
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toBeVisible();
await expect(banner).toHaveCount(0);
await page.getByRole('button', { name: 'Отмена', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
}
await expect(banner).toContainText('Слушаете вместе с moderator');
await expect(page.getByRole('contentinfo', { name: 'Плеер', exact: true }).getByRole('button', { name: 'Следующий', exact: true })).toBeDisabled();
await banner.getByRole('button', { name: 'Выйти из прослушивания', exact: true }).click();
await expect(banner).toHaveCount(0);
});
}
+72
View File
@@ -0,0 +1,72 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const { width, height, light } of [{ width: 1280, height: 900, light: false }, { width: 1920, height: 900, light: false }, { width: 1024, height: 600, light: false }, { width: 1280, height: 720, light: true }]) {
test(`mini chat keeps the page and draft at ${width}x${height} ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
await page.setViewportSize({ width, height });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (width === 1920) {
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Остров', exact: true }).click();
await expect(page.getByRole('button', { name: 'Остров', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('link', { name: 'Главная', exact: true }).first().click();
}
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
const dock = page.getByRole('complementary', { name: 'Друзья и чат', exact: true });
const toggle = dock.getByRole('button', { name: 'Друзья и чат', exact: true });
await toggle.click();
const panel = dock.getByRole('region', { name: 'Друзья и общение', exact: true });
await panel.getByRole('textbox', { name: 'Найти диалог', exact: true }).fill('moderator');
await expect(panel.getByRole('textbox', { name: 'Найти диалог', exact: true })).toHaveCSS('outline-style', 'none');
await panel.getByRole('button', { name: /moderator/ }).click();
const composer = panel.getByRole('textbox', { name: 'Сообщение', exact: true });
await composer.fill('Черновик из мини-чата');
await expect(page).toHaveURL(/\/$/);
await panel.getByRole('button', { name: 'Свернуть чат', exact: true }).click();
await expect(panel).toBeHidden();
await expect(toggle).toBeFocused();
await page.getByRole('link', { name: 'История', exact: true }).first().click();
await toggle.click();
await expect(composer).toHaveValue('Черновик из мини-чата');
await panel.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(panel.getByText('Черновик из мини-чата', { exact: true })).toBeVisible();
await expect(composer).toBeEmpty();
await expect(page).toHaveURL(/\/collection\/history$/);
const panelBox = (await panel.boundingBox())!;
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер', exact: true }).boundingBox())!;
expect(panelBox.width).toBeLessThanOrEqual(600);
expect(panelBox.x).toBeGreaterThanOrEqual(width - 640);
expect(panelBox.y).toBeGreaterThanOrEqual(60);
expect(panelBox.y + panelBox.height).toBeLessThan(playerBox.y);
await panel.evaluate(async element => {
await Promise.all(element.getAnimations({ subtree: true })
.filter(animation => animation.effect?.getTiming().iterations !== Infinity)
.map(animation => animation.finished.catch(() => undefined)));
});
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async (selector) => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => ({ target: node.target, summary: node.failureSummary })) }));
}, '[id="mini-social-panel"]');
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('mini-chat.png'), fullPage: true });
await composer.fill('Продолжить в полном чате');
await composer.press('Escape');
await expect(panel).toBeHidden();
await toggle.click();
await expect(composer).toHaveValue('Продолжить в полном чате');
await page.setViewportSize({ width: 800, height: 900 });
await expect(dock).toHaveCount(0);
await page.setViewportSize({ width, height });
await expect(composer).toHaveValue('Продолжить в полном чате');
await panel.getByRole('button', { name: 'Открыть раздел', exact: true }).click();
await expect(page).toHaveURL(/\/friends\?/);
await expect(dock).toHaveCount(0);
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveValue('Продолжить в полном чате');
});
}
+36 -1
View File
@@ -69,6 +69,8 @@ for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
await expect(page.locator('audio')).toBeHidden();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.locator('audio')).toBeVisible();
await fits(page);
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
@@ -92,6 +94,31 @@ for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430
});
}
for (const width of [390, 1280]) {
test(`player artist opens without a browser URL preview at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page, 'alice');
await page.setViewportSize({ width, height: 844 });
const box = page.getByRole('combobox', { name: 'Поиск', exact: true });
await box.fill('кино');
await box.press('Enter');
await page.getByRole('link', { name: /Группа крови/ }).first().click();
await page.getByRole('listitem').first().click();
const artist = page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('link', { name: 'Кино', exact: true });
await artist.hover();
await expect(artist).not.toHaveAttribute('href');
expect(await artist.evaluate((el) => Boolean(el.closest('[title], a[href]')))).toBe(false);
if (width === 390) {
await artist.focus();
await page.keyboard.press('Enter');
} else {
await artist.click();
}
await expect(page).toHaveURL(/\/artist\//);
await expect(page.getByRole('heading', { name: 'Кино', exact: true })).toBeVisible();
});
}
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 740 });
await login(page, 'alice');
@@ -102,7 +129,15 @@ test('mobile player keeps the island above navigation and exposes the queue', as
await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(220);
const artist = player.getByRole('link', { name: 'Кино', exact: true });
await expect(artist).toBeVisible();
const artistArea = await artist.locator('..').boundingBox();
expect(artistArea!.width).toBeGreaterThan(220);
const trackInfo = await player.getByText('Группа крови', { exact: true }).boundingBox();
const play = await player.getByRole('button', { name: /^(Пауза|Слушать)$/ }).boundingBox();
expect(trackInfo!.y + trackInfo!.height).toBeLessThanOrEqual(play!.y);
await page.screenshot({ path: info.outputPath('player-readable.png') });
await fits(page);
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
+45
View File
@@ -0,0 +1,45 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const width of [390, 1280]) {
test(`notification history scrolls inside the dialog at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.notifications', '1');
});
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await page.getByRole('menuitem', { name: 'Уведомления 4', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Уведомления', exact: true });
const list = dialog.getByRole('region', { name: 'Уведомления', exact: true });
await expect(list.getByRole('article')).toHaveCount(28);
const box = (await dialog.boundingBox())!;
expect(box.height).toBeLessThanOrEqual(680);
expect(box.width).toBeLessThanOrEqual(Math.min(600, width - (width < 768 ? 24 : 32)));
const close = dialog.getByRole('button', { name: 'Закрыть', exact: true });
const closeBefore = (await close.boundingBox())!;
await list.evaluate(element => { element.scrollTop = element.scrollHeight; });
await expect(close).toBeVisible();
expect((await close.boundingBox())!.y).toBe(closeBefore.y);
await list.evaluate(element => { element.scrollTop = 0; });
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async (selector) => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
}, '[role="dialog"]');
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('notifications.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Непрочитанные 4', exact: true }).click();
await expect(list.getByRole('article')).toHaveCount(4);
await list.getByRole('button', { name: 'Прочитано', exact: true }).first().click();
await expect(list.getByRole('article')).toHaveCount(3);
await dialog.getByRole('button', { name: 'Все', exact: true }).click();
await expect(list.getByRole('article')).toHaveCount(28);
await close.click();
await expect(dialog).toBeHidden();
});
}
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
for (const width of [320, 1280]) {
test(`playlists at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
const playlistsHeader = page.locator('header').filter({
has: page.getByRole('heading', { name: 'Плейлисты', exact: true }),
});
await playlistsHeader.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
const shared = new URL(sharedUrl);
expect(shared.origin).toBe(new URL(page.url()).origin);
expect(shared.pathname).toMatch(/^\/shared\/playlist\/[a-f0-9-]+$/);
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('playlist.png') });
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
});
}
+9
View File
@@ -48,6 +48,15 @@ test('settings expose crossfade and loudness normalization', async ({ page }) =>
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
await slider.fill('4');
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
await page.getByRole('button', { name: 'Мужской', exact: true }).click();
await page.getByRole('button', { name: '13–19', exact: true }).click();
const form = page.getByRole('heading', { name: 'Что вы любите слушать?' }).locator('../..');
await form.getByRole('button', { name: 'Сохранить', exact: true }).click();
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
await expect(page.getByRole('button', { name: 'Мужской', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: '13–19', exact: true })).toHaveAttribute('aria-pressed', 'true');
});
test('9. login page switches between Russian and English', async ({ page }) => {
await page.goto('/login');
+221
View File
@@ -0,0 +1,221 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин').fill(username);
await page.getByRole('textbox', { name: 'Пароль' }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('job log follows new output and pauses while reading older lines', async ({ page }) => {
await login(page);
await page.evaluate(() => {
let lines = 100;
window.addEventListener('test-job-lines', (event) => { lines = (event as CustomEvent<number>).detail; });
const original = window.fetch;
window.fetch = async (input, init) => {
const response = await original(input, init);
const request = new Request(input, init);
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/updates') {
const data = await response.json();
data.job = {
id: 'scroll-job', kind: 'update', state: 'running', target: 'main',
started_at: '2026-10-22T09:00:00Z', finished_at: '',
log: Array.from({ length: lines }, (_, i) => `Build line ${i + 1}`).join('\n'),
};
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
}
return response;
};
history.pushState({}, '', '/admin/updates');
window.dispatchEvent(new PopStateEvent('popstate'));
});
const log = page.getByLabel('Журнал выполнения');
const bottomGap = () => log.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
await expect(log).toContainText('Build line 100');
expect(await log.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true);
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 120 })));
await expect(log).toContainText('Build line 120');
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
await log.evaluate((el) => { el.scrollTop = 100; });
await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(100);
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 140 })));
await expect(log).toContainText('Build line 140');
expect(await log.evaluate((el) => el.scrollTop)).toBe(100);
await log.evaluate((el) => { el.scrollTop = el.scrollHeight; });
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 160 })));
await expect(log).toContainText('Build line 160');
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
});
test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Последний релиз', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: 'Источники обновлений', exact: true }).click();
await page.getByRole('button', { name: 'Добавить источник' }).click();
await page.getByLabel('Название', { exact: true }).last().fill('Тестовая репа');
await page.getByLabel('URL Git-репозитория').last().fill('https://git.example.org/pipu/testing.git');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
await page.getByLabel('Источник', { exact: true }).selectOption({ label: 'Тестовая репа' });
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
await page.getByRole('button', { name: 'Установить обновление', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('Тестовая репа');
await expect(dialog).toContainText('v1.10.0');
await dialog.getByRole('button', { name: 'Установить обновление' }).click();
await expect(page.getByRole('status').filter({ hasText: /^Выполняется$/ })).toBeVisible();
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
});
test('config keeps secret unchanged, saves local override and applies separately', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue('');
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug');
await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible();
await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6');
await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).not.toBeVisible();
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Отменить правки', exact: true })).not.toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён');
await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible();
await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; });
await page.screenshot({ path: 'test-results/server-config.png', fullPage: true });
await page.getByRole('button', { name: 'Применить и перезапустить', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Применить и перезапустить' }).click();
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
});
test('update delegate can update but cannot open configuration', async ({ page }) => {
await login(page, 'update-manager', '/admin/updates');
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
await page.evaluate(() => { history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); });
await expect(page).toHaveURL('/');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible();
});
test('configuration masks every stored secret even if an older API returns plaintext', async ({ page }) => {
await login(page);
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const response = await original(input, init);
const request = new Request(input, init);
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/config') {
const data = await response.json();
const common = { group: 'api', type: 'string', secret: true, readonly: false };
data.entries = [
{ ...common, key: 'ENV_SECRET', env: 'env-canary', local: 'local-canary', effective: 'env-canary', default_value: 'default-canary', source: 'env' },
{ ...common, key: 'DEFAULT_SECRET', env: null, local: null, effective: 'default-canary', default_value: 'default-canary', source: 'default' },
{ ...common, key: 'READONLY_SECRET', readonly: true, env: 'readonly-canary', local: null, effective: 'readonly-canary', default_value: '', source: 'env' },
];
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
}
return response;
};
history.pushState({}, '', '/admin/config');
window.dispatchEvent(new PopStateEvent('popstate'));
});
const env = page.getByLabel('ENV_SECRET · config.local', { exact: true });
const defaults = page.getByLabel('DEFAULT_SECRET · config.local', { exact: true });
await expect(env).toHaveValue('');
await expect(env).toHaveAttribute('type', 'password');
await expect(defaults).toHaveValue('');
await expect(defaults).toHaveAttribute('placeholder', '');
await expect(page.getByText('READONLY_SECRET', { exact: true })).toBeVisible();
for (const canary of ['env-canary', 'local-canary', 'default-canary', 'readonly-canary']) {
expect(await page.getByRole('main').innerHTML()).not.toContain(canary);
}
await page.getByRole('button', { name: 'Показать пароль', exact: true }).first().click();
await expect(env).toHaveValue('');
});
test('a replacement secret is hidden and cleared from the form after saving', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
const token = page.getByLabel('GITEA_TOKEN · config.local', { exact: true });
await token.fill('replacement-canary');
await expect(token).toHaveAttribute('type', 'password');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
await expect(token).toHaveValue('');
await expect(token).toHaveAttribute('placeholder', 'Секрет сохранён');
expect(await page.getByRole('main').innerHTML()).not.toContain('replacement-canary');
});
test('ordinary user is redirected away from server management', async ({ page }) => {
await login(page, 'alice', '/admin/updates');
await expect(page).toHaveURL('/');
});
test('installed main and release show no install action; another version can be installed', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'main', exact: true }).click();
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
await expect(page.getByText('Выберите версию для проверки', { exact: true })).toBeVisible();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.9.0');
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
await expect(page.getByText('Доступно обновление', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
});
test('explicit check bypasses cache and hides install action until the check finishes', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const request = new Request(input, init);
if (request.url.includes('/api/v1/admin/server/updates') && request.url.includes('fresh=true')) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const response = await original(input, init);
const data = await response.json();
return new Response(JSON.stringify({ ...data, update_state: 'current', target_commit: data.current_commit }), { headers: { 'Content-Type': 'application/json' } });
}
return original(input, init);
};
});
await page.getByRole('button', { name: 'Проверить обновления', exact: true }).click();
await expect(page.getByText('Проверяем выбранную версию…', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
});
test('saving panel disappears when all edited values return to their original state', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
const save = page.getByRole('button', { name: 'Сохранить', exact: true });
const apply = page.getByRole('button', { name: 'Применить и перезапустить', exact: true });
await expect(save).not.toBeVisible();
await expect(apply).not.toBeVisible();
const field = page.getByLabel('TRAIN_HOUR · config.local', { exact: true });
await field.fill('6');
await expect(save).toBeVisible();
await field.clear();
await expect(save).not.toBeVisible();
await expect(apply).not.toBeVisible();
await field.fill('7');
await page.getByRole('button', { name: 'Отменить правки', exact: true }).click();
await expect(save).not.toBeVisible();
await expect(field).toHaveValue('');
});
+43
View File
@@ -0,0 +1,43 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const { width, light } of [{ width: 390, light: false }, { width: 1280, light: false }, { width: 1280, light: true }]) {
test(`social profile settings at ${width}px ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Уведомления', exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: 'Моя музыка скрыта', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Приватность и общение', exact: true });
const visibility = dialog.getByRole('group', { name: 'Кто видит мою музыку', exact: true });
const friends = visibility.getByRole('button', { name: 'Друзьям', exact: true });
await friends.click();
await expect(friends).toHaveAttribute('aria-pressed', 'true');
await expect(friends).toBeEnabled();
await expect(friends).toBeFocused();
await dialog.getByText('Слушать вместе', { exact: true }).click();
await expect(dialog.getByRole('group', { name: 'Принимать запросы и приглашения от', exact: true })).toBeVisible();
expect((await dialog.boundingBox())!.width).toBeLessThanOrEqual(width - 24);
await expect.poll(() => dialog.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async () => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[role="dialog"]')!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
});
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('privacy.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const heading = page.getByRole('heading', { name: 'Друзья и общение', exact: true });
await expect(heading).toBeVisible();
expect((await heading.locator('..').boundingBox())!.height).toBeLessThanOrEqual(40);
await expect(page.getByRole('button', { name: 'Моя музыка скрыта', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Уведомления', exact: true })).toHaveCount(0);
});
}
+80
View File
@@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';
for (const width of [320, 800, 1280]) {
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: width === 1280 ? 'no-preference' : 'reduce' });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
const openFriends = async () => {
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
};
await openFriends();
if (width === 800) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: /moderator Начать разговор/ }).click();
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByText('Не в сети', { exact: true })).toBeVisible();
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByRole('button', { name: 'Слушать вместе', exact: true })).toBeDisabled();
await expect(page.getByRole('region', { name: 'Написать', exact: true })).toHaveCSS('animation-name', width === 1280 ? /conversationIn/ : 'none');
if (width === 800) expect((await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!.width).toBeGreaterThan(180);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill('Привет! Нашёл музыку для нашей поездки 🎵');
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(page.getByText('Привет! Нашёл музыку для нашей поездки 🎵', { exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeEmpty();
await page.getByRole('button', { name: 'Действия с сообщением', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Удалить для всех', exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: '❤️', exact: true }).click();
await expect(page.getByRole('button', { name: '❤️ 1', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('conversation.png'), fullPage: true });
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('listitem').first().click();
await page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('button', { name: 'Поделиться треком', exact: true }).click();
await expect(page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true })).toBeVisible();
if (width === 800) {
await page.getByRole('dialog').getByRole('button', { name: 'Отправить в приложение', exact: true }).click();
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('shared-url'))).toContain('/track/');
}
await page.getByRole('dialog').getByRole('button', { name: 'moderator · Отправить', exact: true }).click();
await expect(page.getByRole('button', { name: 'moderator · Отправлено', exact: true })).toBeDisabled();
await page.screenshot({ path: info.outputPath('share-music.png') });
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
await openFriends();
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Музыка из переписки', exact: true }).click();
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveCount(0);
await page.screenshot({ path: info.outputPath('shared-music.png'), fullPage: true });
await page.getByRole('button', { name: 'Переписка', exact: true }).click();
const longMessage = '%20%D0%BC%D1%83%D0%B7%D1%8B%D0%BA%D0%B0'.repeat(45);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill(longMessage);
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(page.getByRole('button', { name: 'Показать полностью', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Показать полностью', exact: true }).click();
await expect(page.getByRole('button', { name: 'Свернуть текст', exact: true }).locator('..')).toContainText(longMessage);
await page.getByRole('button', { name: 'Свернуть текст', exact: true }).click();
if (width === 320) {
await page.getByRole('button', { name: 'Назад к сообщениям', exact: true }).click();
await expect(page.getByRole('searchbox', { name: 'Найти диалог', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeHidden();
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
await page.setViewportSize({ width, height: 560 });
}
await expect.poll(async () => {
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
return composerBox.y + composerBox.height - playerBox.y;
}).toBeLessThanOrEqual(0);
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.getByRole('link', { name: 'Открыть трек', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Группа крови', exact: true })).toBeVisible();
});
}
+462
View File
@@ -0,0 +1,462 @@
import { expect, test, type Page } from '@playwright/test';
function previewWav(seconds = 2) {
const dataSize = 8000 * 2 * seconds;
const wav = Buffer.alloc(44 + dataSize);
wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
wav.write('data', 36); wav.writeUInt32LE(dataSize, 40);
return wav;
}
async function setup(page: Page, scenario: 'queue' | 'template' | 'mine' | 'release' | 'mixed', audioAvailable = true) {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
await page.evaluate(async ({ scenario, audioAvailable }) => {
const modulePath = '/src/test/mobileFixtures.ts';
const { mobileFixtures } = await import(modulePath);
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
const file = { ...structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]), batch_has_cover: true };
const files = [
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac', stream_url: `/api/v1/admin/uploads/files/${file.id}/stream?exp=9999999999&sig=test` },
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
{ ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
{ ...file, id: 'rejected', original_name: 'rejected.flac', metadata: null, status: 'rejected' },
];
files[0]!.bitrate = 778000;
const missing = { ...file, id: 'missing', original_name: 'missing.flac', bitrate: 924000, target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' };
const releaseId = '11111111-1111-4111-8111-111111111111';
const deluxeId = '22222222-2222-4222-8222-222222222222';
const tracks = [1, 2].map((n) => ({ recording_id: `recording-${n}`, title: `Song ${n}`, artist: 'Catalogue Artist', disc: 1, position: n, duration_ms: 180000 }));
const standard = { release: { id: releaseId, group_id: releaseId, title: 'Album', artist: 'Catalogue Artist', type: 'Album', date: '2024-01-01', country: 'RU', comment: 'Digital', has_cover: true, tracks },
matches: [{ file_id: 'visible', track_index: 0, confidence: 'high' }, { file_id: 'missing', track_index: 1, confidence: 'high' }], missing: [], existing: [], unmatched: [], matched: 2, total: 2, complete: true, confidence: 'high' };
const bonus = { ...tracks[0]!, title: 'Bonus', position: 3 };
const deluxe = { ...standard, release: { ...standard.release, id: deluxeId, comment: 'Deluxe', tracks: [...tracks, bonus] }, missing: [bonus], existing: [bonus], total: 3, complete: false };
let releaseReport = { state: 'ready', input_hash: 'original', selected_release: null as string | null, candidates: [standard, deluxe], audio_available: audioAvailable, audio_results: [] as { file_id: string; state: string }[] };
const groupA = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', artist: 'Artist A', album: 'Album A', file_ids: ['visible'], has_cover: true, state: 'ready' };
const groupB = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', artist: 'Artist B', album: 'Album B', file_ids: ['missing'], has_cover: false, state: 'ready' };
const groups = [groupA, groupB];
const groupReports = Object.fromEntries(groups.map((g, i) => [g.id, { ...releaseReport, group_id: g.id, groups, input_hash: g.id,
candidates: [{ ...standard, release: { ...standard.release, id: i ? deluxeId : releaseId, artist: g.artist, title: g.album, tracks: [tracks[i]] }, matches: [{ file_id: g.file_ids[0], track_index: 0, confidence: 'high' }], matched: 1, total: 1 }],
}]));
let rejectReleaseOnce = true;
let failOnce = true;
const deleted: string[] = [];
const original = window.fetch;
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
window.fetch = async (input, init) => {
const req = new Request(input, init);
const url = new URL(req.url);
if (scenario === 'mixed' && url.pathname.includes('/admin/uploads/batches/')) {
if (url.pathname.endsWith('/releases')) {
if (req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
return json({ ...groupReports[body.group_id], state: 'queued' }, 202);
}
return json(groupReports[url.searchParams.get('group_id') ?? ''] ?? { ...releaseReport, state: 'needs_input', candidates: [], groups });
}
if (url.pathname.endsWith('/cover')) {
document.documentElement.dataset.releaseCoverGroup = url.searchParams.get('group_id') ?? '';
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release/' + releaseId + '/front-1200', exact_release: true });
}
if (url.pathname.endsWith('/release') && req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
const target = groups.find((g) => g.id === body.group_id)!;
if (body.apply_cover) target.has_cover = true;
if (body.apply_metadata) {
const f = body.group_id === groupA.id ? files[0]! : missing;
Object.assign(f, { metadata: { artist: target.artist, albumartist: target.artist, album: target.album, title: 'Confirmed ' + target.album, genre: '' }, updated_at: new Date().toISOString() });
}
groupReports[body.group_id]!.selected_release = body.release_id;
return json(groupReports[body.group_id]);
}
}
if (scenario === 'mixed' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (url.pathname.endsWith('/releases') && url.pathname.includes('/admin/uploads/batches/')) {
if (scenario !== 'release') return json({ state: 'disabled', input_hash: '', selected_release: null, candidates: [] });
if (req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
if (body.audio) releaseReport = { ...releaseReport, audio_results: [{ file_id: 'visible', state: 'matched' }, { file_id: 'missing', state: 'ambiguous' }], candidates: [
{ ...standard, complete: false, confidence: 'medium', matches: [{ ...standard.matches[0]!, method: 'acoustid' }] } as typeof standard, deluxe,
] };
return json({ ...releaseReport, state: 'queued' }, 202);
}
return json(releaseReport);
}
if (scenario === 'release' && url.pathname.endsWith('/cover')) {
document.documentElement.dataset.releaseCoverRequests = String(Number(document.documentElement.dataset.releaseCoverRequests ?? 0) + 1);
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release-group/11111111-1111-4111-8111-111111111111/front-1200', exact_release: false });
}
if (scenario === 'release' && url.pathname.endsWith('/release') && req.method === 'POST') {
const body = await req.json();
if (body.apply_metadata && rejectReleaseOnce) { rejectReleaseOnce = false; return json({ code: 'uploads.release_stale', status: 409 }, 409); }
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
if (body.apply_metadata) {
for (const [index, f] of [files[0]!, missing].entries()) Object.assign(f, { metadata: { artist: 'Catalogue Artist', albumartist: 'Catalogue Artist', title: `Song ${index + 1}`, album: 'Album', year: 2024, track: index + 1, disc: 1, genre: '' }, updated_at: new Date().toISOString() });
}
releaseReport = { ...releaseReport, input_hash: 'applied', selected_release: body.release_id };
return json(releaseReport);
}
if (scenario === 'release' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (scenario === 'queue' && url.pathname.startsWith('/api/v1/admin/uploads/batches/') && req.method === 'PATCH') {
const body = await req.json();
const bytes = Uint8Array.from(atob(body.cover_base64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
document.documentElement.dataset.coverDimensions = `${image.width}x${image.height}`;
document.documentElement.dataset.coverBytes = String(bytes.length);
image.close();
return json({ items: [files[0], missing] });
}
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') {
const body = await req.json();
Object.assign(missing, { metadata: body.metadata, target_source_id: source.id, target_source_name: source.name, target_rel_path: 'Artist/Album/02 - Fixed.flac', status_reason: null });
return json(missing);
}
if (scenario === 'template') {
if (url.pathname === '/api/v1/admin/uploads') {
const path = document.documentElement.dataset.savedTemplate ? 'Artist/Album/01. Song.flac' : 'Artist/Album/Song.flac';
return json({ items: [{ ...files[0], target_rel_path: path, target_storage_path: `nas:/music/${path}` }], next_cursor: null });
}
if (url.pathname.endsWith('/write-template/suggest')) return json({ template: '{albumartist}/{album}/{track:02}. {title}.{ext}', matched: 180, total: 200, examples: ['Кино/Группа крови/01. Группа крови.flac'] });
if (url.pathname.endsWith('/write-template/check')) return json({ ok: false, total: 50, round_trip: 0, same_path: 45, mismatches: [] });
if (url.pathname === `/api/v1/admin/sources/${source.id}` && req.method === 'PATCH') {
const body = await req.json();
document.documentElement.dataset.savedTemplate = body.write_template;
Object.assign(source, body);
return json(source);
}
if (url.pathname === `/api/v1/admin/sources/${source.id}`) return json(source);
}
if (scenario === 'mine' && url.pathname === '/api/v1/uploads/mine') {
if (url.searchParams.getAll('status').length > 1) return json({ code: 'common.bad_request', status: 400 }, 400);
if (url.searchParams.has('batch')) {
document.documentElement.dataset.batchPages = String(Number(document.documentElement.dataset.batchPages ?? 0) + 1);
return json({ items: [url.searchParams.has('cursor') ? files[2] : files[0]], next_cursor: url.searchParams.has('cursor') ? null : 'next' });
}
const statuses = url.searchParams.get('status')?.split(',') ?? [];
const visible = [files[0], files[1], files[3], files[4]].filter((f) => !statuses.length || statuses.includes(f.status));
return json({ items: visible, next_cursor: 'more' });
}
if (scenario === 'mine' && req.method === 'DELETE' && url.pathname.startsWith('/api/v1/uploads/files/')) {
const id = url.pathname.split('/').pop()!;
deleted.push(id);
document.documentElement.dataset.deleted = JSON.stringify(deleted);
if (id === 'hidden' && failOnce) { failOnce = false; return json({ code: 'uploads.wrong_status', status: 409 }, 409); }
files.find((f) => f.id === id)!.status = 'withdrawn';
return new Response(null, { status: 204 });
}
return original(input, init);
};
const target = scenario === 'queue' || scenario === 'release' || scenario === 'mixed' ? '/admin/uploads' : scenario === 'mine' ? '/uploads/mine' : `/admin/sources/${source.id}/patterns`;
history.pushState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}, { scenario, audioAvailable });
}
test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
await setup(page, 'queue');
await expect(page.getByText('Есть обложка', { exact: true })).toBeVisible();
await expect(page.getByText('2 × FLAC', { exact: true })).toBeVisible();
await expect(page.getByText('778–924 kbps · 44.1 kHz · 16 bit', { exact: true })).toBeVisible();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01 - Song.flac');
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
await expect(page.getByRole('button', { name: 'Развернуть пачку', exact: true })).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).toBeChecked();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).press('Enter');
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
// Continue keyboard selection after keyboard expansion, avoiding another scroll-dependent pointer action.
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).press('Space');
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).not.toBeChecked();
await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist');
await dialog.getByLabel('Название', { exact: true }).fill('Fixed');
await dialog.getByRole('combobox').selectOption({ label: 'Музыкальное хранилище' });
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('queue-placement.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
});
test('automatically suggests a dominant template and saves a custom edit only on request', async ({ page }, info) => {
await setup(page, 'template');
const field = page.getByRole('textbox', { name: 'Шаблон пути', exact: true });
await expect(field).toHaveValue('{albumartist}/{album}/{track:02}. {title}.{ext}');
await expect(page.getByText(/180 из 200/)).toBeVisible();
await expect(page.getByText('Кино/Группа крови/01. Группа крови.flac', { exact: true })).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-saved-template');
await field.fill('{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
await section.getByRole('checkbox').check();
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
await page.screenshot({ path: info.outputPath('auto-template.png'), fullPage: true });
});
test('selects a whole own batch across pages and reports partial withdrawal failures', async ({ page }, info) => {
await setup(page, 'mine');
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
await expect(page.locator('html')).toHaveAttribute('data-batch-pages', '2');
await expect(page.getByText(/Выбрано 2 файла/)).toBeVisible();
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
await expect(page.getByText(/Не удалось отозвать: 1/)).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden"]');
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => Math.max(document.documentElement.scrollWidth - innerWidth, document.querySelector('main')!.scrollWidth - document.querySelector('main')!.clientWidth))).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('bulk-withdraw-mobile.png'), fullPage: true });
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden","hidden"]');
await expect(page.getByRole('region', { name: 'Массовый отзыв предложений' })).toHaveCount(0);
});
test('keeps withdrawn proposals in their own filter', async ({ page }) => {
await setup(page, 'mine');
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Отозванные', exact: true }).click();
await expect(page.getByText('withdrawn.flac', { exact: true }).first()).toBeVisible();
await expect(page.getByText('visible.flac', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Не приняты', exact: true }).click();
await expect(page.getByText('rejected.flac', { exact: true }).first()).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Все', exact: true }).click();
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
});
test('queue shows the current full destination after saving a changed source template', async ({ page }) => {
await setup(page, 'template');
const sourcePath = new URL(page.url()).pathname;
const navigate = async (path: string) => page.evaluate((path) => {
history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}, path);
await navigate('/admin/uploads');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/Song.flac');
await navigate(sourcePath);
await page.getByRole('textbox', { name: 'Шаблон пути', exact: true }).fill('{albumartist}/{album}/{track:02}. {title}.{ext}');
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
await section.getByRole('checkbox').check();
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/{album}/{track:02}. {title}.{ext}');
await navigate('/admin/uploads');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01. Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01. Song.flac');
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveCount(0);
});
test('queue preview only requests audio after play', async ({ page }) => {
const wav = previewWav();
let requested = 0;
let otherRequested = 0;
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => {
requested++;
return route.fulfill({ contentType: 'audio/wav', body: wav });
});
await page.route('**/api/v1/stream/mobile-preview', (route) => {
otherRequested++;
return route.fulfill({ contentType: 'audio/wav', body: wav });
});
await setup(page, 'queue');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
const audio = page.locator('audio').first();
await expect(audio).toHaveAttribute('preload', 'none');
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
expect(requested).toBe(0);
expect(otherRequested).toBe(0);
await audio.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
expect(requested).toBeGreaterThan(0);
expect(otherRequested).toBe(0);
expect(await audio.evaluate((el: HTMLAudioElement) => el.error)).toBeNull();
});
test('queue preview explains HTTP failures and recovers on retry', async ({ page }) => {
let available = false;
// The E2E bridge mocks window.fetch; media diagnostics must reach the same
// intercepted HTTP route as the native audio element.
await page.addInitScript(() => Object.assign(window, { previewNativeFetch: window.fetch.bind(window) }));
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => available
? route.fulfill({ contentType: 'audio/wav', body: previewWav() })
: route.fulfill({ status: 403, contentType: 'text/plain', body: 'Forbidden' }));
await setup(page, 'queue');
await page.evaluate(() => {
const original = window.fetch;
const native = (window as unknown as { previewNativeFetch: typeof fetch }).previewNativeFetch;
window.fetch = (input, init) => new URL(new Request(input, init).url).pathname.endsWith('/stream')
? native(input, init) : original(input, init);
});
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
const preview = page.locator('audio').first().locator('..');
await preview.locator('audio').evaluate((el: HTMLAudioElement) => el.play().catch(() => undefined));
await expect(preview.getByRole('alert')).toHaveText('Не удалось загрузить аудио: HTTP 403.');
available = true;
await preview.getByRole('button', { name: 'Повторить', exact: true }).click();
await expect.poll(() => preview.locator('audio').evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
await expect(preview.getByRole('alert')).toHaveCount(0);
});
test('starting another queue preview pauses the previous track', async ({ page }) => {
const wav = previewWav(10);
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
await page.route('**/api/v1/stream/mobile-preview', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
await setup(page, 'queue');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
const first = page.locator('audio').nth(0);
const second = page.locator('audio').nth(1);
await first.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
await second.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
expect(await second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
expect(await first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
await first.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
expect(await first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
});
test('oversized album cover is reduced before saving the batch', async ({ page }) => {
await setup(page, 'queue');
const png = await page.evaluate(() => {
const canvas = document.createElement('canvas');
canvas.width = 3000; canvas.height = 1800;
canvas.getContext('2d')!.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png').split(',')[1]!;
});
// A valid image with trailing padding reproduces an oversized input without a large fixture in Git.
const largeCover = Buffer.concat([Buffer.from(png, 'base64'), Buffer.alloc(12 * 1024 * 1024)]);
await page.getByRole('button', { name: 'На всю пачку', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.locator('input[type=file]').setInputFiles({ name: 'Cover.png', mimeType: 'image/png', buffer: largeCover });
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-cover-dimensions', '2048x1229');
expect(Number(await page.locator('html').getAttribute('data-cover-bytes'))).toBeLessThan(8 * 1024 * 1024);
});
test('mixed batch checks each release and applies only within the chosen group', async ({ page }, info) => {
await setup(page, 'mixed');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist A — Album A', { exact: true })).toBeVisible();
await expect(page.getByText('Artist B — Album B', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: 'Выбрать загруженные строки релиза Album A', exact: true }).check();
await expect(page.getByRole('checkbox', { name: 'Выбрать visible.flac', exact: true })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Выбрать missing.flac', exact: true })).not.toBeChecked();
await page.getByRole('button', { name: 'Релизов в пачке: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
await expect(dialog.getByText('Artist B — Album B', { exact: true })).toBeVisible();
await expect(dialog.getByText('Без обложки', { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-cover-group', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
await page.screenshot({ path: info.outputPath('mixed-release.png') });
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
await dialog.getByRole('checkbox', { name: 'Добавить обложку выбранного релиза', exact: true }).check();
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-applied', /"group_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"/);
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
await expect(dialog.getByText('Artist A — Album A', { exact: true })).toBeVisible();
await expect(dialog.getByText('Сопоставлено 1 из 1', { exact: false })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(dialog.getByRole('checkbox', { name: /Применить названия/ })).not.toBeChecked();
});
test('audio recognition starts only on request and keeps suggestions separate', async ({ page }) => {
await setup(page, 'release');
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
await expect(dialog.getByText(/только аудиоотпечатки и длительности/)).toBeVisible();
await dialog.getByRole('button', { name: 'Распознать по звуку', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-search', '{"audio":true}');
await expect(dialog.getByText('По звуку распознано: 1 из 2 треков', { exact: true })).toBeVisible();
await expect(dialog.getByText(/Неоднозначное или недостаточно уверенное совпадение/)).toBeVisible();
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
await expect(dialog.getByText('Совпадение по звуку · AcoustID', { exact: true })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
});
test('audio identification without application key shows setup and stays disabled', async ({ page }) => {
await setup(page, 'release', false);
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
await expect(dialog.getByText(/задайте ACOUSTID_API_KEY/)).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
});
test('release suggestions require explicit application and handle stale metadata', async ({ page }) => {
await setup(page, 'release');
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
await expect(dialog.getByText('Уверенность сопоставления: высокая', { exact: true })).toBeVisible();
const apply = dialog.getByRole('button', { name: 'Применить выбранное', exact: true });
await expect(apply).toBeDisabled();
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
await apply.click();
await expect(dialog.getByText(/Состав или теги пачки изменились/)).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
await dialog.getByRole('textbox', { name: 'Исполнитель', exact: true }).fill('Catalogue Artist');
await dialog.getByRole('textbox', { name: 'Альбом', exact: true }).fill('Album');
await dialog.getByRole('button', { name: 'Найти / повторить поиск', exact: true }).click();
await expect(dialog.getByRole('status')).toHaveText('Найдено изданий: 2');
await expect(dialog.getByRole('combobox', { name: 'Издание' })).toBeVisible();
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
await expect(apply).toBeEnabled();
await apply.click();
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"11111111-1111-4111-8111-111111111111","apply_metadata":true,"apply_cover":false}');
await expect(dialog.getByText('Выбранное издание применено', { exact: true })).toBeVisible();
});
test('deluxe completeness and release-group artwork are shown before replacing an existing cover', async ({ page }, info) => {
await setup(page, 'release');
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222');
await expect(dialog.getByText('Альбом · сопоставлено 2/3 · Неполный состав', { exact: true })).toBeVisible();
await expect(dialog.getByText('Файл не сопоставлен', { exact: true })).toBeVisible();
await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible();
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible();
await expect(dialog.getByRole('img', { name: 'Обложка пачки' })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await page.screenshot({ path: info.outputPath('release-comparison.png') });
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await dialog.getByRole('checkbox', { name: 'Заменить текущую обложку пачки этой обложкой', exact: true }).check();
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"22222222-2222-4222-8222-222222222222","apply_metadata":false,"apply_cover":true}');
});
+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { expect, test } from '@playwright/test';
const nginxHeaders = readFileSync(new URL('../../../deploy/nginx/security-headers.conf', import.meta.url), 'utf8');
const desktopConfig = JSON.parse(readFileSync(new URL('../../desktop/src-tauri/tauri.conf.json', import.meta.url), 'utf8'));
const policies: Record<string, string> = {
web: nginxHeaders.match(/add_header Content-Security-Policy "([^"]+)" always;/)![1],
desktop: desktopConfig.app.security.csp,
};
for (const [platform, policy] of Object.entries(policies)) {
test(`upload hash worker runs under ${platform} CSP`, async ({ page }) => {
// Apply the deployed policy to both the document and worker responses.
await page.route('**/*', async (route) => {
if (new URL(route.request().url()).pathname === '/csp-upload-test') {
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Upload CSP</title>', headers: { 'Content-Security-Policy': policy } });
return;
}
if (new URL(route.request().url()).pathname === '/csp-eval-test.js') {
await route.fulfill({ contentType: 'application/javascript', body: `
try { new Function('return 1')(); document.documentElement.dataset.evalBlocked = 'false'; }
catch { document.documentElement.dataset.evalBlocked = 'true'; }
WebAssembly.compile(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])).then(
() => { document.documentElement.dataset.wasmBlocked = 'false'; },
() => { document.documentElement.dataset.wasmBlocked = 'true'; },
);
` });
return;
}
const response = await route.fetch();
await route.fulfill({ response, headers: { ...response.headers(), 'Content-Security-Policy': policy } });
});
await page.goto('/csp-upload-test');
const result = await page.evaluate(async () => {
const worker = new Worker('/src/features/uploads/hash.worker.ts', { type: 'module' });
try {
const results = [];
// Reuse one worker for empty, known-vector and multi-chunk binary files.
const binary = Uint8Array.from({ length: (9 << 20) + 17 }, (_, i) => i % 251);
for (const [id, bytes] of [['empty', new Uint8Array()], ['abc', new TextEncoder().encode('abc')], ['binary', binary]] as const) {
results.push(await new Promise((resolve, reject) => {
const progress: number[] = [];
worker.onerror = (event) => reject(new Error(event.message));
worker.onmessage = ({ data }) => {
if (data.type === 'progress') { progress.push(data.done); return; }
resolve({ ...data, progress });
};
worker.postMessage({ id, file: new File([bytes], `${id}.flac`) });
}));
}
return results;
} finally {
worker.terminate();
}
});
const binary = Uint8Array.from({ length: (9 << 20) + 17 }, (_, i) => i % 251);
expect(result).toEqual([
{ id: 'empty', type: 'done', sha256: createHash('sha256').update('').digest('hex'), progress: [] },
{ id: 'abc', type: 'done', sha256: createHash('sha256').update('abc').digest('hex'), progress: [] },
{ id: 'binary', type: 'done', sha256: createHash('sha256').update(binary).digest('hex'), progress: expect.arrayContaining([expect.any(Number)]) },
]);
// A page script observes CSP; Playwright's evaluate bypasses the eval restriction.
await page.addScriptTag({ url: '/csp-eval-test.js' });
await expect(page.locator('html')).toHaveAttribute('data-eval-blocked', 'true');
await expect(page.locator('html')).toHaveAttribute('data-wasm-blocked', 'true');
});
}
+84
View File
@@ -0,0 +1,84 @@
import { expect, test } from '@playwright/test';
test('locks the comment and retries one failed upload while another is running', async ({ page }, info) => {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url).pathname;
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
if (path === '/api/v1/uploads/quota') return json({ limit_bytes: 1e9, used_bytes: 0, per_user_limit_bytes: null, user_used_bytes: 0, max_file_bytes: 1e9 });
if (path === '/api/v1/uploads/precheck') {
const body = await req.json();
return json({ files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) });
}
if (path === '/api/v1/uploads' && req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.uploadComment = body.comment;
document.documentElement.dataset.uploadReservations = String(Number(document.documentElement.dataset.uploadReservations ?? 0) + 1);
return json({ batch_id: 'test-batch', files: body.files.map((f: { client_id: string; name: string }) => ({ client_id: f.client_id, upload_file_id: f.name, tus_url: `/api/v1/uploads/files/${f.name}` })) }, 201);
}
return original(input, init);
};
history.pushState({}, '', '/uploads/new');
window.dispatchEvent(new PopStateEvent('popstate'));
});
let retry = false;
let failures = 0;
let resumedOffset: string | undefined;
let releaseSecond!: () => void;
const secondPending = new Promise<void>((resolve) => { releaseSecond = resolve; });
await page.route('**/api/v1/uploads/files/*', async (route) => {
const req = route.request();
const first = req.url().endsWith('/first.flac');
if (req.method() === 'HEAD') {
await route.fulfill({ status: 200, headers: { 'Upload-Length': '1024', 'Upload-Offset': first && failures > 0 ? '256' : '0', 'Tus-Resumable': '1.0.0' } });
return;
}
if (first && !retry) {
failures++;
await route.fulfill({ status: 507, contentType: 'application/problem+json', body: JSON.stringify({ status: 507, code: 'uploads.storage_full', detail: 'upload storage is full' }) });
return;
}
if (first) resumedOffset = req.headers()['upload-offset'];
else await secondPending;
await route.fulfill({ status: 204, headers: { 'Upload-Offset': '1024', 'Tus-Resumable': '1.0.0' } });
});
const inputs = page.locator('input[type=file]');
await inputs.first().setInputFiles(['first.flac', 'second.flac'].map((name) => ({ name, mimeType: 'audio/flac', buffer: Buffer.alloc(1024) })));
const comment = page.getByRole('textbox', { name: 'Комментарий для администратора' });
await comment.fill('Lossless album');
await expect(comment).toBeEnabled();
const send = page.getByRole('button', { name: /^Отправить/ });
await expect(send).toBeEnabled();
await send.click();
await expect(comment).toBeDisabled();
await expect(page.getByRole('button', { name: 'Очистить список' })).toBeDisabled();
await expect(page.getByText(/На сервере закончилось место для загрузок/)).toBeVisible();
const retryButton = page.getByRole('button', { name: 'Повторить загрузку first.flac', exact: true });
await expect(retryButton).toBeEnabled();
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('upload-retry.png'), fullPage: true });
expect(failures).toBe(1);
retry = true;
await retryButton.click();
await expect.poll(() => resumedOffset).toBe('256');
await expect(retryButton).toHaveCount(0);
await expect(comment).toBeDisabled();
await expect(page.locator('html')).toHaveAttribute('data-upload-reservations', '1');
await expect(page.locator('html')).toHaveAttribute('data-upload-comment', 'Lossless album');
releaseSecond();
await expect(page.getByText(/Готово: 2 файла/)).toBeVisible();
await page.getByRole('button', { name: 'Загрузить ещё', exact: true }).click();
await inputs.first().setInputFiles({ name: 'third.flac', mimeType: 'audio/flac', buffer: Buffer.alloc(1024) });
await expect(comment).toBeEnabled();
});
+70
View File
@@ -0,0 +1,70 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/admin/users') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
for (const width of [320, 1280]) {
test(`user transfer and cover counts at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await login(page);
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url).pathname;
if (path === '/api/v1/admin/users/export') return new Response(JSON.stringify({
version: 1, users: [{ username: 'alice', role: 'user', password_hash: '$argon2id$test' }],
}), { headers: { 'Content-Type': 'application/json' } });
if (path === '/api/v1/admin/users/import') {
const archive = await req.json();
if (archive.users[0].password_hash !== '$argon2id$test') throw new Error('lost hash');
return new Response(JSON.stringify({ created: 1, skipped: ['alice'] }), { headers: { 'Content-Type': 'application/json' } });
}
return original(input, init);
};
});
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Экспорт пользователей', exact: true }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('pipu-users.json');
await page.getByRole('button', { name: 'Импорт пользователей', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Импорт пользователей' });
const file = dialog.getByLabel('Файл пользователей (.json)');
await file.setInputFiles({ name: 'bad.json', mimeType: 'application/json', buffer: Buffer.from('{}') });
await expect(dialog.getByRole('alert')).toContainText('Некорректный файл');
await expect(dialog.getByRole('button', { name: 'Импортировать', exact: true })).toBeDisabled();
await file.setInputFiles({ name: 'users.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({
version: 1, users: [{ username: 'new-user', role: 'moderator', password_hash: '$argon2id$test' }],
})) });
await expect(dialog).toContainText('Учётных записей в файле: 1');
await page.screenshot({ path: info.outputPath('user-import.png') });
await dialog.getByRole('button', { name: 'Импортировать', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('Создано: 1. Пропущено существующих: 1.');
await expect(dialog.getByRole('status')).toContainText('alice');
await page.keyboard.press('Escape');
if (width < 768) {
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
} else await page.evaluate(() => {
history.pushState({}, '', '/admin/sources');
window.dispatchEvent(new PopStateEvent('popstate'));
});
await expect(page.getByRole('article')).toContainText('Локальные обложки: 87 из 120 файлов');
await expect(page.getByRole('article')).toContainText('Альбомов ожидают обработки: 3');
await page.screenshot({ path: info.outputPath('source-covers.png') });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
});
}
test('moderator cannot see user transfer controls', async ({ page }) => {
await login(page, 'moderator');
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Экспорт пользователей', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Импорт пользователей', exact: true })).toHaveCount(0);
});
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<title>pipu</title>
</head>
<body>
+1 -1
View File
@@ -23,7 +23,7 @@
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20",
"@tanstack/react-virtual": "^3.14.21",
"hash-wasm": "^4.12.0",
"js-sha256": "^1.0.0",
"music-metadata": "^12.2.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+6 -1
View File
@@ -3,7 +3,12 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] },
use: {
baseURL: 'http://127.0.0.1:4173',
...devices['Desktop Chrome'],
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: {
command: 'pnpm exec vite --port 4173 --strictPort',
url: 'http://127.0.0.1:4173',
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 840 B

+499 -3
View File
@@ -1,4 +1,4 @@
import { AuthSession } from '@pipu/core';
import { AuthSession, refreshListening } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory } from '@tanstack/react-router';
@@ -10,10 +10,260 @@ import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App';
import { createAppRouter, safeRedirect } from './app/router';
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup);
describe('social', () => {
it.each([false, true])('closes a join dialog and follows only after admission (automatic=%s)', async (automatic) => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, session } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 1000 });
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: automatic ? 'friends' : 'nobody', autoplay: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
if (!automatic) {
expect(api.listeningMembers.has(alice.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
expect(api.listeningRequests.size).toBe(1);
const request = [...api.listeningRequests.values()][0]!;
const room = [...api.listeningRooms.values()][0]!;
api.listeningMembers.set(alice.id, room.id);
api.listeningRequests.delete(request.id);
refreshListening(session);
}
await screen.findByText('Слушаете вместе с bob');
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument());
});
it('closes a pending invitation without announcing a stream and allows cancellation', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, router } = await renderApp([alice, bob], `/track/${albumTracks[0]!.id}`, { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: 'nobody', autoplay: true });
await router.navigate({ to: '/friends' });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
expect(api.listeningMembers.has(bob.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: 'Отмена' }));
await waitFor(() => expect(api.listeningRequests.size).toBe(0));
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
});
it('explains listening restrictions instead of reporting a chat error and can close after rejection', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Совместное прослушивание недоступно');
expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Чат доступен только друзьям');
expect(api.listeningRequests.size).toBe(0);
expect(api.listeningRooms.size).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Закрыть' }));
expect(dialog).not.toBeInTheDocument();
});
it('enables listening together only while a friend is online, independently of music privacy', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
const conversation = await screen.findByRole('region', { name: 'Написать' });
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
api.presence.set(bob.id, Date.now());
await queryClient.invalidateQueries({ queryKey: ['social'] });
await within(conversation).findByText('В сети');
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeEnabled();
expect(api.activity.has(bob.id)).toBe(false);
await user.click(within(conversation).getByRole('button', { name: 'Слушать вместе' }));
await screen.findByRole('dialog', { name: 'Слушать вместе' });
api.presence.set(bob.id, Date.now() - 46_000);
await queryClient.invalidateQueries({ queryKey: ['social'] });
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Слушать вместе' })).not.toBeInTheDocument());
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
});
it('finds music beyond the last text page without acknowledging unread messages', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
for (let seq = 1; seq <= 61; seq++) api.messages.push({ id: crypto.randomUUID(), seq, sender_id: bob.id, recipient: alice.id, text: seq === 1 ? albumTracks[0]!.title : `Сообщение ${seq}`, attachment_kind: seq === 1 ? 'track' : 'none', ...(seq === 1 ? { attachment_id: albumTracks[0]!.id } : {}), created_at: new Date().toISOString(), read: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Музыка из переписки' }));
await screen.findByRole('link', { name: 'Открыть трек' });
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
const firstCall = api.calls.length;
api.messages.push({ id: crypto.randomUUID(), seq: 62, sender_id: bob.id, recipient: alice.id, text: 'Новая музыка', attachment_kind: 'track', attachment_id: albumTracks[0]!.id, created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByText('Новая музыка');
expect(api.messages.at(-1)?.read).toBe(false);
expect(api.calls.slice(firstCall).some(c => c.path.endsWith('/actions') && (c.body as { action?: string })?.action === 'read_chat')).toBe(false);
});
it('keeps the current conversation when another friend sends newer messages and confirms blocking', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const carol = makeUser('carol', 'user');
const { api, user, queryClient } = await renderApp([alice, bob, carol], '/friends', { signedInAs: alice });
for (const friend of [bob, carol]) api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByRole('heading', { name: 'bob' });
await user.type(screen.getByRole('textbox', { name: 'Сообщение' }), 'Черновик для bob');
api.messages.push({ id: crypto.randomUUID(), seq: 1, sender_id: carol.id, recipient: alice.id, text: 'Новое сообщение от carol', attachment_kind: 'none', created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByText('Новое сообщение от carol');
expect(screen.getByRole('heading', { name: 'bob' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Сообщение' })).toHaveValue('Черновик для bob');
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
const confirm = await screen.findByRole('dialog', { name: 'Заблокировать bob?' });
expect(api.friendships.size).toBe(2);
await user.click(within(confirm).getByRole('button', { name: 'Отмена' }));
expect(api.friendships.size).toBe(2);
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
await user.click(within(await screen.findByRole('dialog', { name: 'Заблокировать bob?' })).getByRole('button', { name: 'Заблокировать' }));
await waitFor(() => expect(api.friendships.size).toBe(1));
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
});
it('follows a room with locked playback controls, independent volume and local exit', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
const id = crypto.randomUUID();
api.listeningRooms.set(id, { id, owner_id: bob.id, owner_name: 'bob', device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, position_ms: 5000, playing: false, updated_at: new Date().toISOString(), participants: [] });
api.listeningMembers.set(alice.id, id); api.listeningMembers.set(bob.id, id);
refreshListening(session);
await screen.findByText('Слушаете вместе с bob');
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(player).getByRole('button', { name: 'Слушать' })).toBeDisabled());
expect(within(player).getByRole('slider', { name: 'Громкость' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument());
expect(api.listeningMembers.has(alice.id)).toBe(false);
});
it('edits, replies, reacts, searches, deletes and preserves a draft between sections', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: /bob Начать разговор/ }));
const composer = () => screen.getByRole('textbox', { name: 'Сообщение' });
await user.type(composer(), 'Первый вариант');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
await screen.findByText('Первый вариант');
const actions = async (name: string) => {
await user.click((await screen.findAllByRole('button', { name: 'Действия с сообщением' }))[0]!);
await user.click(await screen.findByRole('menuitem', { name }));
};
await actions('Редактировать');
await user.clear(composer()); await user.type(composer(), 'Исправленный текст');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByText('Исправленный текст', { exact: true });
await actions('❤️');
await screen.findByRole('button', { name: '❤️ 1' });
await actions('Ответить');
await user.type(composer(), 'Ответ на музыку');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(api.messages).toHaveLength(2));
expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id);
await user.click(screen.getByRole('button', { name: 'Поиск в переписке' }));
await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный');
await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument());
await actions('Удалить для всех');
await waitFor(() => expect(api.messages[0]?.deleted).toBe(true));
await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' }));
await user.type(composer(), 'Мой черновик');
await user.click(screen.getByRole('button', { name: 'Друзья' }));
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Написать' }));
expect(composer()).toHaveValue('Мой черновик');
});
it('saves privacy and independent request admission preferences', async () => {
const alice = makeUser('alice', 'user');
const { api, user } = await renderApp([alice], '/', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(await screen.findByRole('menuitem', { name: 'Моя музыка скрыта' }));
const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' });
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
await waitFor(() => expect(api.activityVisibility.get(alice.id)).toBe('friends'));
await user.click(screen.getByText('Слушать вместе', { exact: true }));
const requests = await screen.findByRole('group', { name: 'Принимать запросы и приглашения от' });
const auto = screen.getByRole('group', { name: 'Автоматически принимать от' });
await user.click(within(requests).getByRole('button', { name: 'Друзьям' }));
await waitFor(() => expect(api.listeningPreferences.get(alice.id)?.requests).toBe('friends'));
await waitFor(() => expect(within(auto).getByRole('button', { name: 'Всем' })).toBeEnabled());
await user.click(within(auto).getByRole('button', { name: 'Всем' }));
await waitFor(() => expect(api.listeningPreferences.get(alice.id)).toMatchObject({ requests: 'friends', auto_accept: 'everyone' }));
});
it('shows persistent notifications and acknowledges only the chosen event', async () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user');
const { api, user, queryClient, router } = await renderApp([alice, bob], '/', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(await screen.findByRole('menuitem', { name: 'Уведомления 1' }));
await screen.findByText('Хочет добавить вас в друзья');
await user.click(screen.getByRole('button', { name: 'Прочитано' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
expect(api.notifications[0]?.read).toBe(true);
await user.click(screen.getByRole('button', { name: 'Открыть: bob' }));
await screen.findByRole('button', { name: 'Принять' });
expect(router.state.location.pathname).toBe('/friends');
expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id });
});
it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user', { display_name: 'Борис' });
const { api, router, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
await user.click((await screen.findAllByRole('button', { name: 'Найти друзей' }))[0]!);
await user.type(await screen.findByLabelText('Найти по имени или логину'), 'bob');
await user.click(await screen.findByRole('button', { name: 'В друзья' }));
await screen.findByRole('button', { name: 'Отменить заявку' });
await user.click(screen.getByRole('button', { name: 'Отменить заявку' }));
await screen.findByRole('button', { name: 'В друзья' });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Принять' }));
await user.click(await screen.findByRole('button', { name: 'Написать' }));
await user.type(await screen.findByLabelText('Сообщение', { exact: true }), 'Привет, послушай это');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
expect(await screen.findByText('Привет, послушай это')).toBeInTheDocument();
expect(api.messages).toHaveLength(1);
await router.navigate({ to: '/track/$id', params: { id: albumTracks[0]!.id } });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(player).getByRole('button', { name: 'Поделиться треком' }));
await user.click(await screen.findByRole('button', { name: 'Борис · Отправить' }));
await screen.findByRole('button', { name: 'Борис · Отправлено' });
expect(api.messages.at(-1)).toMatchObject({ attachment_kind: 'track', attachment_id: albumTracks[0]!.id, recipient: bob.id });
expect(screen.getByRole('button', { name: 'Борис · Отправлено' })).toBeDisabled();
});
});
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
const api = fakeApi(users);
const i18n = await createI18n('ru');
@@ -27,7 +277,7 @@ async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: Fak
<App session={session} router={router} queryClient={queryClient} />
</I18nextProvider>,
);
return { api, router, session, i18n, user: userEvent.setup() };
return { api, router, session, i18n, queryClient, user: userEvent.setup() };
}
describe('sign-in', () => {
@@ -154,6 +404,39 @@ describe('users admin', () => {
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
});
it.each(['root', 'anna'])('lets admins change the username of %s', async (target) => {
const root = makeUser('root', 'admin');
const anna = makeUser('anna', 'user');
const { user, api, session } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
await user.click(await screen.findByRole('button', { name: `Действия с ${target}` }));
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
const input = within(dialog).getByLabelText('Логин');
await user.clear(input);
await user.type(input, `new.${target}`);
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(await screen.findByText(`@new.${target}`)).toBeInTheDocument();
expect(api.users.get(target === 'root' ? root.id : anna.id)?.username).toBe(`new.${target}`);
if (target === 'root') expect(session.state).toMatchObject({ status: 'signed_in', user: { username: 'new.root' } });
});
it('keeps the username dialog open when the new username is taken', async () => {
const root = makeUser('root', 'admin');
const anna = makeUser('anna', 'user');
const { user, api } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
await user.click(await screen.findByRole('button', { name: 'Действия с anna' }));
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
const input = within(dialog).getByLabelText('Логин');
await user.clear(input);
await user.type(input, 'ROOT');
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
expect(api.users.get(anna.id)?.username).toBe('anna');
expect(dialog).toBeInTheDocument();
});
it('offers moderators only what they may do', async () => {
const mod = makeUser('mod', 'moderator');
const anna = makeUser('anna', 'user');
@@ -248,6 +531,66 @@ describe('search box', () => {
});
describe('player', () => {
it.each(['album', 'artist'] as const)('toggles the current %s row without restarting or replacing the listening attempt', async (kind) => {
const alice = makeUser('alice', 'user');
const id = kind === 'artist' ? '00000000-0000-4000-8000-0000000000aa' : '00000000-0000-4000-8000-0000000000bb';
const { user, api } = await renderApp([alice], `/${kind}/${id}`, { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument());
await user.keyboard('{ArrowRight}');
const progress = within(bar).getByRole('slider', { name: 'Позиция' });
expect(progress).toHaveValue('10000');
await user.click(row);
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
expect(progress).toHaveValue('10000');
await user.click(row);
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
expect(progress).toHaveValue('10000');
await user.keyboard('{Enter}');
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
await user.keyboard('{Enter}');
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
expect(progress).toHaveValue('10000');
window.dispatchEvent(new Event('pagehide'));
await waitFor(() => {
const events = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events);
expect(events.filter((event) => event.type === 'play_start')).toHaveLength(1);
expect(events.find((event) => event.type === 'play_start')).toMatchObject({ context_type: kind });
expect(events.some((event) => event.type === 'skip')).toBe(false);
});
const other = screen.getAllByText('Группа крови').map((node) => node.closest('[role="listitem"]')).find(Boolean)!;
await user.click(other);
await waitFor(() => expect(within(bar).getByText('Группа крови')).toBeInTheDocument());
expect(progress).toHaveValue('0');
});
it('opens a shared track after login and copies its page URL from the player', async () => {
const alice = makeUser('alice', 'user');
const path = '/track/00000000-0000-4000-8000-000000000101';
const { user, router } = await renderApp([alice], path);
await screen.findByRole('heading', { name: 'Вход' });
await user.type(screen.getByLabelText('Логин'), alice.username);
await user.type(screen.getByLabelText('Пароль'), alice.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
expect(router.state.location.pathname).toBe(path);
await user.click(screen.getByRole('button', { name: 'Слушать' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
});
it('shows a missing shared track without starting playback', async () => {
const alice = makeUser('alice', 'user');
await renderApp([alice], '/track/missing', { signedInAs: alice });
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
});
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
@@ -350,3 +693,156 @@ describe('safeRedirect', () => {
expect(safeRedirect(input)).toBe(expected);
});
});
describe('playlists', () => {
it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('listitem')[0]!);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Из плеера');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(api.playlists.size).toBe(1));
expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
// Simulate automatic advancement while the picker is open.
await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
});
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
const alice = makeUser('alice', 'user');
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'В дорогу');
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'В дорогу' });
const id = [...api.playlists.keys()][0]!;
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
await router.navigate({ to: '/playlist/$id', params: { id } });
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
await user.clear(screen.getByLabelText('Название'));
await user.type(screen.getByLabelText('Название'), 'Вечер');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Вечер' });
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
await screen.findByRole('heading', { name: 'Плейлисты' });
expect(api.playlists.size).toBe(0);
});
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Первый');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Первый' });
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
const item = [...api.playlists.values()][0]!;
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
await user.type(screen.getByLabelText('Название'), ' изменён');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
});
});
describe('recommendation preferences', () => {
it('saves demographics, edits the age group and asks again after an admin reset', async () => {
const admin = makeUser('owner', 'admin');
const { user, api, router, session } = await renderApp([admin], '/settings', { signedInAs: admin });
await user.click(await screen.findByRole('button', { name: 'Настроить рекомендации' }));
await user.click(await screen.findByRole('button', { name: 'Мужской' }));
await user.click(screen.getByRole('button', { name: '13–19' }));
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
await screen.findByRole('button', { name: 'Настроить рекомендации' });
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toMatchObject({ gender: 'male', age_group: '13_19' });
await user.click(screen.getByRole('button', { name: 'Настроить рекомендации' }));
expect(await screen.findByRole('button', { name: '13–19' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: '20–29' }));
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
await screen.findByRole('button', { name: 'Настроить рекомендации' });
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: admin.id } } });
await router.navigate({ to: '/' });
await screen.findByRole('heading', { name: 'Что вы любите слушать?' });
expect(screen.getByRole('button', { name: 'Мужской' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '20–29' })).toHaveAttribute('aria-pressed', 'false');
await user.click(screen.getByRole('button', { name: 'Пропустить' }));
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
});
});
describe('playlist sharing', () => {
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
const alice = makeUser('alice', 'user');
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
const playlist = created.data!;
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await screen.findByRole('heading', { name: 'Общий микс' });
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Закрыть' }));
const shareId = api.playlists.get(playlist.id)!.share_id!;
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('heading', { name: 'Общий микс' });
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
await screen.findByRole('contentinfo', { name: 'Плеер' });
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('alert');
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
});
it('returns to the shared playlist after login without starting playback', async () => {
const bob = makeUser('bob', 'user');
const shareId = '00000000-0000-4000-9001-000000000001';
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
await screen.findByRole('heading', { name: 'Вход' });
await user.type(screen.getByLabelText('Логин'), bob.username);
await user.type(screen.getByLabelText('Пароль'), bob.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'От друга' });
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
});
});
+72 -9
View File
@@ -9,6 +9,8 @@ import {
} from '@tanstack/react-router';
import { lazy } from 'react';
const ServerUpdatesPage = lazy(() => import('../features/server-management/UpdatesPage').then((m) => ({ default: m.UpdatesPage })));
const ServerConfigPage = lazy(() => import('../features/server-management/ConfigPage').then((m) => ({ default: m.ConfigPage })));
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
@@ -18,9 +20,13 @@ const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
import type { DesktopHandle } from '../desktop';
import { AlbumPage } from '../pages/AlbumPage';
import { ConnectPage } from '../pages/ConnectPage';
import { TrackPage } from '../pages/TrackPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { ActivityPlaylistPage, PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage';
@@ -29,9 +35,12 @@ import { SettingsPage } from '../pages/SettingsPage';
import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError';
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
import { SocialPage } from '../features/social/Social';
export interface RouterContext {
session: AuthSession;
desktop: DesktopHandle | null;
}
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
@@ -50,9 +59,12 @@ export function safeRedirect(target: unknown): string {
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
}
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
const user = signedInUser(session);
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
if (!user) {
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
throw redirect({ to: '/login', search: { redirect: location.href } });
}
return user;
}
@@ -61,8 +73,26 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError,
});
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage });
const androidAppRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/android',
beforeLoad: () => {
throw redirect({ to: '/download' });
},
component: AppDownloadPage,
});
const connectRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/connect',
beforeLoad: ({ context }) => {
if (!context.desktop) throw redirect({ to: '/login' });
if (context.desktop.server) throw redirect({ to: '/login' });
},
component: ConnectPage,
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -70,6 +100,7 @@ const loginRoute = createRoute({
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
beforeLoad: ({ context, search }) => {
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
const user = signedInUser(context.session);
if (!user) return;
const home = homeFor(user);
@@ -83,7 +114,7 @@ const setupAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/admin',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const user = requireUser(context.session, location, context.desktop);
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="admin" />,
@@ -93,7 +124,7 @@ const setupPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/password',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const user = requireUser(context.session, location, context.desktop);
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="password" />,
@@ -104,7 +135,7 @@ const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const user = requireUser(context.session, location, context.desktop);
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
return { user };
@@ -117,6 +148,15 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string; tab?: 'messages' | 'people' | 'requests' } => ({ ...(typeof s.peer === 'string' ? { peer: s.peer } : {}), ...(s.tab === 'messages' || s.tab === 'people' || s.tab === 'requests' ? { tab: s.tab } : {}) }), component: () => { const search = socialRoute.useSearch(); return <SocialPage peer={search.peer} initialTab={search.tab} />; } });
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
const downloadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/downloads', component: DesktopDownloadsPage,
beforeLoad: ({ context }) => { if (!context.desktop) throw redirect({ to: '/settings' }); },
});
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
@@ -239,13 +279,30 @@ const adminSettingsRoute = createRoute({
component: AdminSettingsPage,
});
const serverUpdatesRoute = createRoute({
getParentRoute: () => appRoute, path: '/admin/updates', component: ServerUpdatesPage,
beforeLoad: ({ context }) => {
if (context.user.role !== 'admin' && !context.user.manages_updates) throw redirect({ to: '/' });
},
});
const serverConfigRoute = createRoute({ getParentRoute: () => appRoute, path: '/admin/config', beforeLoad: adminOnly, component: ServerConfigPage });
const routeTree = rootRoute.addChildren([
downloadAppRoute,
androidAppRoute,
connectRoute,
loginRoute,
setupAdminRoute,
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
activityPlaylistRoute,
socialRoute,
trackRoute,
playlistsRoute,
downloadsRoute,
sharedPlaylistRoute,
playlistRoute,
settingsRoute,
searchRoute,
historyRoute,
@@ -262,11 +319,17 @@ const routeTree = rootRoute.addChildren([
myUploadsRoute,
uploadQueueRoute,
adminSettingsRoute,
serverUpdatesRoute,
serverConfigRoute,
]),
]);
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
export function createAppRouter(
session: AuthSession,
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
) {
const { desktop = null, ...rest } = opts;
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
session.subscribe(() => void router.invalidate());
return router;
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthSession, type AuthUser } from '@pipu/core';
import { createDesktop, desktopFetch, isDesktop } from './desktop';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('desktop bridge', () => {
it('restores the last desktop user when the proxy cannot reach the server', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
const user = { id: 'alice', username: 'alice' } as AuthUser;
const clear = vi.fn();
const session = new AuthSession({ clientType: 'desktop', fetch: desktopFetch, baseUrl: 'http://local', refreshStore: {
get: async () => 'refresh', set: vi.fn(), clear, getUser: async () => user,
} });
expect(await session.restore()).toEqual({ status: 'signed_in', offline: true, user });
expect(clear).not.toHaveBeenCalled();
});
it('reports a failed proxy hop as a network failure, preserving real HTTP errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
await expect(desktopFetch('http://local/api')).rejects.toThrow('Server unavailable');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('denied', { status: 401 })));
expect((await desktopFetch('http://local/api')).status).toBe(401);
});
it('is inactive in the browser', () => {
expect(isDesktop()).toBe(false);
});
it('bootstraps through the Tauri invoke surface', async () => {
const invoke = vi.fn(async (cmd: string) => {
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
if (cmd === 'set_server') return 'https://music.example/pipu';
return null;
});
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
const desktop = await createDesktop();
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
expect(desktop?.server).toBe('https://music.example');
await desktop?.setServer('https://music.example/pipu');
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
});
});
+102
View File
@@ -0,0 +1,102 @@
import type { AuthUser, RefreshStore } from '@pipu/core';
import { createContext, createElement, useContext, type ReactNode } from 'react';
type Tauri = {
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
};
function api(): Tauri | undefined {
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
}
export function isDesktop(): boolean {
return api() !== undefined;
}
/** A failed loopback hop must behave like a network failure to offline auth restore. */
export const desktopFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (response.headers.get('x-pipu-transport-error') === '1') throw new TypeError('Server unavailable');
return response;
};
export function desktopInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const tauri = api();
if (!tauri) throw new Error('Tauri API unavailable');
return tauri.core.invoke(cmd, args) as Promise<T>;
}
const invoke = desktopInvoke;
export type DesktopHandle = {
proxyBase: string;
server: string | null;
setServer: (url: string) => Promise<string>;
clearServer: () => Promise<void>;
refreshStore: RefreshStore;
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
};
export const DesktopContext = createContext<DesktopHandle | null>(null);
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
return createElement(DesktopContext.Provider, { value }, children);
}
export function useDesktop(): DesktopHandle | null {
return useContext(DesktopContext);
}
let active: DesktopHandle | null = null;
export function desktopHandle(): DesktopHandle | null {
return active;
}
export async function createDesktop(): Promise<DesktopHandle | null> {
if (!isDesktop()) return null;
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
const handle: DesktopHandle = {
proxyBase: boot.proxyBase,
server: boot.server,
setServer: async (url) => {
const server = await invoke<string>('set_server', { url });
handle.server = server;
return server;
},
clearServer: async () => {
await invoke('clear_server');
handle.server = null;
},
refreshStore: {
get: () => invoke<string | null>('token_get'),
set: (token) => invoke('token_set', { token }),
clear: async () => {
await invoke('token_clear');
await invoke('user_clear');
},
getUser: async () => {
const json = await invoke<string | null>('user_get');
return json ? (JSON.parse(json) as AuthUser) : null;
},
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
},
listenMedia: async (handler) => {
const tauri = api();
if (!tauri) return () => undefined;
return tauri.event.listen('media', (e) => handler(e.payload));
},
setMedia: (info) => {
void invoke('media_set', {
title: info.title,
artist: info.artist,
album: info.album,
coverUrl: info.coverUrl ?? null,
playing: info.playing,
});
},
};
active = handle;
return handle;
}
+58 -2
View File
@@ -8,12 +8,18 @@
color: rgba(255, 255, 255, 0.85);
}
.cover img {
.coverImg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease;
}
.coverImgOn {
opacity: 1;
}
.round {
@@ -59,10 +65,52 @@
width: 128px;
}
.cardCoverWrap {
position: relative;
margin-bottom: 4px;
overflow: hidden;
border-radius: 12px;
}
.cardCover {
aspect-ratio: 1;
border-radius: 12px;
margin-bottom: 4px;
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card:hover .cardCover,
.card:focus-within .cardCover {
transform: scale(1.04);
}
.cardPlay {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 1;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
color: #05070d;
opacity: 0;
transform: translateY(6px);
transition:
opacity 0.18s,
transform 0.18s;
}
.card:hover .cardPlay,
.card:focus-within .cardPlay,
.cardPlay:focus-visible {
opacity: 1;
transform: none;
}
.cardPlay:active {
transform: scale(0.94);
}
.cardTitle {
@@ -104,6 +152,12 @@
min-height: 56px;
padding: 6px 12px;
border-radius: 10px;
animation: pipuIn 0.2s ease-out both;
animation-delay: var(--stagger, 0ms);
}
.trackStatic {
animation: none;
}
.track:has(.trackAlbum) {
@@ -272,9 +326,11 @@
}
@media (max-width: 767px) {
.cardPlay { opacity: 1; transform: none; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
.end { gap: 4px; }
.like { opacity: 1; width: 44px; height: 44px; }
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
}
+61 -14
View File
@@ -1,13 +1,17 @@
import type { components } from '@pipu/api-client';
import { Link } from '@tanstack/react-router';
import { unwrap } from '@pipu/core';
import { Link, useNavigate } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef } from 'react';
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists';
import { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
import { albumIcon, likeFillIcon, likeIcon, pauseIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css';
@@ -27,6 +31,7 @@ function fallbackColors(id: string): string[] {
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string {
if (!url.includes('/api/v1/covers/')) return url;
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`;
@@ -57,7 +62,7 @@ export function Cover({
aria-hidden
>
{album.cover_url ? (
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
<CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)}
@@ -66,25 +71,58 @@ export function Cover({
}
/** Исполнители через запятую, каждый — ссылка. */
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
export function ArtistLinks({ artists, hideUrlPreview = false }: { artists: ArtistRef[]; hideUrlPreview?: boolean }) {
const navigate = useNavigate();
return (
<>
{artists.map((a, i) => (
<Fragment key={a.id}>
{i > 0 && ', '}
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
{hideUrlPreview ? <button type="button" role="link" className={s.link}
onClick={() => void navigate({ to: '/artist/$id', params: { id: a.id } })}>
{a.name}
</Link>
</button> : <Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
{a.name}
</Link>}
</Fragment>
))}
</>
);
}
function CoverImg({ src }: { src: string }) {
const [ready, setReady] = useState(false);
return (
<img
src={src}
alt=""
loading="lazy"
className={cx(s.coverImg, ready && s.coverImgOn)}
onLoad={() => setReady(true)}
/>
);
}
export function AlbumCard({ album }: { album: AlbumSummary }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const play = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
void (async () => {
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
})();
};
return (
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
<Cover album={album} size="100%" className={s.cardCover} />
<span className={s.cardCoverWrap}>
<Cover album={album} size="100%" className={s.cardCover} />
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
<Icon src={playIcon} size={22} />
</button>
</span>
<span className={s.cardTitle}>{album.title}</span>
<span className={s.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
@@ -105,7 +143,7 @@ export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
/**
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры.
* Клик или Enter запускает список с выбранного трека; у текущего трека переключает паузу без перезапуска.
*/
export function TrackList({
tracks,
@@ -115,6 +153,7 @@ export function TrackList({
rowMeta,
lead,
disliked,
actions,
}: {
tracks: Track[];
showAlbum?: boolean;
@@ -126,6 +165,7 @@ export function TrackList({
lead?: (t: Track, i: number) => string;
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
disliked?: boolean;
actions?: (track: Track, index: number) => ReactNode;
}) {
const { t } = useTranslation();
const player = usePlayer();
@@ -143,7 +183,11 @@ export function TrackList({
const row = (tr: Track, i: number) => {
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const play = () => {
if (!tr.available) return;
if (isCurrent) player.toggle();
else player.playList(tracks, i, context);
};
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
@@ -151,10 +195,11 @@ export function TrackList({
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play();
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
@@ -165,7 +210,7 @@ export function TrackList({
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
<Icon src={isCurrent && playing ? pauseIcon : playIcon} size={20} />
</span>
)}
</span>
@@ -181,7 +226,9 @@ export function TrackList({
{tr.album.title}
</Link>
)}
<span className={s.end}>
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
{!actions && <SaveOfflineButton track={tr} compact />}
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
{disliked ? (
<button
type="button"
+101
View File
@@ -0,0 +1,101 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { playlistAddIcon } from '../shared/ui/icons';
import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist'];
export type PlaylistInput = components['schemas']['PlaylistInput'];
export function usePlaylists(enabled = true) {
const session = useSession();
return useQuery({ queryKey: ['playlists'], enabled,
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
}
export function usePlaylist(id: string, shared = false) {
const session = useSession();
const user = useUser();
return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
queryFn: async () => shared
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
: unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
}
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
}
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
}) {
const { t } = useTranslation();
const session = useSession();
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) ?? trackIds };
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 => {
cache.setQueryData(['playlists', p.id], p);
void cache.invalidateQueries({ queryKey: ['playlists'] });
onSaved(p);
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
</form>
</Dialog>;
}
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const errorText = useErrorText();
const list = usePlaylists();
const [create, setCreate] = useState(false);
const add = useMutation({ mutationFn: async (id: string) => {
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
const body = playlistInput(p);
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
<div style={{ display: 'grid', gap: 8 }}>
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div>
</Dialog>;
});
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
const { t } = useTranslation();
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
const close = useCallback(() => setSelectedIds(null), []);
return <>
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
</>;
}
@@ -0,0 +1,147 @@
import { releaseNotesText, type Track } from '@pipu/core';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession } from '../../app/session';
import { desktopInvoke, useDesktop } from '../../desktop';
import { TrackList } from '../../entities/catalog';
import { usePlayer, usePlayerState } from '../../player/context';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import { formatBytes } from '../../shared/lib/format';
import { checkIcon, downloadIcon } from '../../shared/ui/icons';
import s from '../../pages/page.module.css';
export function useOffline() {
const offline = usePlayer().offline;
useSyncExternalStore(offline?.subscribe ?? (() => () => undefined), offline?.snapshot ?? (() => 0));
return offline;
}
export function SaveOfflineButton({ track, compact = false }: { track: Track; compact?: boolean }) {
const offline = useOffline();
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
if (!offline) return null;
return <span>
<Button busy={busy} disabled={!track.available} icon={offline.pinned(track.id) ? checkIcon : downloadIcon}
aria-label={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}
title={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')} onClick={() => {
setBusy(true); setError(false);
void (offline.pinned(track.id) ? offline.remove(track.id) : offline.save(track, true))
.catch(() => setError(true)).finally(() => setBusy(false));
}}>{compact ? null : t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}</Button>
{error && <span role="alert">{t('desktop.download_failed')}</span>}
</span>;
}
export function DesktopOfflineSection() {
const offline = useOffline();
const { track } = usePlayerState();
const { t, i18n } = useTranslation();
const [error, setError] = useState(false);
useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]);
if (!offline) return null;
const tracks = offline.tracks();
return <section className={s.section}>
<h2>{t('desktop.offline')}</h2>
<p className={s.note}>{t('desktop.cache_hint')}</p>
<div className={s.toolbar}>
<span>{formatBytes(offline.bytes, i18n.language)}</span>
<Button onClick={() => void offline.clearRecent().catch(() => setError(true))}>{t('desktop.clear_recent')}</Button>
{track && <SaveOfflineButton track={track} />}
</div>
<FormError>{error ? t('desktop.storage_failed') : null}</FormError>
{tracks.length > 0 ? <TrackList tracks={tracks} context={{ type: 'collection', id: 'offline' }} actions={(track) => <SaveOfflineButton track={track} compact />} /> : <p>{t('desktop.offline_empty')}</p>}
</section>;
}
export function DesktopDownloadsPage() {
const { t } = useTranslation();
return <div className={s.page}><h1>{t('desktop.offline')}</h1><DesktopOfflineSection /></div>;
}
export function DesktopOfflineBanner() {
const state = useAuthState();
const desktop = useDesktop();
const { t } = useTranslation();
return desktop && state.status === 'signed_in' && state.offline ? <p role="status">{t('mobile.offline')}</p> : null;
}
type Release = { build: { version: string; size: number }; notes: string };
type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number };
let update: Update = { status: 'idle' };
let generation = 0;
const listeners = new Set<() => void>();
function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); }
const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; };
function useUpdate() { return useSyncExternalStore(subscribe, () => update); }
async function checkUpdate(server: string, manual = false) {
if (update.status === 'checking' || update.status === 'installing') return;
const key = `pipu.update.checked.${server}`;
if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return;
setUpdate({ status: 'checking' });
const requestGeneration = generation;
try {
const release = await desktopInvoke<Release | null>('update_check');
if (requestGeneration !== generation) return;
localStorage.setItem(key, String(Date.now()));
setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' });
} catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); }
}
export function DesktopUpdateButton() {
const desktop = useDesktop();
const state = useUpdate();
const { t } = useTranslation();
if (!desktop?.server) return null;
return <section className={s.section}>
<h2>{t('desktop.updates')}</h2>
<Button busy={state.status === 'checking'} disabled={state.status === 'installing'} onClick={() => void checkUpdate(desktop.server!, true)}>{t('mobile.update_check')}</Button>
{state.status === 'latest' && <p role="status">{t('mobile.update_latest')}</p>}
{state.status === 'error' && !state.release && <FormError>{t('desktop.check_failed')}</FormError>}
</section>;
}
export function DesktopUpdateDialog() {
const desktop = useDesktop();
const session = useSession();
const state = useUpdate();
const { t } = useTranslation();
useEffect(() => {
if (!desktop?.server) return;
const check = () => void checkUpdate(desktop.server!);
check();
window.addEventListener('focus', check);
return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); };
}, [desktop, session]);
useEffect(() => {
if (!desktop) return;
let stop: (() => void) | undefined;
let disposed = false;
// The native updater emits progress through the same Tauri event surface as media controls.
const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__;
void api?.event.listen('update-progress', (e) => {
const progress = Number(e.payload);
if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) });
}).then((fn) => { if (disposed) fn(); else stop = fn; });
return () => { disposed = true; stop?.(); };
}, [desktop]);
if (!desktop || !state.release || state.dismissed) return null;
const release = state.release;
const busy = state.status === 'installing';
const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); };
const notes = releaseNotesText(release.notes);
return <Dialog title={t('mobile.update_title', { version: release.build.version })} onClose={close} footer={<>
<Button disabled={busy} onClick={close}>{t('mobile.update_later')}</Button>
<Button busy={busy} onClick={() => {
setUpdate({ status: 'installing', release, progress: 0 });
void desktopInvoke('update_install').then(() => setUpdate({ status: 'ready', release })).catch(() => setUpdate({ status: 'error', release }));
}}>{t('mobile.update_install')}</Button>
</>}>
{notes && <p style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '35vh', overflowY: 'auto' }}>{notes}</p>}
<p>{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}</p>
{busy && <p role="status">{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}</p>}
{state.status === 'ready' && <p role="status">{t('desktop.installer_open')}</p>}
{state.status === 'error' && <FormError>{t('mobile.update_failed')}</FormError>}
</Dialog>;
}
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AuthSession, PlayEvent } from '@pipu/core';
import { desktopEventSink, evictRecent, offlineOwner } from './offline';
import { createDesktop } from '../../desktop';
import { albumTracks } from '../../test/fakeApi';
afterEach(() => {
localStorage.clear();
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
});
describe('desktop cache policy', () => {
it('evicts oldest recent files while keeping manual downloads outside the limit', () => {
const entry = (id: string, used: number, pinned = false) => ({ id, used, pinned, track: albumTracks[0]!, audio: { size: 60 } as Blob });
expect(evictRecent([entry('new', 3), entry('manual', 0, true), entry('old', 1), entry('middle', 2)], 100)).toEqual(['old', 'middle']);
});
});
it('persists offline events across sink restarts and deduplicates retries', async () => {
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke: async () => ({ proxyBase: 'http://local', server: 'https://music.example' }) } };
await createDesktop();
const post = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue({ data: {}, response: new Response('{}') });
const session = { state: { status: 'signed_in', user: { id: 'alice' } }, client: { POST: post } } as unknown as AuthSession;
const owner = offlineOwner(session)!;
const event: PlayEvent = { id: 'one', type: 'play_start', occurred_at: new Date().toISOString(), track_id: 'track' };
await expect(desktopEventSink(session, owner)([event])).rejects.toThrow('offline');
await desktopEventSink(session, owner)([event]);
expect(post.mock.calls[1]?.[1].body.events).toEqual([event]);
expect(JSON.parse(localStorage.getItem(`pipu.events.${owner}`)!)).toEqual([]);
});
+181
View File
@@ -0,0 +1,181 @@
import { unwrap, type AuthSession, type PlayEvent, type Track } from '@pipu/core';
import { desktopHandle } from '../../desktop';
export const RECENT_LIMIT = 500 * 1024 * 1024;
type Entry = { id: string; track: Track; audio: Blob; cover?: Blob; pinned: boolean; used: number };
const size = (e: Entry) => e.audio.size + (e.cover?.size ?? 0);
export function evictRecent(entries: Entry[], limit = RECENT_LIMIT): string[] {
let total = entries.filter((e) => !e.pinned).reduce((n, e) => n + size(e), 0);
const removed: string[] = [];
for (const e of entries.filter((e) => !e.pinned).sort((a, b) => a.used - b.used)) {
if (total <= limit) break;
removed.push(e.id);
total -= size(e);
}
return removed;
}
export function offlineOwner(session: AuthSession): string | null {
const desktop = desktopHandle();
return desktop?.server && session.state.status === 'signed_in'
? JSON.stringify([desktop.server, session.state.user.id]) : null;
}
/** WebView IndexedDB is persistent across desktop restarts; signed URLs are never stored. */
export class DesktopOffline {
private db: Promise<IDBDatabase> | null = null;
private loaded: Promise<void> | null = null;
private entries: Entry[] = [];
private urls = new Map<string, Track>();
private objects = new Map<string, string[]>();
private inUse = new Set<string>();
private listeners = new Set<() => void>();
private revision = 0;
private writes: Promise<unknown> = Promise.resolve();
private disposed = false;
get ready(): Promise<void> {
this.loaded ??= this.reload();
return this.loaded;
}
activate() { this.disposed = false; }
constructor(readonly owner: string, private session: AuthSession) {}
private database(): Promise<IDBDatabase> {
return this.db ??= new Promise((resolve, reject) => {
const request = indexedDB.open(`pipu.offline.${this.owner}`, 1);
request.onupgradeneeded = () => request.result.createObjectStore('tracks', { keyPath: 'id' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
snapshot = () => this.revision;
private async reload() {
const db = await this.database();
const entries = await new Promise<Entry[]>((resolve, reject) => {
const req = db.transaction('tracks').objectStore('tracks').getAll();
req.onsuccess = () => resolve(req.result as Entry[]);
req.onerror = () => reject(req.error);
});
if (this.disposed) return;
this.entries = entries;
for (const e of entries) {
if (this.urls.has(e.id)) continue;
const audio = URL.createObjectURL(e.audio);
const cover = e.cover ? URL.createObjectURL(e.cover) : null;
this.objects.set(e.id, [...(this.objects.get(e.id) ?? []), audio, ...(cover ? [cover] : [])]);
this.urls.set(e.id, { ...e.track, stream_url: audio, available: true, album: { ...e.track.album, cover_url: cover } });
}
for (const id of this.urls.keys()) if (!entries.some((e) => e.id === id)) {
this.urls.delete(id);
if (!this.inUse.has(id)) this.release(id);
}
this.revision++;
for (const fn of this.listeners) fn();
}
tracks() { return this.entries.map((e) => this.local(e.track)); }
private release(id: string) {
for (const url of this.objects.get(id) ?? []) URL.revokeObjectURL(url);
this.objects.delete(id);
}
/** Keep only files currently playing/crossfading alive after cache deletion. */
keep(ids: string[]) {
this.inUse = new Set(ids);
for (const id of this.objects.keys()) if (!this.inUse.has(id) && !this.urls.has(id)) this.release(id);
}
local(track: Track): Track { return this.urls.get(track.id) ?? track; }
has(id: string) { return this.entries.some((e) => e.id === id); }
pinned(id: string) { return this.entries.some((e) => e.id === id && e.pinned); }
get bytes() { return this.entries.reduce((n, e) => n + size(e), 0); }
private mutate(fn: (store: IDBObjectStore) => void): Promise<void> {
const work = this.writes.catch(() => undefined).then(async () => {
const db = await this.database();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('tracks', 'readwrite');
fn(tx.objectStore('tracks'));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
await this.reload();
});
this.writes = work;
return work;
}
private downloads = new Map<string, Promise<void>>();
private downloadTail: Promise<unknown> = Promise.resolve();
async save(track: Track, pinned = false): Promise<void> {
await this.ready;
const pending = this.downloads.get(track.id);
if (pending) { await pending; return this.save(track, pinned); }
const existing = this.entries.find((e) => e.id === track.id);
if (existing) {
await this.mutate((store) => {
const latest = this.entries.find((e) => e.id === track.id);
if (latest) store.put({ ...latest, pinned: latest.pinned || pinned, used: Date.now() });
});
return;
}
const job = this.downloadTail.catch(() => undefined).then(() => this.download(track, pinned));
this.downloadTail = job;
this.downloads.set(track.id, job);
try { await job; } finally { this.downloads.delete(track.id); }
}
private async download(track: Track, pinned: boolean) {
if (this.disposed) return;
const fresh = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })).items[0];
if (!fresh?.available || !fresh.stream_url) throw new Error('Track unavailable');
const response = await fetch(fresh.stream_url);
if (!response.ok) throw new Error('Download failed');
const audio = await response.blob();
if (!audio.size) throw new Error('Empty download');
let cover: Blob | undefined;
if (fresh.album.cover_url) {
try {
const response = await fetch(`${fresh.album.cover_url}&size=300`);
if (response.ok) cover = await response.blob();
} catch { /* Artwork is optional. */ }
}
if (this.disposed) return;
const entry: Entry = { id: track.id, track: { ...fresh, stream_url: null, album: { ...fresh.album, cover_url: null } }, audio, cover, pinned, used: Date.now() };
await this.mutate((store) => {
const removed = evictRecent([...this.entries.filter((e) => e.id !== entry.id), entry]);
for (const id of removed) store.delete(id);
if (!removed.includes(entry.id)) store.put(entry);
});
}
async remove(id: string) { await this.mutate((store) => store.delete(id)); }
async clearRecent() { await this.mutate((store) => { for (const e of this.entries) if (!e.pinned) store.delete(e.id); }); }
dispose() {
this.disposed = true;
queueMicrotask(() => {
if (!this.disposed) return;
for (const id of this.objects.keys()) this.release(id);
void this.downloadTail.catch(() => undefined).then(() => this.writes)
.finally(() => this.db?.then((db) => db.close())).catch(() => undefined);
});
}
}
/** Persist before sending; retries keep event IDs for server-side deduplication. */
export function desktopEventSink(session: AuthSession, owner: string) {
const key = `pipu.events.${owner}`;
let sending = Promise.resolve();
return async (events: PlayEvent[]) => {
const work = sending.catch(() => undefined).then(async () => {
const old = JSON.parse(localStorage.getItem(key) ?? '[]') as PlayEvent[];
const merged = [...new Map([...old, ...events].map((e) => [e.id, e])).values()];
localStorage.setItem(key, JSON.stringify(merged));
if (offlineOwner(session) !== owner) return;
while (merged.length) {
if (offlineOwner(session) !== owner) return;
const batch = merged.slice(0, 100);
unwrap(await session.client.POST('/api/v1/events', { body: { events: batch } }));
merged.splice(0, batch.length);
localStorage.setItem(key, JSON.stringify(merged));
}
});
sending = work;
await work;
};
}
@@ -0,0 +1,95 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError, PageSkeleton } from '../../shared/ui/ui';
import { JobPanel } from './JobPanel';
import s from './management.module.css';
export function ConfigPage() {
const { t } = useTranslation('admin');
const session = useSession();
const errorText = useErrorText();
const qc = useQueryClient();
const [draft, setDraft] = useState<Record<string, string | null>>({});
const [baseRevision, setBaseRevision] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [group, setGroup] = useState('all');
const [confirm, setConfirm] = useState(false);
const [saved, setSaved] = useState(false);
const dirty = Object.keys(draft).length > 0;
const query = useQuery({ queryKey: ['server-config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/config')), retry: false });
const status = useQuery({ queryKey: ['server-updates', 'config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates')), refetchInterval: 3000, retry: false });
const running = status.data?.job?.state === 'running';
useEffect(() => {
if (!dirty) return;
const prevent = (e: BeforeUnloadEvent) => { e.preventDefault(); };
window.addEventListener('beforeunload', prevent);
return () => window.removeEventListener('beforeunload', prevent);
}, [dirty]);
useEffect(() => {
if (status.data?.job?.state === 'succeeded') void qc.invalidateQueries({ queryKey: ['server-config'] });
}, [status.data?.job?.id, status.data?.job?.state, qc]);
const save = useMutation({
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/server/config', { body: { revision: baseRevision ?? query.data!.revision, values: draft } })),
onSuccess: (data) => { qc.setQueryData(['server-config'], data); setDraft({}); setBaseRevision(null); setSaved(true); },
});
const apply = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/config/apply')),
onSuccess: (job) => {
setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', 'config'], (old) => old ? { ...old, job } : old);
void qc.invalidateQueries({ queryKey: ['server-updates'] });
},
});
const change = (key: string, value: string | null) => {
const entry = query.data!.entries.find((v) => v.key === key)!;
const unchanged = value === null ? entry.local === null
: entry.secret ? value === '' : value === (entry.local ?? '');
const next = { ...draft };
if (unchanged) delete next[key]; else next[key] = value;
setBaseRevision(Object.keys(next).length ? baseRevision ?? query.data!.revision : null);
setDraft(next); setSaved(false); save.reset();
};
if (query.isPending) return <PageSkeleton />;
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
return <div className={s.page}>
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.config')}</h1><p>{t('management.config_intro')}</p></div></header>
{query.error && <FormError>{errorText(query.error)}</FormError>}
{query.data && <>
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.priority')}</span><strong className={s.priority}>ENV → config.local → {t('management.defaults')}</strong></div><p>{t('management.priority_hint')}</p></section>
<div className={s.toolbar}><Field type="search" label={t('management.search')} value={search} onChange={setSearch} /><label className={s.selectLabel}>{t('management.group')}<select value={group} onChange={(e) => setGroup(e.target.value)}><option value="all">{t('management.all')}</option>{[...new Set(query.data.entries.map((v) => v.group))].map((v) => <option key={v} value={v}>{t(`management.groups.${v}`, { defaultValue: v })}</option>)}</select></label></div>
<p className={s.note}>{t('management.config_files_hint')}</p>
<div className={s.configList}>{entries.map((entry) => {
const changed = Object.hasOwn(draft, entry.key);
const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? '';
const display = (stored: string | null) => entry.secret && stored ? '••••••••' : stored;
return <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
<div className={s.values}><span>{t('management.effective')}: <code>{display(entry.effective) || '—'}</code></span><span>ENV: <code>{display(entry.env) ?? '—'}</code></span></div>
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} autoComplete={entry.secret ? 'new-password' : 'off'} value={value} disabled={running || save.isPending} placeholder={entry.secret ? entry.local ? t('management.secret_saved') : '' : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
</div>}
</section>;
})}</div>
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
{saved && !dirty && <p className={s.note} role="status">{t('management.saved')}</p>}
{dirty && <section className={s.saveBar} aria-label={t('management.unsaved_panel')}>
<div role="status">{t('management.unsaved', { count: Object.keys(draft).length })}</div>
<div className={s.actions}><Button disabled={save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button></div>
</section>}
{!dirty && query.data.restart_required && !running && <section className={s.saveBar} aria-label={t('management.pending_restart')}>
<div role="status">{t('management.pending_restart')}</div>
<Button busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button>
</section>}
{save.error && <FormError>{errorText(save.error)}</FormError>}
{apply.error && <FormError>{errorText(apply.error)}</FormError>}
<JobPanel job={status.data?.job} reconnecting={status.isError} />
</>}
{confirm && <Dialog title={t('management.apply')} onClose={() => !apply.isPending && setConfirm(false)}><p>{t('management.apply_warning')}</p>{apply.error && <FormError>{errorText(apply.error)}</FormError>}<div className={s.actions}><Button disabled={apply.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={apply.isPending} onClick={() => apply.mutate()}>{t('management.apply')}</Button></div></Dialog>}
</div>;
}
@@ -0,0 +1,33 @@
import type { components } from '@pipu/api-client';
import { useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import s from './management.module.css';
export type Job = components['schemas']['ServerJob'];
export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; reconnecting?: boolean }) {
const { t } = useTranslation('admin');
const logRef = useRef<HTMLPreElement>(null);
const followLog = useRef(true);
useLayoutEffect(() => {
followLog.current = true;
}, [job?.id]);
useLayoutEffect(() => {
const log = logRef.current;
if (log && followLog.current) log.scrollTop = log.scrollHeight;
}, [job?.id, job?.log]);
if (!job) return null;
return <section className={s.card} aria-label={t('management.job')}>
<div className={s.cardHead}>
<div><span className={s.eyebrow}>{t('management.job')}</span><h2>{job.target}</h2></div>
<span className={s.status} data-state={job.state} role="status">{t(`management.state.${job.state}`)}</span>
</div>
<p className={s.note}>{t('management.started')}: {new Date(job.started_at).toLocaleString()}
{job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}</>}</p>
{job.state === 'running' && <p className={s.note}>{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}</p>}
<pre ref={logRef} className={s.log} tabIndex={0} aria-label={t('management.log')} onScroll={(event) => {
const log = event.currentTarget;
followLog.current = log.scrollHeight - log.clientHeight - log.scrollTop <= 4;
}}>{job.log || t('management.waiting')}</pre>
</section>;
}
@@ -0,0 +1,109 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError, PageSkeleton, Segmented } from '../../shared/ui/ui';
import { JobPanel } from './JobPanel';
import s from './management.module.css';
type Source = components['schemas']['ServerSource'];
type Channel = components['schemas']['ServerUpdateRequest']['channel'];
export function UpdatesPage() {
const { t } = useTranslation('admin');
const session = useSession();
const qc = useQueryClient();
const errorText = useErrorText();
const [sourceId, setSourceId] = useState('');
const [channel, setChannel] = useState<Channel>('latest');
const [version, setVersion] = useState('');
const [confirm, setConfirm] = useState(false);
const [editing, setEditing] = useState<Source[] | null>(null);
const fresh = useRef(false);
const queryKey = ['server-updates', sourceId, channel, version];
const query = useQuery({
queryKey,
queryFn: async () => {
const force = fresh.current;
fresh.current = false;
return unwrap(await session.client.GET('/api/v1/admin/server/updates', {
params: { query: { source_id: sourceId || undefined, channel, version: channel === 'version' ? version : undefined, fresh: force } },
}));
},
placeholderData: keepPreviousData,
refetchInterval: (q) => q.state.data?.job?.state === 'running' ? 3000 : 60000,
retry: false,
});
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
const running = query.data?.job?.state === 'running';
const checking = query.isFetching;
const availability = query.isError ? 'unavailable' : query.data?.update_state || 'unknown';
const canInstall = availability === 'available' && !checking && !query.isPlaceholderData && !query.isError && !running;
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
const save = useMutation({
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
onSuccess: () => { setEditing(null); setSourceId(''); void refresh(); },
});
const start = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined, expected_commit: query.data!.target_commit },
})),
onSuccess: (job) => {
setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(queryKey, (old) => old ? { ...old, job } : old);
void refresh();
},
onError: () => { void refresh(); },
});
if (query.isPending) return <PageSkeleton />;
return <div className={s.page}>
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
<Button onClick={() => { fresh.current = true; void query.refetch(); }} busy={query.isFetching}>{t('management.check_updates')}</Button>
</header>
{query.error && <FormError>{errorText(query.error)}</FormError>}
{query.data && <>
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.current')}</span><strong>{query.data.current_version || '—'}</strong></div><p>{t('management.manual')}</p></section>
<section className={s.card}>
<div className={s.cardHead}><h2>{t('management.update_target')}</h2><Button disabled={running} onClick={() => { save.reset(); setEditing(query.data.sources.map((v) => ({ ...v }))); }}>{t('management.sources')}</Button></div>
<label className={s.selectLabel}>{t('management.source')}
<select aria-label={t('management.source')} value={source?.id ?? ''} disabled={running || start.isPending} onChange={(e) => { setSourceId(e.target.value); setVersion(''); start.reset(); }}>
{query.data.sources.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</label>
<p className={s.url}>{source?.url}</p>
<Segmented label={t('management.channel')} value={channel} options={(['latest', 'version', 'main'] as const).map((value) => ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} />
<p className={s.note}>{t(`management.hint_${channel}`)}</p>
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
{start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.updateAction}>
<div role="status" aria-live="polite">
<strong>{t(`management.availability.${checking ? 'checking' : running ? 'running' : availability}`)}</strong>
{query.data.target_version && <p className={s.note}>{query.data.target_version} · {query.data.target_commit.slice(0, 12)}</p>}
</div>
{canInstall && <Button variant="primary" disabled={!source || !!query.data.source_error} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button>}
</div>
</section>
<JobPanel job={query.data.job} reconnecting={query.isError} />
</>}
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
<p>{t('management.confirm_update_text', { source: source?.name, target: query.data?.target_version || version })}</p>
<p className={s.note}>{t('management.update_warning')}</p>
{start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" disabled={!canInstall} busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
</Dialog>}
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
<p className={s.note}>{t('management.sources_hint')}</p>
<div className={s.sourceList}>{editing.map((item, index) => <div className={s.sourceEditor} key={item.id}>
<Field label={t('management.source_name')} value={item.name} maxLength={100} onChange={(name) => setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} />
<Field label={t('management.repository')} value={item.url} maxLength={2048} mono onChange={(url) => setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} />
<Button disabled={editing.length === 1 || save.isPending} onClick={() => setEditing(editing.filter((v) => v.id !== item.id))}>{t('management.remove_source')}</Button>
</div>)}</div>
{save.error && <FormError>{errorText(save.error)}</FormError>}
<div className={s.actions}><Button disabled={editing.length >= 20 || save.isPending} onClick={() => setEditing([...editing, { id: crypto.randomUUID(), name: '', url: '' }])}>{t('management.add_source')}</Button><Button variant="primary" disabled={editing.some((v) => !v.name.trim() || !v.url.trim())} busy={save.isPending} onClick={() => save.mutate(editing)}>{t('management.save')}</Button></div>
</Dialog>}
</div>;
}
@@ -0,0 +1,36 @@
.page { width: 100%; min-width: 0; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
.eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--text2); font-weight: 600; }
.hero { display: flex; gap: 24px; align-items: center; justify-content: space-between; padding: 28px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: linear-gradient(120deg, var(--accent-soft), var(--surface-a)); }
.hero strong { display: block; font-family: var(--font-display); font-size: 30px; margin-top: 8px; overflow-wrap: anywhere; }
.hero strong.priority { font-size: 19px; }
.hero p { color: var(--text2); max-width: 340px; line-height: 1.6; font-size: 13px; }
.card { padding: 24px; background: var(--surface-a); border: 1px solid var(--line); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: 18px; }
.card h2 { margin: 0; font-size: 18px; }
.selectLabel { display: flex; flex-direction: column; gap: 8px; font-size: 13px; font-weight: 500; }
.selectLabel select { height: 42px; padding: 0 12px; font: inherit; color: var(--text); border: 1px solid var(--line); border-radius: 10px; background: var(--surface2); max-width: 100%; }
.selectLabel select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.url { color: var(--text2); font: 12px monospace; overflow-wrap: anywhere; margin: -8px 0 0; }
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.status, .tag { padding: 5px 10px; border: 1px solid var(--line); border-radius: 99px; color: var(--text2); font-size: 12px; }
.status[data-state='running'] { background: var(--accent-soft); color: var(--accent); }
.status[data-state='succeeded'] { color: var(--ok); }
.status[data-state='failed'] { color: var(--danger); }
.log { margin: 0; padding: 18px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); max-height: 380px; min-height: 100px; overflow: auto; font-size: 12px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
.sourceList { display: flex; flex-direction: column; gap: 16px; max-height: 50vh; overflow: auto; margin-bottom: 18px; }
.sourceEditor { display: flex; flex-direction: column; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; }
.toolbar { display: grid; grid-template-columns: 1fr 220px; gap: 16px; align-items: end; }
.configList { display: flex; flex-direction: column; gap: 12px; }
.configRow { padding: 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-a); display: flex; flex-direction: column; gap: 14px; }
.configRow[data-changed] { border-color: var(--accent); }
.configMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.configMeta > code { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
.values { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--text2); font-size: 12px; }
.values code { overflow-wrap: anywhere; }
.editor { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
.saveBar { position: sticky; bottom: 12px; z-index: 2; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); box-shadow: 0 8px 32px #0003; font-size: 13px; }
@media (max-width: 650px) { .hero { flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; } .hero p { max-width: none; } .toolbar { grid-template-columns: 1fr; } .editor { grid-template-columns: 1fr; } .card, .configRow { padding: 16px; } .actions { justify-content: flex-start; } }
.updateAction { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 16px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 14px; }
@@ -0,0 +1,132 @@
import { CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors';
import { arrowBackIcon, arrowDownIcon, arrowUpIcon, chatIcon, closeIcon, moreIcon, musicIcon, searchIcon, smileIcon } from '../../shared/ui/icons';
import { Button, EmptyState, Field, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
import { ListenTogetherButton } from './Listening';
import { ChatMusic, MessageText } from './ChatMusic';
import { Presence } from './Presence';
import s from './social.module.css';
export function ChatConversation({ person, api, queryKey, userId, active = true, onBack, actions }: {
person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; active?: boolean; onBack?: () => void; actions?: ReactNode;
}) {
const { t, i18n } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const server = useDesktop()?.server ?? window.location.origin;
const draftKey = chatDraftKey(server, userId, person.id);
const [text, setText] = useState(() => { try { return localStorage.getItem(draftKey) ?? ''; } catch { return ''; } });
const [reply, setReply] = useState<DirectMessage | null>(null);
const [editing, setEditing] = useState<DirectMessage | null>(null);
const [view, setView] = useState<'conversation' | 'music'>('conversation');
const [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [emojis, setEmojis] = useState(false);
const [atBottom, setAtBottom] = useState(true);
const beforeEdit = useRef('');
const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
const typedAt = useRef(0);
const composer = useRef<HTMLTextAreaElement>(null);
const scroll = useRef<HTMLDivElement>(null);
const previousLast = useRef<number | undefined>(undefined);
const [visible, setVisible] = useState(document.visibilityState === 'visible');
const music = view === 'music';
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 250); return () => clearTimeout(timer); }, [search]);
useEffect(() => {
if (editing) return;
try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Storage may be unavailable in private mode. */ }
}, [text, draftKey, editing]);
useEffect(() => {
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !music && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
return () => clearTimeout(timer);
}, [text, active, visible, editing, music, person.id, api]);
useEffect(() => () => { void api.typing(person.id, false).catch(() => undefined); }, [api, person.id]);
useEffect(() => { if (composer.current) { composer.current.style.height = 'auto'; composer.current.style.height = `${Math.min(composer.current.scrollHeight, 144)}px`; } }, [text, music, active]);
const meta = useQuery({ queryKey: [...queryKey, 'typing', person.id], queryFn: () => api.chatMeta(person.id), enabled: active && visible && !music, refetchInterval: active && visible && !music ? 2000 : false });
const messages = useInfiniteQuery({ queryKey: [...queryKey, 'chat', person.id, q, music], initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) => api.messages(person.id, pageParam, q, music), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined,
enabled: active && visible, refetchInterval: active && visible ? 3000 : false });
const list = [...(messages.data?.pages ?? [])].reverse().flat();
const last = list.at(-1)?.seq;
const firstUnread = list.find(m => m.sender_id !== userId && !m.read)?.id;
useEffect(() => {
const el = scroll.current;
if (el && active && !q && !music && (atBottom || previousLast.current === undefined)) el.scrollTop = el.scrollHeight;
previousLast.current = last;
}, [last, active, q, music]);
useEffect(() => {
const el = scroll.current;
const latestVisible = el && el.scrollHeight - el.scrollTop - el.clientHeight < 64;
if (last && firstUnread && latestVisible && visible && active && !q && !music && atBottom) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
}, [last, firstUnread, visible, active, q, music, atBottom, api, person.id, cache, 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: uuidv7(), 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; setAtBottom(true); await refresh(); if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; composer.current?.focus(); } });
const cancelEdit = () => { setText(beforeEdit.current); setEditing(null); };
const mutate = (id: string, body: ChatMessageUpdate) => { if (!update.isPending) update.mutate({ id, body }); };
const changeView = (next: typeof view) => { setView(next); setSearch(''); setQ(''); setSearchOpen(false); previousLast.current = undefined; setAtBottom(true); };
const older = async () => {
const el = scroll.current;
const height = el?.scrollHeight ?? 0;
const top = el?.scrollTop ?? 0;
await messages.fetchNextPage();
requestAnimationFrame(() => { if (el) el.scrollTop = top + el.scrollHeight - height; });
};
return <section className={s.chat} aria-label={t('social.chat')}>
<header className={s.chatHeader}>
{onBack && <Button className={`${s.iconButton} ${s.backButton}`} variant="ghost" icon={arrowBackIcon} aria-label={t('social.back_to_messages')} onClick={onBack} />}
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
<div className={s.chatIdentity}><h2>{person.display_name}</h2><Presence online={person.online} /></div>
<div className={s.chatTools}><ListenTogetherButton person={person} /><Button variant="ghost" className={s.iconButton} icon={searchIcon} aria-label={t('social.chat_search')} aria-expanded={searchOpen} onClick={() => { setSearchOpen(v => !v); setSearch(''); setQ(''); }} />{actions}</div>
</header>
{person.activity && <div className={s.nowListening}><Icon src={musicIcon} size={18} /><div><span>{t('social.listening')}</span><Link to="/track/$id" params={{ id: person.activity.track_id }}>{person.activity.track_title}{person.activity.artist ? ` · ${person.activity.artist}` : ''}</Link>{person.activity.context === 'playlist' && person.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: person.id }}>{t('social.from_playlist', { name: person.activity.playlist_name ?? '' })}</Link>}</div></div>}
<nav className={s.chatTabs} aria-label={t('social.conversation_content')}><button type="button" aria-pressed={!music} onClick={() => changeView('conversation')}><Icon src={chatIcon} size={16} />{t('social.conversation')}</button><button type="button" aria-pressed={music} onClick={() => changeView('music')}><Icon src={musicIcon} size={16} />{t('social.shared_music')}</button></nav>
{searchOpen && <div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} autoFocus /><Button variant="ghost" icon={closeIcon} className={s.iconButton} aria-label={t('social.close_search')} onClick={() => { setSearchOpen(false); setSearch(''); setQ(''); }} /></div>}
<FormError>{messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.error) : null}</FormError>
<div ref={scroll} className={`${s.messages} ${music ? s.musicMessages : ''}`} onScroll={e => { const el = e.currentTarget; setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 64); }}>
{messages.hasNextPage && <Button className={s.older} busy={messages.isFetchingNextPage} onClick={() => void older()}>{t('social.older')}</Button>}
{messages.isPending && <div className={s.chatSkeleton} role="status" aria-label={t('social.loading')}><Skeleton style={{ height: 70, width: '70%' }} /><Skeleton style={{ height: 46, width: '55%', alignSelf: 'end' }} /><Skeleton style={{ height: 80, width: '65%' }} /></div>}
{!list.length && messages.isSuccess && <EmptyState icon={music ? musicIcon : chatIcon}>{t(q ? 'social.chat_no_results' : music ? 'social.no_shared_music' : 'social.first_message')}</EmptyState>}
{list.map((m, i) => <Fragment key={m.id}>
{(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && <time className={s.date}>{new Date(m.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', year: 'numeric' })}</time>}
{m.id === firstUnread && !q && !music && <div className={s.newMessages}>{t('social.new_messages')}</div>}
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''} ${music ? s.mediaBubble : ''}`}>
{m.reply_to && !music && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
{(m.deleted || m.text && m.attachment_kind === 'none') && <div className={s.messageText}>{m.deleted ? t('social.message_deleted') : <MessageText text={m.text} />}</div>}
{!m.deleted && m.attachment_id && m.attachment_kind !== 'none' && <ChatMusic message={m} scope={queryKey} />}
{!m.deleted && !!m.reactions?.length && <div className={s.reactions}>{m.reactions.map(r => <button key={r.emoji} type="button" aria-pressed={r.mine} disabled={update.isPending} onClick={() => mutate(m.id, { action: 'react', emoji: r.emoji as typeof CHAT_REACTIONS[number] })}>{r.emoji} {r.count}</button>)}</div>}
<div className={s.messageFoot}><small className={s.muted}>{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')}` : ''}</small>
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger={<Icon src={moreIcon} size={18} />} items={[
{ label: t('social.reply'), onSelect: () => { changeView('conversation'); if (editing) cancelEdit(); setReply(m); requestAnimationFrame(() => composer.current?.focus()); } },
...CHAT_REACTIONS.map(emoji => ({ label: emoji, onSelect: () => mutate(m.id, { action: 'react', emoji }) })),
...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { changeView('conversation'); beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); requestAnimationFrame(() => composer.current?.focus()); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []),
]} />}
</div>
</article>
</Fragment>)}
{send.isPending && !!text.trim() && !editing && !music && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
</div>
{!atBottom && !music && <Button className={s.jumpToBottom} icon={arrowDownIcon} onClick={() => { if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; setAtBottom(true); }}>{t('social.latest_messages')}</Button>}
{!music && <>
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? <><span className={s.typingDots} aria-hidden><i /><i /><i /></span>{person.display_name} {t('social.typing')}</> : null}</div>
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" icon={closeIcon} className={s.iconButton} onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')} /></div>}
{emojis && <div className={s.emojiPicker}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" aria-label={emoji} onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); composer.current?.focus(); }}>{emoji}</button>)}</div>}
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
<Button variant="ghost" icon={smileIcon} className={s.iconButton} aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)} />
<textarea ref={composer} aria-label={t('social.message')} placeholder={t('social.message_placeholder')} maxLength={4000} rows={1} value={text} disabled={send.isPending} onChange={e => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
<Button type="submit" variant="primary" className={s.iconButton} icon={arrowUpIcon} aria-label={t(editing ? 'playlists.save' : 'social.send')} busy={send.isPending} disabled={!text.trim()} />
</form>
</>}
</section>;
}
@@ -0,0 +1,33 @@
import { createI18n } from '@pipu/i18n';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest';
import { MessageText } from './ChatMusic';
afterEach(cleanup);
describe('message text', () => {
it('compacts a long URL while preserving its full destination when text is collapsed', async () => {
const i18n = await createI18n('ru');
const url = `https://example.com/music/${'long-path-'.repeat(90)}?share=abc`;
render(<I18nextProvider i18n={i18n}><MessageText text={`Послушай ${url}`} /></I18nextProvider>);
expect(screen.getByRole('link')).toHaveAttribute('href', url);
expect(screen.getByRole('link').textContent!.length).toBeLessThan(65);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Показать полностью' }));
expect(screen.getByRole('link')).toHaveAttribute('href', url);
expect(screen.getByRole('button', { name: 'Свернуть текст' })).toHaveAttribute('aria-expanded', 'true');
});
it('keeps encoded text and markup literal, with expansion for long messages', async () => {
const i18n = await createI18n('ru');
const text = `<script>alert(1)</script> ${'%D0%BC%20'.repeat(100)}`;
const { container } = render(<I18nextProvider i18n={i18n}><MessageText text={text} /></I18nextProvider>);
expect(container.querySelector('script')).toBeNull();
expect(container.textContent).toContain('%D0%BC%20');
expect(screen.queryByRole('link')).not.toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: 'Показать полностью' }));
expect(container.textContent).toContain(text);
});
});
@@ -0,0 +1,63 @@
import { unwrap, type DirectMessage } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { useSession } from '../../app/session';
import { Cover } from '../../entities/catalog';
import { usePlayer, usePlayerState } from '../../player/context';
import { formatDuration } from '../../shared/lib/format';
import { musicIcon, pauseIcon, playIcon, queueIcon } from '../../shared/ui/icons';
import { Button, Icon } from '../../shared/ui/ui';
import s from './social.module.css';
/** Catalog reads stay in the client adapter; message text is never decoded into metadata. */
export function ChatMusic({ message, scope }: { message: DirectMessage; scope: readonly unknown[] }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const state = usePlayerState();
const id = message.attachment_id!;
const track = useQuery({ queryKey: [...scope, 'attachment', 'track', id], staleTime: 60_000, retry: false,
enabled: message.attachment_kind === 'track', queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })) });
const playlist = useQuery({ queryKey: [...scope, 'attachment', 'playlist', id], retry: false,
enabled: message.attachment_kind === 'playlist', queryFn: async () => unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } })) });
const isTrack = message.attachment_kind === 'track';
const query = isTrack ? track : playlist;
const tr = !track.isError ? track.data : undefined;
const pl = !playlist.isError ? playlist.data : undefined;
const metadataTitle = isTrack ? tr?.title : pl?.name;
const title = metadataTitle || message.text || t(isTrack ? 'social.shared_track' : 'social.shared_playlist');
const album = isTrack ? tr?.album : pl?.tracks[0]?.album;
const current = !!tr && tr.id === state.track?.id;
const available = !query.isError && (isTrack ? tr?.available : pl?.tracks.some(x => x.available));
const subtitle = query.isError ? t('social.attachment_unavailable') : isTrack ? tr ? `${tr.artists.map(a => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}` : t('social.shared_track') : t('social.shared_playlist');
return <><div className={`${s.musicCard} ${query.isError ? s.musicUnavailable : ''}`}>
{album ? <Cover album={album} size={48} /> : <span className={s.musicCover}><Icon src={isTrack ? musicIcon : queueIcon} size={24} /></span>}
<div className={s.musicInfo}>
<Link to={isTrack ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id, shareId: id }} aria-label={t(isTrack ? 'social.listen_track' : 'social.listen_playlist')} title={title}>{title}</Link>
<span>{subtitle}</span>
</div>
<Button variant="ghost" className={s.iconButton} icon={current && state.playing ? pauseIcon : playIcon} aria-label={t(current && state.playing ? 'player.pause' : 'catalog.play')} disabled={!available || state.listening?.locked} onClick={() => {
if (isTrack && tr) { if (current) player.toggle(); else player.playList([tr], 0, { type: 'queue' }); }
else if (pl) player.playList(pl.tracks.filter(x => x.available), 0, { type: 'queue' });
}} />
</div>{message.text && message.text.trim() !== metadataTitle?.trim() && <span className={s.attachmentCaption}><MessageText text={message.text} /></span>}</>;
}
/** Compact only actual HTTP(S) URLs. Arbitrary encoded text remains literal. */
export function MessageText({ text }: { text: string }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const long = text.length > 600;
const shown = long && !expanded ? `${text.slice(0, 400)}…` : text;
const originalParts = text.split(/(https?:\/\/[^\s<>]+)/g);
return <>{shown.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) => {
if (!/^https?:\/\//.test(part)) return part;
try {
const original = originalParts[i] ?? part;
const url = new URL(original);
return <a key={i} href={url.href} target="_blank" rel="noopener noreferrer" title={original}>{url.hostname}{url.pathname === '/' ? '' : `${url.pathname.slice(0, 34)}${url.pathname.length > 34 || url.search ? '…' : ''}`}</a>;
} catch { return part; }
})}{long && <button type="button" className={s.expandText} aria-expanded={expanded} onClick={() => setExpanded(v => !v)}>{t(expanded ? 'social.show_less' : 'social.show_more')}</button>}</>;
}
Loaded 100 of 428 files, more files were not shown because too many files have changed in this diff. Show more