From bb828ff2dce80a4376a6a327a8a756dd1dc3b89c Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:29:47 -0500 Subject: [PATCH] =?UTF-8?q?android:=20the=20phone,=20on=20the=20real=20cor?= =?UTF-8?q?e=20=E2=80=94=20the=20mockup=20shell,=20playback,=20the=20media?= =?UTF-8?q?=20card,=20the=20catalog,=20the=20resolver,=20Save,=20the=20dev?= =?UTF-8?q?=20flavour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same Tauri 2 project builds for Android: mobile.html as a third Vite entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest on Android, the AAudio buffer, and a hand-driven detour build (src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off. ANDROID.md is the recipe. Playback: the media-session plugin crate (lock-screen card, MediaStyle notification, audio focus, headset buttons, a foreground service that promotes itself first — Android 14 kills the app otherwise), session restore across reload and restart, solid chrome after measuring the backdrop-filter lag. The catalog: Search is the desktop's top bar (the top-result rule, artist cards), the artist page and the record page are the desktop's, Home is the desktop's landing page, Gaps per followed shelf. Playing goes through the desktop's own source rules, moved verbatim from state.svelte.js into src/lib/sources.js so both apps share one resolver; the phone readies the next three tracks and crosses boundaries gapless. Save fetches a record or a track into the library by the download rules; Transfers shows the burst. The raw Soulseek search stays, offered on request, a peer's copy streaming with the lane rising as the bytes arrive. Community is the Soulseek side (rooms, chat, buddies); the private layer waits. The opener and storage plugins (social links, the picked library folder, all-files access), the volume normalization switch, and the harness: tests/mobile/smoke.mjs walks the mock (a fake network behind src/lib/events.js) and the real phone over CDP, perf.mjs measures frames. --- .gitignore | 4 + ANDROID.md | 378 +++++ Cargo.lock | 214 ++- Cargo.toml | 2 + crates/metadata/Cargo.toml | 10 +- crates/player/src/lib.rs | 9 + crates/tauri-plugin-media-session/Cargo.toml | 16 + .../android/.gitignore | 2 + .../android/build.gradle.kts | 28 + .../android/src/main/AndroidManifest.xml | 29 + .../soulstream/mediasession/MediaService.kt | 315 +++++ .../mediasession/MediaSessionPlugin.kt | 77 + .../main/res/drawable/ic_soulstream_note.xml | 11 + crates/tauri-plugin-media-session/build.rs | 15 + .../autogenerated/commands/clear.toml | 13 + .../commands/registerListener.toml | 13 + .../commands/removeListener.toml | 13 + .../autogenerated/commands/update.toml | 13 + .../permissions/autogenerated/reference.md | 127 ++ .../permissions/default.toml | 10 + .../permissions/schemas/schema.json | 354 +++++ crates/tauri-plugin-media-session/src/lib.rs | 109 ++ crates/tauri-plugin-storage/Cargo.toml | 16 + .../tauri-plugin-storage/android/.gitignore | 2 + .../android/build.gradle.kts | 28 + .../android/src/main/AndroidManifest.xml | 18 + .../com/soulstream/storage/StoragePlugin.kt | 94 ++ crates/tauri-plugin-storage/build.rs | 13 + .../commands/all_files_access.toml | 13 + .../autogenerated/commands/pick_folder.toml | 13 + .../commands/request_all_files_access.toml | 13 + .../permissions/autogenerated/reference.md | 99 ++ .../permissions/default.toml | 9 + .../permissions/schemas/schema.json | 342 +++++ crates/tauri-plugin-storage/src/lib.rs | 111 ++ mobile.html | 13 + package-lock.json | 11 +- package.json | 3 +- src-tauri/Cargo.toml | 25 +- src-tauri/android/build.sh | 26 + src-tauri/android/deploy.sh | 28 + src-tauri/android/dev.sh | 53 + src-tauri/android/env.sh | 23 + src-tauri/capabilities/default.json | 5 +- src-tauri/src/android_glue.rs | 56 + src-tauri/src/commands.rs | 55 +- src-tauri/src/config.rs | 100 ++ src-tauri/src/diagnostics.rs | 26 + src-tauri/src/lib.rs | 31 + src-tauri/src/lyric_align_stub.rs | 56 + src-tauri/src/playlists.rs | 33 +- src-tauri/src/updater_mobile.rs | 83 ++ src-tauri/src/vocal_sep_stub.rs | 31 + src-tauri/src/ytdlp.rs | 27 +- src-tauri/tauri.android.conf.json | 18 + src/lib/api.js | 10 +- src/lib/events.js | 9 + src/lib/mockLibrary.js | 252 +++- src/lib/sources.js | 872 ++++++++++++ src/lib/state.svelte.js | 816 +---------- src/mobile-main.js | 9 + src/mobile/Cover.svelte | 26 + src/mobile/HalfSheet.svelte | 211 +++ src/mobile/Lane.svelte | 109 ++ src/mobile/Mobile.svelte | 282 ++++ src/mobile/Release.svelte | 25 + src/mobile/Sheet.svelte | 275 ++++ src/mobile/art.svelte.js | 124 ++ src/mobile/catalog.svelte.js | 160 +++ src/mobile/disco.js | 94 ++ src/mobile/home.svelte.js | 148 ++ src/mobile/lib.svelte.js | 123 ++ src/mobile/media.svelte.js | 75 + src/mobile/mobile.css | 142 ++ src/mobile/nav.svelte.js | 171 +++ src/mobile/net.svelte.js | 148 ++ src/mobile/player.svelte.js | 695 +++++++++ src/mobile/prefs.svelte.js | 31 + src/mobile/resolve.svelte.js | 279 ++++ src/mobile/screens/Album.svelte | 97 ++ src/mobile/screens/Artist.svelte | 334 +++++ src/mobile/screens/CatalogAlbum.svelte | 221 +++ src/mobile/screens/Community.svelte | 160 +++ src/mobile/screens/Home.svelte | 320 +++++ src/mobile/screens/Library.svelte | 187 +++ src/mobile/screens/Playlist.svelte | 70 + src/mobile/screens/Room.svelte | 69 + src/mobile/screens/Search.svelte | 383 +++++ src/mobile/screens/Stub.svelte | 13 + src/mobile/screens/Thread.svelte | 57 + src/mobile/screens/Tracks.svelte | 69 + src/mobile/screens/Transfers.svelte | 46 + src/mobile/screens/You.svelte | 124 ++ src/mobile/shell.css | 1260 +++++++++++++++++ src/mobile/slsk.svelte.js | 177 +++ src/mobile/transfers.svelte.js | 61 + src/mobile/util.js | 177 +++ tests/mobile/cdp.mjs | 101 ++ tests/mobile/miniws.mjs | 81 ++ tests/mobile/perf.mjs | 155 ++ tests/mobile/smoke.mjs | 583 ++++++++ vite.config.js | 21 +- 102 files changed, 12258 insertions(+), 830 deletions(-) create mode 100644 ANDROID.md create mode 100644 crates/tauri-plugin-media-session/Cargo.toml create mode 100644 crates/tauri-plugin-media-session/android/.gitignore create mode 100644 crates/tauri-plugin-media-session/android/build.gradle.kts create mode 100644 crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml create mode 100644 crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt create mode 100644 crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt create mode 100644 crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_soulstream_note.xml create mode 100644 crates/tauri-plugin-media-session/build.rs create mode 100644 crates/tauri-plugin-media-session/permissions/autogenerated/commands/clear.toml create mode 100644 crates/tauri-plugin-media-session/permissions/autogenerated/commands/registerListener.toml create mode 100644 crates/tauri-plugin-media-session/permissions/autogenerated/commands/removeListener.toml create mode 100644 crates/tauri-plugin-media-session/permissions/autogenerated/commands/update.toml create mode 100644 crates/tauri-plugin-media-session/permissions/autogenerated/reference.md create mode 100644 crates/tauri-plugin-media-session/permissions/default.toml create mode 100644 crates/tauri-plugin-media-session/permissions/schemas/schema.json create mode 100644 crates/tauri-plugin-media-session/src/lib.rs create mode 100644 crates/tauri-plugin-storage/Cargo.toml create mode 100644 crates/tauri-plugin-storage/android/.gitignore create mode 100644 crates/tauri-plugin-storage/android/build.gradle.kts create mode 100644 crates/tauri-plugin-storage/android/src/main/AndroidManifest.xml create mode 100644 crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt create mode 100644 crates/tauri-plugin-storage/build.rs create mode 100644 crates/tauri-plugin-storage/permissions/autogenerated/commands/all_files_access.toml create mode 100644 crates/tauri-plugin-storage/permissions/autogenerated/commands/pick_folder.toml create mode 100644 crates/tauri-plugin-storage/permissions/autogenerated/commands/request_all_files_access.toml create mode 100644 crates/tauri-plugin-storage/permissions/autogenerated/reference.md create mode 100644 crates/tauri-plugin-storage/permissions/default.toml create mode 100644 crates/tauri-plugin-storage/permissions/schemas/schema.json create mode 100644 crates/tauri-plugin-storage/src/lib.rs create mode 100644 mobile.html create mode 100644 src-tauri/android/build.sh create mode 100644 src-tauri/android/deploy.sh create mode 100644 src-tauri/android/dev.sh create mode 100644 src-tauri/android/env.sh create mode 100644 src-tauri/src/android_glue.rs create mode 100644 src-tauri/src/lyric_align_stub.rs create mode 100644 src-tauri/src/updater_mobile.rs create mode 100644 src-tauri/src/vocal_sep_stub.rs create mode 100644 src-tauri/tauri.android.conf.json create mode 100644 src/lib/events.js create mode 100644 src/lib/sources.js create mode 100644 src/mobile-main.js create mode 100644 src/mobile/Cover.svelte create mode 100644 src/mobile/HalfSheet.svelte create mode 100644 src/mobile/Lane.svelte create mode 100644 src/mobile/Mobile.svelte create mode 100644 src/mobile/Release.svelte create mode 100644 src/mobile/Sheet.svelte create mode 100644 src/mobile/art.svelte.js create mode 100644 src/mobile/catalog.svelte.js create mode 100644 src/mobile/disco.js create mode 100644 src/mobile/home.svelte.js create mode 100644 src/mobile/lib.svelte.js create mode 100644 src/mobile/media.svelte.js create mode 100644 src/mobile/mobile.css create mode 100644 src/mobile/nav.svelte.js create mode 100644 src/mobile/net.svelte.js create mode 100644 src/mobile/player.svelte.js create mode 100644 src/mobile/prefs.svelte.js create mode 100644 src/mobile/resolve.svelte.js create mode 100644 src/mobile/screens/Album.svelte create mode 100644 src/mobile/screens/Artist.svelte create mode 100644 src/mobile/screens/CatalogAlbum.svelte create mode 100644 src/mobile/screens/Community.svelte create mode 100644 src/mobile/screens/Home.svelte create mode 100644 src/mobile/screens/Library.svelte create mode 100644 src/mobile/screens/Playlist.svelte create mode 100644 src/mobile/screens/Room.svelte create mode 100644 src/mobile/screens/Search.svelte create mode 100644 src/mobile/screens/Stub.svelte create mode 100644 src/mobile/screens/Thread.svelte create mode 100644 src/mobile/screens/Tracks.svelte create mode 100644 src/mobile/screens/Transfers.svelte create mode 100644 src/mobile/screens/You.svelte create mode 100644 src/mobile/shell.css create mode 100644 src/mobile/slsk.svelte.js create mode 100644 src/mobile/transfers.svelte.js create mode 100644 src/mobile/util.js create mode 100644 tests/mobile/cdp.mjs create mode 100644 tests/mobile/miniws.mjs create mode 100644 tests/mobile/perf.mjs create mode 100644 tests/mobile/smoke.mjs diff --git a/.gitignore b/.gitignore index 463fb6c..3fbec87 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ branding/ # the setup app frontend build (vite.setup.config.js) setup/dist/ setup/src-tauri/gen/ + +# the phone UI smoke test (tests/mobile/smoke.mjs) builds and captures here +dist-smoke/ +tests/mobile/out/ diff --git a/ANDROID.md b/ANDROID.md new file mode 100644 index 0000000..b47f6ed --- /dev/null +++ b/ANDROID.md @@ -0,0 +1,378 @@ +# SoulStream on Android + +The phone is the **same Tauri 2 project** as the desktop: the same Rust core +in `src-tauri`, the same commands over IPC, a third Vite entry for the +phone's own UI. Started 2026-09-12 on a Pixel 6a (Android 16). The design it +implements is `research/mobile-mockup/` (gitignored; see its README). + +## What is where + +| | | +|---|---| +| `mobile.html` → `src/mobile-main.js` → `src/mobile/Mobile.svelte` | the phone UI (its own Vite input, next to `index.html` and `mini.html`) | +| `src/mobile/mobile.css` | the foundations: the desktop palette, phone radii/spacing, Android insets, the sampled springs, the registered `--accent` and its transition | +| `src/mobile/shell.css` | the shared vocabulary, global on purpose: ground, glass bar, cards, rows, chips, the record head, the mini bar, the nav, the player sheet, the lane, half sheets, the screen transitions | +| `src/mobile/nav.svelte.js` | a stack per tab; the browser history mirrors it one entry per back-able layer so the **system back gesture** peels the sheet, a half sheet, a pushed screen, then the tab | +| `src/mobile/player.svelte.js` | the queue (a predetermined shuffle order, the unshuffled queue kept), the transport, gapless preload the desktop's way (`preload_local`, `track-ended { gapless }`), the session in localStorage (a reload follows the live audio thread, a restart restores paused with `needsResource`), the sleep timer, the lane's story (hold → arriving), the play-history ticks (the desktop's rule, the same table) | +| `src/mobile/art.svelte.js` | covers behind a LIFO gate (3 in flight), the `visible` action, the accent per cover through `src/lib/color.js`; an https URL goes through the desktop's art proxy (`catalog_art`) | +| `src/mobile/lib.svelte.js` | albums, recents, follows, playlists; artists derived from the albums | +| `src/mobile/catalog.svelte.js` | the catalog lookups, cached per session: an artist by name (the desktop's most-followed-exact-match rule), a catalog record by id | +| `src/mobile/media.svelte.js` | the lock-screen card's wiring: mirrors the bar into the media session plugin on every transport change (plus a top-up every 30 s), carries out the card's, the headset's and audio focus's actions | +| `src/mobile/slsk.svelte.js` | the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's `slsk` store and events | +| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows | +| `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the waveform lane, the two card pieces (a long press on a card is its sheet) | +| `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Playlist, Community (sign in; rooms, chat, buddies — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) | +| `src/mobile/prefs.svelte.js` | Appearance, kept in localStorage: ambience, accent from artwork, motion | +| `src-tauri/tauri.android.conf.json` | merged over `tauri.conf.json` on Android: one window at `mobile.html`, no desktop resources, `minSdkVersion` 26 | +| `src-tauri/gen/android/` | the generated Gradle project (`npx tauri android init`); `minSdk = 26`. Gitignored | +| `src-tauri/android/build.sh`, `deploy.sh` | the detour build (below) and build → install → launch → screenshot | +| `src-tauri/src/android_glue.rs` | `JNI_OnLoad`: hands the JavaVM and app Context to `ndk-context` (cpal's AAudio backend needs it; tao 0.35 never fills it) | +| `src-tauri/src/updater_mobile.rs` | the updater's stand-in on mobile (`#[cfg(mobile)] #[path]`) | +| `src-tauri/src/lyric_align_stub.rs`, `vocal_sep_stub.rs` | stand-ins off Windows for the two Windows ML modules | +| `tests/mobile/smoke.mjs`, `perf.mjs`, `cdp.mjs` | the phone UI walked in headless Chrome or on the phone, the frame-time probe, and the DevTools client they share (below) | + +Android-only manifest tables: `src-tauri/Cargo.toml` and +`crates/metadata/Cargo.toml` give reqwest `rustls-tls` on Android and the +platform TLS everywhere else (no OpenSSL to build against on Android). The +tables are exclusive on purpose — an unconditional reqwest entry unifies +default-tls back in. `tauri-plugin-updater` is a desktop-only dependency. + +What the phone does differently at runtime: + +- the library root is `app_data_dir()/Library` (Android's `getDataDir()`, so + `/data/user/0/com.soulstream.app/Library`) until the SAF folder story lands; +- the Soulseek password lives in `secrets.json` under the app's private data + directory (`config::save_password` and friends on Android) — there is no + credential manager the keyring crate speaks to, and the app directory is + what Android's account storage amounts to for an app; +- the audio output stream asks for an 8192-frame buffer (`open_output_stream`); + AAudio's default put it on the fast path with a 10 ms burst, where the mixer's + inline decode and file reads underran audibly; +- `pick_folder` answers "nothing picked" (desktop-only dialog); `save_file` works; +- the window-glass command is a no-op (the ground is the artwork); +- the aligner and the separator report "not on this platform"; +- the desktop's Win32 perf counters read 0 / None in the debug overlay. + +## The shell, on real data + +The mockup's rules, kept: the page ground is the artwork in play, blown up and +blurred (two layers, so a change of record crossfades); the accent follows +the artwork **per screen** — a record page wears the record's colour, an +artist page the artist's, everything else what is playing — extracted with +the desktop's own `color.js` and crossfaded by the compositor through the +registered `--accent`. The glass bar is `GlassBar.svelte`'s recipe and stays +anonymous until the page's large title has scrolled under it. The mini bar +is the desktop's 62px row over a 16px lane strip; tapping it is a container +transform (the small art flies to the big one). The player's scrubber is the +lane itself, no knob. Motion is Material 3 Expressive's springs, sampled into +`linear()`. Reduce motion (You → Appearance, or the OS setting) turns it off. + +## Search and the resolver + +Search is the desktop's top bar: the field asks the **catalog** once the +typing pauses (`catalog_search` — Deezer, MusicBrainz and Apple behind it), +and shows artists, a rail of records and songs, then what the library holds, +then — offered, not run — a raw Soulseek search grouped by peer (a peer's +copy plays from its row, the lane rising as the bytes arrive). A song row, +a catalog record's tracks and its play disc, and Popular on an artist page +all play through the resolver. + +The resolver is the desktop's, not a fork of it: the source rules — the query +ladder with its poisoned-term hygiene, the paced search with its early exit, +the quality window, the candidate scoring for songs and folders, the +remembered stream sources — live in **`src/lib/sources.js`**, one copy for +both apps (moved out of `state.svelte.js` verbatim on 2026-09-12; +`state.svelte.js` imports them back). `src/mobile/resolve.svelte.js` runs +them on the phone: the library first, then a remembered source whose bytes +are still complete in the cache, then the network. A record queues every +track from the one folder that wins; a song played off a record queues the +rest of the record behind it as path-less entries that find their own copy +when their turn comes (`hooks.resolveEntry`). Failover is the player's: an +entry carries its runner-up peers in `t.alts`, and a stream that has not +sent a byte in 12 s (8 s for a retry) moves to the next one, then on. + +A streamed record's cover comes out of the file's own tags once enough of it +is here (`stream_embedded_art`), and the accent follows it when it lands. + +Search leads with the desktop's **top result** — the most popular exact match +of its kind (artist, record or song), scored as a share of its own kind's +leader, ties falling artist → record → song — on the featured surface, then +artist cards, records, songs. The **artist page** is the desktop's +(CatalogArtist + ArtistExtras) at phone scale: the banner with the wordmark or +the name, Play popular, Follow, the stat chips (releases, fans, years active, +genre, socials), Popular with listener bars, the latest release, the +discography filed the desktop's way (`src/mobile/disco.js` over the shared +`localReleaseType.js` + `editions.js`: tabs per category, editions folded +into families, the library's copies marked), releases by year, fans also +like, what is on this phone, About. Opened from a search row it carries the +row's identity (`seed`), so a namesake can never take the page. + +The **catalog record page** is the desktop's album page: the kind, the +artist byline with a portrait, year · tracks · runtime, Last.fm's totals, +Play / Save / Shuffle, the record's other editions (the artist's page folded +by `editions.js`, the desktop's own rule), the tracklist with listener bars +and disc heads, About this record (Wikipedia through `catalog_album_info`, +the lede promoted), and the release pills with who said so. **Save** fetches +the best folder by the DOWNLOAD rules into the phone's library folder; the +pipeline files it and says `soulseek:library-changed`, which the library +store watches, so the record appears on its own. A partial copy (a Save cut +short, a rip missing tracks) keeps the button as "Complete · N missing". A +long press on a row is the track sheet: Play, Save to library, Go to record / +artist. Verified 2026-09-13: Discovery, 14 FLACs from one peer, whole in the +library folder 31 s after the tap, filed as "Artist/Year - Album/… - NN - +Title.flac" with cover.jpg beside them. + +**The THREE next tracks are readied while the current one plays** — the +desktop's depth (`STREAM_PRELOAD_DEPTH`), in the player's `armPreload`: a +library file is decoded and appended; a peer's copy is fetched into the +cache ahead of time; a record's later track that has no copy yet is found +first, QUIETLY (a background search never shows the resolver's line); the +immediate next is appended the moment it is byte-complete, so the boundary +is gapless. Streams wait four seconds after a start so nothing competes with +the track that is starting. Verified 2026-09-13 on a record the phone had +never played: the first track resolved and started in 14 s, the next three +were found and the first of them cached by 16 s, and the boundary crossed +gapless (`player.advance` says how each boundary was crossed, "gapless" or +"sourced" — the harness reads it). + +**The library folder** (You → Library → Library folder) is picked through +Android's document tree picker in `crates/tauri-plugin-storage` (the same +plugin shape as the media session: `links`, `build.rs` with +`android_path("android")`, the capability `storage:default`). The picked +tree's content URI is kept with a persistable grant and resolved to a real +path when it is on a volume that has one (`primary:Music` → +`/storage/emulated/0/Music`; an SD card's `XXXX-XXXX:` too; a cloud +provider's tree has none and is refused), because the Rust scanner and the +download pipeline work on paths. That path becomes `download_dir` — the +folder IS the library — and a rescan follows. A folder in shared storage +needs Android's **all-files access** under scoped storage: the plugin +declares `MANAGE_EXTERNAL_STORAGE`, reports whether it is granted, and opens +the system screen that grants it (You shows the row only while the folder +is outside the app's private data and access is missing). Nothing is read +outside the folder the user picked. + +**Playback** (You → Playback) has the desktop's volume normalization switch +(−14 LUFS through the player crate's BS.1770 meter; on by default). There is +no graphic equalizer in the app — the "eq" in the code is the animated bars +glyph. + +**Home** is the desktop's landing page on top of the library rows the phone +had: one slot per followed artist (`src/mobile/home.svelte.js` — their +catalog page, the MusicBrainz-only records, six related artists, warmed by +`home_warm`), the feed's lead on the featured surface (a new or coming +record; the newest record here when there is none), New from your artists, +the news, Jump back in, From the back catalogue, Recommended for you (each +card names the follow that earned it), Recently added, Out now, Complete the +set, Following. The rules are the desktop's, copied; a follow made anywhere +re-walks the slots (`soulseek:follows-changed`). + +**Gaps** (Library → Gaps) is the desktop's gap line per shelf: only an artist +you FOLLOW is asked (an anchored identity, never a name guessed onto a +namesake), the catalog's released records minus the shelf's by loose title, +dimmed cards that open the catalog record page, the totals remembered in +`library.gaps.v1` so the line says its number before the lookup. + +## Background playback and the lock-screen card + +The service is started with `startForegroundService`, so it promotes itself +FIRST in `onStartCommand` — with the card there is, or a plain one — and +only then mirrors the state, stepping back (`STOP_FOREGROUND_DETACH`) when +the track is paused or stopping when the bar is empty. Android 14 kills the +whole app about ten seconds after a foreground start that never called +`startForeground()`, and every restart restores a PAUSED track, which is +exactly that case: on 2026-09-13 the first real Save died with the process +that way, four tracks in. The check: force-stop, relaunch with a paused +track, wait 25 s, the pid survives. + +`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs` +hands its `android/` Gradle module to tauri-build (which writes it into +`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin +class and exposes `update` / `clear`, and the Kotlin side is two files — +the plugin (arguments in, the `action` event out) and `MediaService`, a +`mediaPlayback` foreground service holding a `MediaSessionCompat` and a +MediaStyle notification. Playing → `startForeground`; paused → the service +steps back and the card stays, dismissable. Android 13+ asks for +`POST_NOTIFICATIONS` the first time something plays. Audio focus is +requested while playing (loss pauses, a transient loss pauses and resumes), +and headphones coming out pause. The app's capability grants +`media-session:default`; the plugin is registered on every platform so the +permission resolves everywhere, and on the desktop its commands answer +"not here" (the desktop has `smtc.rs`). + +A trap: tauri-build regenerates `gen/android/tauri.settings.gradle` and +`app/tauri.build.gradle.kts` — the lines that include every plugin's Android +module — ONLY when `TAURI_ANDROID_PROJECT_PATH` names the project, which the +Tauri CLI sets and a hand-driven cargo build does not. Without it the plugin +compiled, the APK packaged without its Kotlin, and the app died at start-up +with a `ClassNotFoundException`. `src-tauri/android/build.sh` exports it; +after adding a plugin, touch `src-tauri/build.rs` too, so the app's build +script re-runs and rewrites the wiring. + +## Performance + +`tests/mobile/perf.mjs` logs the frames the WebView produces through +scripted scenes (a long scroll, Home's scroll, a record push, the player +opening, idle playback) under CSS variants injected live, so an effect's +cost is measured rather than guessed. On the Pixel 6a the frosted chrome — +`backdrop-filter` on the nav, the mini bar and the glass bar — turned +Home's scroll from 60 Hz to 30 Hz (16 of 129 frames over 32 ms; 0 without) +and dropped every other frame of the player's opening (12 → 0). The +ambience blur, the hero's blur and the entrance springs cost nothing +measurable. So the phone's chrome is solid by default and the blur is a +switch (You → Appearance → Glass blur). Re-run the probe before adding any +backdrop blur to the phone; `--quick` runs the base variant only, and the +app must be on the phone's screen. + +The audio side had its own lag: `dumpsys media.audio_flinger` showed the +track on the fast mixer with runs of ~55 ms underruns. The 8192-frame buffer +above moved it to the normal mixer; the phone walk samples the underrun +counters before and after and fails if they rose. + +## Prerequisites (home machine, done 2026-09-12) + +- Android SDK at `%LOCALAPPDATA%\Android\Sdk`: command-line tools, + platform-tools, platforms 34–36, build-tools 35/36, **NDK 28.2.13676358**, + cmake 3.31.6. Installed with `sdkmanager` alone — no Android Studio. +- User environment: `ANDROID_HOME`, `NDK_HOME`, `ANDROID_NDK_HOME`, + `JAVA_HOME=C:\Program Files\Java\jdk-17`; PATH has platform-tools and cmake. + (A bash session may not see them; the scripts below set their own.) +- Rust targets: `aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android`. +- The Tauri CLI here is npm's: **`npx tauri …`** (there is no `cargo tauri`). +- **Windows Developer Mode** (Settings → System → For developers). Tauri + symlinks the built library into `gen/android/app/src/main/jniLibs`; Windows + refuses symlinks without it. Until it is on, use the detour below. +- No emulator: virtualization is off in this machine's firmware (MSI B550 + Tomahawk: OC → Advanced CPU Configuration → SVM Mode) and Windows + Hypervisor Platform is off. Builds go to a real phone over USB. + +## Build and run + +```bash +npx tauri android build --debug --target aarch64 --apk +``` + +With Developer Mode off, the CLI builds the Rust side and then fails at the +symlink. The detour, scripted: + +```bash +bash src-tauri/android/deploy.sh # vite → cargo → strip → gradle → adb install → launch → screenshot +bash src-tauri/android/build.sh # the build alone; APK path printed at the end +``` + +What the scripts do, by hand: + +```bash +# 0. the frontend, then the library — with tauri's custom-protocol feature ON. +# Without it the build embeds the dev URL (http://localhost:1421) instead of +# dist/, and the phone shows "Failed to request http://localhost:1421/mobile.html". +npm run build +cargo build --lib -p app --target aarch64-linux-android --features tauri/custom-protocol +# (with CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER / CC_ / CXX_ / AR_aarch64_linux_android +# pointing at the NDK's windows-x86_64/bin aarch64-linux-android26-clang(.cmd) wrappers) +# 1. strip the library's debug info (607 MB → 50 MB; the debug packaging keeps symbols) +cp target/aarch64-linux-android/debug/libapp_lib.so src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/ +"%LOCALAPPDATA%\Android\Sdk\ndk\28.2.13676358\toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-strip.exe" --strip-debug src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/libapp_lib.so +# 2. package with Gradle, skipping the task that would redo the symlink +cd src-tauri/gen/android && ./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug +``` + +APK: `src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk`. +A frontend-only change still needs the cargo step: `dist/` is embedded in +the library at compile time (the app crate alone recompiles, ~1 min). + +Phone (USB debugging on, prompt accepted): + +```bash +adb devices +adb install -r src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk +adb shell am start -n com.soulstream.app/.MainActivity +adb logcat --pid=$(adb shell pidof com.soulstream.app) # Rust logs land under RustStdoutStderr / app_lib::* +adb exec-out screencap -p > shot.png +adb shell input tap X Y # drive it: 1080×2400 px, 2.625 px per dp +adb shell input keyevent KEYCODE_BACK +``` + +Music for a test library goes in as the app (no root needed on a debug build): + +```bash +adb push "F:\Music\SoulSeek Music\\" /data/local/tmp/ssm/ +adb shell "chmod -R a+rX /data/local/tmp/ssm; run-as com.soulstream.app sh -c 'mkdir -p \"Library/\" && cp -r \"/data/local/tmp/ssm/\" \"Library//\"'" +adb shell am force-stop com.soulstream.app # the scan runs at launch +``` + +## Iterating without cargo (the dev flavour) + +Every embedded build pays ~2 min of cargo for a CSS change, because `dist/` +is compiled into the library. The dev flavour skips that: the same detour +build **without** `tauri/custom-protocol`, so the library embeds the dev URL +(`http://localhost:1421`), and `adb reverse tcp:1421 tcp:1421` makes the +phone's localhost:1421 this machine's Vite dev server **over the USB cable** — +no LAN address, no firewall hole, hot reload on the phone. Cargo runs only +when Rust changes. Both flavours share `target/`: a switch recompiles tauri +and the app crate once (~3 min, the leaf crates stay fresh). + +```bash +bash src-tauri/android/dev.sh # build the dev library (when Rust changed), install, tunnel, launch +npm run dev # or the Browser pane's "frontend" config — the phone loads from it +bash src-tauri/android/dev.sh --no-build # after a reconnect: reinstall/tunnel/launch without cargo +bash src-tauri/android/deploy.sh # the embedded build again — what the phone keeps when the cable goes +``` + +The dev APK needs this machine and the cable; put the embedded build back +before the phone leaves. The toolchain env both scripts share is +`src-tauri/android/env.sh`. Verified 2026-09-13: a shell.css edit reached +the phone in under a second. The page's origin stays `http://tauri.localhost` +— Tauri proxies the dev server through its own scheme on mobile — so the +harness and the app see one origin in both flavours. + +## Verifying + +```bash +node tests/mobile/smoke.mjs # headless Chrome + the mock library +node tests/mobile/smoke.mjs --phone --album "Spiderman" # the real app on the connected phone +``` + +The mock walk builds `dist-smoke/`, switches the mock library on +(`src/lib/mockLibrary.js`: 140 records, some without art on purpose, and a +**fake network** behind the same switch — a stored account that signs in at +boot, rooms with a few lines in them, a catalog with one artist, a search +answered by five peers, a stream that arrives over twelve seconds with its +waveform growing; backend events go over the mock's own bus, which +`src/lib/events.js` puts in front of Tauri's `listen`), drives Home → a long +press → Search → Library and its tabs → a record → its sheets → the player's +three faces → the back gesture → an artist page → Community → a room → a +catalog search → a song through the resolver → the lane rising in the sheet → +You → the sleep timer, asserts what each step should show, screenshots every +step into `tests/mobile/out/`, and fails on any exception or console error. +Two toasts are expected there and filtered: without Tauri, `listen` and +`invoke` do not exist. + +The phone walk is the same driver on the real thing: a debug build's WebView +exposes DevTools on `localabstract:webview_devtools_remote_`, `adb +forward` puts it on a local port, and the steps run against the real library +— real playback, real covers, the colour following the record — with the +phone's own frames (`adb screencap`) as the stills. It refuses to run when +another app is on the phone's screen (it never takes the phone from whoever +is holding it), and a WebView in the background does not answer, so the app +has to be in front. One of its steps is `location.reload()`: the audio +thread keeps playing and the session comes back live, which is the reload +half of session restore tested for real. After the steps it force-stops and +relaunches the app (the restart half: paused at the position, play resumes), +checks the system's media session, sends media keys, backgrounds the app for +ten seconds, and — with the app signed into Soulseek — types `--query` into +Search, taps the first catalog song and watches it resolve and stream (the +peer, the phase, the coverage), then asks for the raw peers. `--lockscreen` +takes the lock-screen card's still last, because it leaves the phone locked; +`--only-lock` takes just that still of whatever is on the bar (resuming it if +paused) without the walk. + +## Not done yet + +- Import (the desktop's import flow) on the phone. +- Save and Transfers exist (You → Transfers, fed by `soulseek:download`; a + live row cancels on tap; a long press on a catalog row offers the track's + own Save). +- Community's private layer (Spaces, holdings, the sealed inbox, friends by + key) — Freenet on the phone is a later experiment; the network side is in. +- Follows and the play history are per library folder, so the phone's own + folder starts empty of both; they arrive with a shared library. diff --git a/Cargo.lock b/Cargo.lock index 3b0e52e..3d39fef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,10 +170,12 @@ dependencies = [ "flate2", "freenet-adapter", "image", + "jni 0.21.1", "keyring", "library", "log", "metadata", + "ndk-context", "persona-state", "player", "quick-xml 0.37.5", @@ -191,6 +193,9 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-log", + "tauri-plugin-media-session", + "tauri-plugin-opener", + "tauri-plugin-storage", "tauri-plugin-updater", "tempfile", "tokio", @@ -721,6 +726,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.9.1" @@ -732,6 +743,17 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chacha20poly1305" version = "0.10.1" @@ -739,7 +761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -1991,8 +2013,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2014,8 +2038,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2348,6 +2375,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2621,6 +2649,25 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2978,6 +3025,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mach2" version = "0.4.3" @@ -3560,6 +3613,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "openssl" version = "0.10.81" @@ -4026,6 +4090,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -4068,6 +4188,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4106,6 +4237,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4245,6 +4391,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -4252,6 +4400,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -4259,6 +4408,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -4462,6 +4612,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -5659,6 +5810,48 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-media-session" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows 0.61.3", + "zbus", +] + +[[package]] +name = "tauri-plugin-storage" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-updater" version = "2.11.0" @@ -6645,6 +6838,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" @@ -6710,6 +6913,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/Cargo.toml b/Cargo.toml index 1df0fc7..eeca415 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ members = [ "crates/audio-formats", "crates/freenet-adapter", "crates/persona-state", + "crates/tauri-plugin-media-session", + "crates/tauri-plugin-storage", ] [workspace.package] diff --git a/crates/metadata/Cargo.toml b/crates/metadata/Cargo.toml index 76c71e7..940a9aa 100644 --- a/crates/metadata/Cargo.toml +++ b/crates/metadata/Cargo.toml @@ -7,7 +7,6 @@ rust-version.workspace = true [dependencies] futures = "0.3" image.workspace = true -reqwest = { version = "0.12", features = ["json"] } serde.workspace = true serde_json.workspace = true tokio.workspace = true @@ -15,3 +14,12 @@ thiserror.workspace = true log.workspace = true # RFC-7231 Date parsing for preview-lease math (tiny, no deps of its own). httpdate = "1" + +# Desktop keeps the platform TLS; Android has no OpenSSL to build against and +# takes rustls with the bundled roots instead. The two tables are exclusive on +# purpose — an unconditional entry would unify default-tls back in. +[target.'cfg(not(target_os = "android"))'.dependencies] +reqwest = { version = "0.12", features = ["json"] } + +[target.'cfg(target_os = "android")'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index 57d760c..cd29e87 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -471,6 +471,15 @@ fn open_output_stream(device_name: Option<&str>) -> Result<(rodio::OutputStream, let actual_name = device.name().unwrap_or_else(|_| "Unknown device".into()); let builder = rodio::OutputStreamBuilder::from_device(device) .map_err(|e| format!("could not configure {actual_name}: {e}"))?; + // Android: a music player's buffer, not the fast path's. AAudio's default + // lands the stream on the fast mixer with a ~10 ms burst, and the mixer's + // callback decodes and reads from storage inline — a 50 ms hiccup on a + // phone's flash or a busy core is an audible dropout there (the Pixel 6a + // logged runs of ~55 ms underruns, 2026-09-12). 8192 frames is ~170 ms of + // slack at 48 kHz, which nobody hears as latency on a record. Should the + // device refuse it, open_stream_or_fallback takes the default instead. + #[cfg(target_os = "android")] + let builder = builder.with_buffer_size(rodio::cpal::BufferSize::Fixed(8192)); let stream = builder .open_stream_or_fallback() .map_err(|e| format!("could not open {actual_name}: {e}"))?; diff --git a/crates/tauri-plugin-media-session/Cargo.toml b/crates/tauri-plugin-media-session/Cargo.toml new file mode 100644 index 0000000..aa58c02 --- /dev/null +++ b/crates/tauri-plugin-media-session/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tauri-plugin-media-session" +version = "0.1.0" +description = "SoulStream's media session on the phone: the lock-screen card, the notification's transport, audio focus, headset buttons — a foreground service that keeps playback alive in the background" +edition = "2021" +rust-version = "1.77.2" +# required by tauri-plugin's build script; must equal the crate name +links = "tauri-plugin-media-session" + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[build-dependencies] +tauri-plugin = { version = "2", features = ["build"] } diff --git a/crates/tauri-plugin-media-session/android/.gitignore b/crates/tauri-plugin-media-session/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/crates/tauri-plugin-media-session/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/crates/tauri-plugin-media-session/android/build.gradle.kts b/crates/tauri-plugin-media-session/android/build.gradle.kts new file mode 100644 index 0000000..98637b7 --- /dev/null +++ b/crates/tauri-plugin-media-session/android/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.soulstream.mediasession" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + // MediaSessionCompat, the MediaStyle notification, MediaButtonReceiver + implementation("androidx.media:media:1.7.0") + implementation(project(":tauri-android")) +} diff --git a/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c38a29c --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt new file mode 100644 index 0000000..6de91a8 --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt @@ -0,0 +1,315 @@ +package com.soulstream.mediasession + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import android.os.IBinder +import android.os.SystemClock +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Base64 +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import androidx.media.session.MediaButtonReceiver + +/** + * What is playing, shared between the plugin (which hears the WebView) and + * the service (which owns the session). The service is started on the first + * update and refreshed on every one after. + */ +object MediaHub { + @Volatile var onAction: ((String, Double) -> Unit)? = null + @Volatile var state: NowPlayingArgs? = null + @Volatile var art: Bitmap? = null + @Volatile var artKey: String? = null + @Volatile var service: MediaService? = null + + fun update(ctx: Context, np: NowPlayingArgs) { + if (np.artKey != artKey) { + artKey = np.artKey + art = null + } + np.art?.let { b64 -> + try { + val bytes = Base64.decode(b64, Base64.DEFAULT) + art = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } catch (_: Exception) { + /* an unreadable cover is no cover */ + } + } + state = np + val running = service + if (running == null) { + /* Android 12+ refuses a foreground start from the background; the + card simply waits for the next update from the foreground */ + try { + ContextCompat.startForegroundService(ctx, Intent(ctx, MediaService::class.java)) + } catch (e: Exception) { + android.util.Log.w("SoulStream", "media service could not start: $e") + } + } else { + running.refresh() + } + } + + fun clear(ctx: Context) { + state = null + val running = service + if (running != null) running.stopSelf() else ctx.stopService(Intent(ctx, MediaService::class.java)) + } + + fun act(action: String, position: Double = -1.0) { + onAction?.invoke(action, position) + } +} + +/** + * The foreground service: a MediaSession the system reads for the lock + * screen and the shade, a MediaStyle notification with the transport, audio + * focus (pause when another app takes the output, or the headphones come + * out), and the reason the process is not frozen in the background. The + * audio never passes through here. + */ +class MediaService : Service() { + companion object { + const val CHANNEL = "playback" + const val NOTIFICATION_ID = 1 + } + + private lateinit var session: MediaSessionCompat + private var focus: AudioFocusRequest? = null + private var pausedByFocus = false + private var noisyRegistered = false + private var inForeground = false + + private val noisy = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) MediaHub.act("pause") + } + } + + private val focusListener = AudioManager.OnAudioFocusChangeListener { change -> + when (change) { + AudioManager.AUDIOFOCUS_LOSS -> { + pausedByFocus = false + MediaHub.act("pause") + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + if (MediaHub.state?.playing == true) { + pausedByFocus = true + MediaHub.act("pause") + } + } + AudioManager.AUDIOFOCUS_GAIN -> { + if (pausedByFocus) { + pausedByFocus = false + MediaHub.act("play") + } + } + /* CAN_DUCK: the framework ducks us on its own */ + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + session = MediaSessionCompat(this, "SoulStream") + session.setCallback(object : MediaSessionCompat.Callback() { + override fun onPlay() { MediaHub.act("play") } + override fun onPause() { MediaHub.act("pause") } + override fun onSkipToNext() { MediaHub.act("next") } + override fun onSkipToPrevious() { MediaHub.act("prev") } + override fun onStop() { MediaHub.act("stop") } + override fun onSeekTo(pos: Long) { MediaHub.act("seek", pos / 1000.0) } + }) + session.isActive = true + createChannel() + /* a system broadcast reaches a non-exported receiver; Android 14 wants + the flag said out loud */ + ContextCompat.registerReceiver( + this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + noisyRegistered = true + MediaHub.service = this + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + /* a headset or card button arrives as a MEDIA_BUTTON intent */ + MediaButtonReceiver.handleIntent(session, intent) + /* Started with startForegroundService, so Android demands + startForeground() promptly WHATEVER the state — a paused restore + and an empty bar included — or it kills the whole app + (ForegroundServiceDidNotStartInTimeException: on 2026-09-13 a Save + died with the process, 4 of 14 tracks in). Promote first, with the + card there is or a plain one; refresh() may step back at once. */ + if (!inForeground) promote(buildNotification(MediaHub.state ?: NowPlayingArgs())) + refresh() + return START_NOT_STICKY + } + + private fun promote(notification: Notification) { + if (Build.VERSION.SDK_INT >= 29) { + ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + startForeground(NOTIFICATION_ID, notification) + } + inForeground = true + } + + override fun onDestroy() { + if (noisyRegistered) { + try { unregisterReceiver(noisy) } catch (_: Exception) {} + noisyRegistered = false + } + abandonFocus() + session.isActive = false + session.release() + MediaHub.service = null + super.onDestroy() + } + + /** Mirror MediaHub.state into the session and the notification. */ + fun refresh() { + val np = MediaHub.state + if (np == null) { + if (inForeground) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + inForeground = false + } + stopSelf() + return + } + val meta = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, np.title ?: "") + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, np.artist ?: "") + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, np.album ?: "") + np.duration?.let { meta.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, (it * 1000).toLong()) } + MediaHub.art?.let { + meta.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it) + meta.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, it) + } + session.setMetadata(meta.build()) + + var actions = PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_STOP + if (np.hasNext) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT + if (np.hasPrev) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + val state = PlaybackStateCompat.Builder() + .setActions(actions) + .setState( + if (np.playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, + (np.position * 1000).toLong(), + if (np.playing) 1f else 0f, + SystemClock.elapsedRealtime(), + ) + .build() + session.setPlaybackState(state) + + if (np.playing) requestFocus() + + val notification = buildNotification(np) + if (np.playing) { + promote(notification) + } else { + /* paused: the card stays, the service steps back so the system + may reclaim it and the card can be swiped away */ + if (inForeground) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + inForeground = false + } + try { + NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification) + } catch (_: SecurityException) { + /* no notification permission: the session alone still serves the lock screen */ + } + } + } + + private fun buildNotification(np: NowPlayingArgs): Notification { + val launch = packageManager.getLaunchIntentForPackage(packageName) + val open = launch?.let { + PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + val prev = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS) + val toggle = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE) + val next = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_NEXT) + val stop = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_STOP) + return NotificationCompat.Builder(this, CHANNEL) + .setSmallIcon(R.drawable.ic_soulstream_note) + .setContentTitle(np.title ?: "SoulStream") + .setContentText(np.artist ?: "") + .setSubText(np.album) + .setLargeIcon(MediaHub.art) + .setContentIntent(open) + .setDeleteIntent(stop) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setOngoing(np.playing) + .addAction(android.R.drawable.ic_media_previous, "Previous", prev) + .addAction( + if (np.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, + if (np.playing) "Pause" else "Play", + toggle, + ) + .addAction(android.R.drawable.ic_media_next, "Next", next) + .setStyle( + androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(session.sessionToken) + .setShowActionsInCompactView(0, 1, 2), + ) + .build() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < 26) return + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (manager.getNotificationChannel(CHANNEL) != null) return + val channel = NotificationChannel(CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW) + channel.description = "What is playing" + channel.setShowBadge(false) + channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + manager.createNotificationChannel(channel) + } + + private fun requestFocus() { + if (focus != null) return + val manager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + ) + .setOnAudioFocusChangeListener(focusListener) + .build() + if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) focus = request + } + + private fun abandonFocus() { + val request = focus ?: return + (getSystemService(Context.AUDIO_SERVICE) as AudioManager).abandonAudioFocusRequest(request) + focus = null + } +} diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt new file mode 100644 index 0000000..5968d6b --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt @@ -0,0 +1,77 @@ +package com.soulstream.mediasession + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin + +/** What the card shows — the Rust `NowPlaying`, camelCase. */ +@InvokeArg +class NowPlayingArgs { + var title: String? = null + var artist: String? = null + var album: String? = null + var playing: Boolean = false + var position: Double = 0.0 + var duration: Double? = null + /** identifies the cover; the bytes only come along when it changes */ + var artKey: String? = null + /** the cover, base64 image bytes */ + var art: String? = null + var hasNext: Boolean = true + var hasPrev: Boolean = true +} + +/** + * The bridge between the WebView and the service that owns the session. + * `update` mirrors the track on the bar (and starts the service the first + * time); `clear` takes the card down. The session's buttons come back as + * the plugin event `action` with `{ action, position }`. + */ +@TauriPlugin +class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) { + private var askedForNotifications = false + + init { + MediaHub.onAction = { action, position -> + val payload = JSObject() + payload.put("action", action) + payload.put("position", position) + trigger("action", payload) + } + } + + @Command + fun update(invoke: Invoke) { + val args = invoke.parseArgs(NowPlayingArgs::class.java) + ensureNotificationPermission() + MediaHub.update(activity.applicationContext, args) + invoke.resolve() + } + + @Command + fun clear(invoke: Invoke) { + MediaHub.clear(activity.applicationContext) + invoke.resolve() + } + + /** Android 13+: the card is a notification and needs the permission; asked + * once, the first time something plays. Media controls in the shade and + * on the lock screen follow the session either way. */ + private fun ensureNotificationPermission() { + if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return + askedForNotifications = true + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED + ) return + ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4711) + } +} diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_soulstream_note.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_soulstream_note.xml new file mode 100644 index 0000000..7a137d2 --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_soulstream_note.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/crates/tauri-plugin-media-session/build.rs b/crates/tauri-plugin-media-session/build.rs new file mode 100644 index 0000000..dd4f9db --- /dev/null +++ b/crates/tauri-plugin-media-session/build.rs @@ -0,0 +1,15 @@ +// registerListener / removeListener are the mobile plugin runtime's own +// commands, the ones `addPluginListener` invokes to hear the `action` +// event. They are commands like any other to the ACL: without them here the +// listener was refused ("media-session.registerListener not allowed") and +// the card's buttons went nowhere. +const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"]; + +fn main() { + // Registers the command permissions (permissions/autogenerated) and, on + // Android, hands the Gradle module under android/ to tauri-build, which + // includes it in the app project (tauri.settings.gradle). + tauri_plugin::Builder::new(COMMANDS) + .android_path("android") + .build(); +} diff --git a/crates/tauri-plugin-media-session/permissions/autogenerated/commands/clear.toml b/crates/tauri-plugin-media-session/permissions/autogenerated/commands/clear.toml new file mode 100644 index 0000000..83de181 --- /dev/null +++ b/crates/tauri-plugin-media-session/permissions/autogenerated/commands/clear.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-clear" +description = "Enables the clear command without any pre-configured scope." +commands.allow = ["clear"] + +[[permission]] +identifier = "deny-clear" +description = "Denies the clear command without any pre-configured scope." +commands.deny = ["clear"] diff --git a/crates/tauri-plugin-media-session/permissions/autogenerated/commands/registerListener.toml b/crates/tauri-plugin-media-session/permissions/autogenerated/commands/registerListener.toml new file mode 100644 index 0000000..f035e95 --- /dev/null +++ b/crates/tauri-plugin-media-session/permissions/autogenerated/commands/registerListener.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-registerListener" +description = "Enables the registerListener command without any pre-configured scope." +commands.allow = ["registerListener"] + +[[permission]] +identifier = "deny-registerListener" +description = "Denies the registerListener command without any pre-configured scope." +commands.deny = ["registerListener"] diff --git a/crates/tauri-plugin-media-session/permissions/autogenerated/commands/removeListener.toml b/crates/tauri-plugin-media-session/permissions/autogenerated/commands/removeListener.toml new file mode 100644 index 0000000..ebc31bc --- /dev/null +++ b/crates/tauri-plugin-media-session/permissions/autogenerated/commands/removeListener.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-removeListener" +description = "Enables the removeListener command without any pre-configured scope." +commands.allow = ["removeListener"] + +[[permission]] +identifier = "deny-removeListener" +description = "Denies the removeListener command without any pre-configured scope." +commands.deny = ["removeListener"] diff --git a/crates/tauri-plugin-media-session/permissions/autogenerated/commands/update.toml b/crates/tauri-plugin-media-session/permissions/autogenerated/commands/update.toml new file mode 100644 index 0000000..92645d3 --- /dev/null +++ b/crates/tauri-plugin-media-session/permissions/autogenerated/commands/update.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-update" +description = "Enables the update command without any pre-configured scope." +commands.allow = ["update"] + +[[permission]] +identifier = "deny-update" +description = "Denies the update command without any pre-configured scope." +commands.deny = ["update"] diff --git a/crates/tauri-plugin-media-session/permissions/autogenerated/reference.md b/crates/tauri-plugin-media-session/permissions/autogenerated/reference.md new file mode 100644 index 0000000..40605d3 --- /dev/null +++ b/crates/tauri-plugin-media-session/permissions/autogenerated/reference.md @@ -0,0 +1,127 @@ +## Default Permission + +The phone's media session: the lock-screen card, the notification's +transport, audio focus. `update` mirrors the track on the bar, `clear` +takes the card down, and the two listener commands are what lets the page +hear the card's buttons (`addPluginListener`). + +#### This default permission set includes the following: + +- `allow-update` +- `allow-clear` +- `allow-registerListener` +- `allow-removeListener` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`media-session:allow-clear` + + + +Enables the clear command without any pre-configured scope. + +
+ +`media-session:deny-clear` + + + +Denies the clear command without any pre-configured scope. + +
+ +`media-session:allow-registerListener` + + + +Enables the registerListener command without any pre-configured scope. + +
+ +`media-session:deny-registerListener` + + + +Denies the registerListener command without any pre-configured scope. + +
+ +`media-session:allow-removeListener` + + + +Enables the removeListener command without any pre-configured scope. + +
+ +`media-session:deny-removeListener` + + + +Denies the removeListener command without any pre-configured scope. + +
+ +`media-session:allow-update` + + + +Enables the update command without any pre-configured scope. + +
+ +`media-session:deny-update` + + + +Denies the update command without any pre-configured scope. + +
diff --git a/crates/tauri-plugin-media-session/permissions/default.toml b/crates/tauri-plugin-media-session/permissions/default.toml new file mode 100644 index 0000000..79ea649 --- /dev/null +++ b/crates/tauri-plugin-media-session/permissions/default.toml @@ -0,0 +1,10 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +The phone's media session: the lock-screen card, the notification's +transport, audio focus. `update` mirrors the track on the bar, `clear` +takes the card down, and the two listener commands are what lets the page +hear the card's buttons (`addPluginListener`). +""" +permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"] diff --git a/crates/tauri-plugin-media-session/permissions/schemas/schema.json b/crates/tauri-plugin-media-session/permissions/schemas/schema.json new file mode 100644 index 0000000..65862e2 --- /dev/null +++ b/crates/tauri-plugin-media-session/permissions/schemas/schema.json @@ -0,0 +1,354 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the clear command without any pre-configured scope.", + "type": "string", + "const": "allow-clear", + "markdownDescription": "Enables the clear command without any pre-configured scope." + }, + { + "description": "Denies the clear command without any pre-configured scope.", + "type": "string", + "const": "deny-clear", + "markdownDescription": "Denies the clear command without any pre-configured scope." + }, + { + "description": "Enables the registerListener command without any pre-configured scope.", + "type": "string", + "const": "allow-registerListener", + "markdownDescription": "Enables the registerListener command without any pre-configured scope." + }, + { + "description": "Denies the registerListener command without any pre-configured scope.", + "type": "string", + "const": "deny-registerListener", + "markdownDescription": "Denies the registerListener command without any pre-configured scope." + }, + { + "description": "Enables the removeListener command without any pre-configured scope.", + "type": "string", + "const": "allow-removeListener", + "markdownDescription": "Enables the removeListener command without any pre-configured scope." + }, + { + "description": "Denies the removeListener command without any pre-configured scope.", + "type": "string", + "const": "deny-removeListener", + "markdownDescription": "Denies the removeListener command without any pre-configured scope." + }, + { + "description": "Enables the update command without any pre-configured scope.", + "type": "string", + "const": "allow-update", + "markdownDescription": "Enables the update command without any pre-configured scope." + }, + { + "description": "Denies the update command without any pre-configured scope.", + "type": "string", + "const": "deny-update", + "markdownDescription": "Denies the update command without any pre-configured scope." + }, + { + "description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`", + "type": "string", + "const": "default", + "markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`" + } + ] + } + } +} \ No newline at end of file diff --git a/crates/tauri-plugin-media-session/src/lib.rs b/crates/tauri-plugin-media-session/src/lib.rs new file mode 100644 index 0000000..1f55146 --- /dev/null +++ b/crates/tauri-plugin-media-session/src/lib.rs @@ -0,0 +1,109 @@ +//! The media session on the phone. +//! +//! Android shows a playing app on the lock screen and in the shade through a +//! `MediaSession` carried by a foreground service, and it is that service +//! that keeps the process alive — and un-frozen — once the app is in the +//! background. The audio itself stays where it is, on the Rust audio thread; +//! the Kotlin side only mirrors what is playing and hands the card's +//! buttons, the headset's buttons and audio-focus changes back as events. +//! +//! Two commands, both fed by the phone UI (src/mobile/media.svelte.js): +//! `update` with the track on the bar, `clear` when the bar empties. The +//! session's actions arrive as the plugin event `action` +//! (`addPluginListener("media-session", "action", …)`). +//! +//! On the desktop the plugin is registered too, so the permission set +//! resolves on every platform, but its commands answer "not here": the +//! desktop has its own card (src-tauri/src/smtc.rs). + +use serde::{Deserialize, Serialize}; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +/// What the card shows. `art` carries the cover's bytes (base64) only when +/// `art_key` changed since the last update — the card keeps the bitmap. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct NowPlaying { + pub title: Option, + pub artist: Option, + pub album: Option, + #[serde(default)] + pub playing: bool, + #[serde(default)] + pub position: f64, + pub duration: Option, + pub art_key: Option, + pub art: Option, + #[serde(default = "yes")] + pub has_next: bool, + #[serde(default = "yes")] + pub has_prev: bool, +} + +fn yes() -> bool { + true +} + +#[cfg(mobile)] +pub struct MediaSession(tauri::plugin::PluginHandle); + +#[tauri::command] +fn update(app: tauri::AppHandle, state: NowPlaying) -> Result<(), String> { + #[cfg(mobile)] + { + use tauri::Manager; + let session = app.state::>(); + session + .0 + .run_mobile_plugin::("update", state) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = (app, state); + Err("the media session is a phone feature".into()) + } +} + +#[tauri::command] +fn clear(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(mobile)] + { + use tauri::Manager; + let session = app.state::>(); + session + .0 + .run_mobile_plugin::("clear", ()) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = app; + Err("the media session is a phone feature".into()) + } +} + +pub fn init() -> TauriPlugin { + Builder::new("media-session") + .invoke_handler(tauri::generate_handler![update, clear]) + .setup(|app, api| { + #[cfg(target_os = "android")] + { + use tauri::Manager; + let handle = + api.register_android_plugin("com.soulstream.mediasession", "MediaSessionPlugin")?; + app.manage(MediaSession(handle)); + } + #[cfg(not(target_os = "android"))] + { + let _ = (app, api); + } + Ok(()) + }) + .build() +} diff --git a/crates/tauri-plugin-storage/Cargo.toml b/crates/tauri-plugin-storage/Cargo.toml new file mode 100644 index 0000000..ff561eb --- /dev/null +++ b/crates/tauri-plugin-storage/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tauri-plugin-storage" +version = "0.1.0" +description = "SoulStream's storage on the phone: the folder picker (a Storage Access Framework tree, resolved to a path when the volume allows), and Android's all-files access — checked and requested — so the library can live outside the app's private folder" +edition = "2021" +rust-version = "1.77.2" +# required by tauri-plugin's build script; must equal the crate name +links = "tauri-plugin-storage" + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[build-dependencies] +tauri-plugin = { version = "2", features = ["build"] } diff --git a/crates/tauri-plugin-storage/android/.gitignore b/crates/tauri-plugin-storage/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/crates/tauri-plugin-storage/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/crates/tauri-plugin-storage/android/build.gradle.kts b/crates/tauri-plugin-storage/android/build.gradle.kts new file mode 100644 index 0000000..6e3ec32 --- /dev/null +++ b/crates/tauri-plugin-storage/android/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.soulstream.storage" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + // the picker's ActivityResult + implementation("androidx.activity:activity-ktx:1.9.3") + implementation(project(":tauri-android")) +} diff --git a/crates/tauri-plugin-storage/android/src/main/AndroidManifest.xml b/crates/tauri-plugin-storage/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c41a08a --- /dev/null +++ b/crates/tauri-plugin-storage/android/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt b/crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt new file mode 100644 index 0000000..0218d7e --- /dev/null +++ b/crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt @@ -0,0 +1,94 @@ +package com.soulstream.storage + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.Settings +import androidx.activity.result.ActivityResult +import app.tauri.annotation.ActivityCallback +import app.tauri.annotation.Command +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin + +/** + * The phone's storage for the library: a folder picked through Android's + * document tree picker (its content URI kept with a persistable grant, and + * resolved to a real path when the tree is on a volume that has one), and + * all-files access — the permission that lets the Rust side read and write + * that path under scoped storage — checked, and requested on the system + * screen that grants it. + */ +@TauriPlugin +class StoragePlugin(private val activity: Activity) : Plugin(activity) { + + @Command + fun pickFolder(invoke: Invoke) { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + startActivityForResult(invoke, intent, "folderPicked") + } + + @ActivityCallback + fun folderPicked(invoke: Invoke, result: ActivityResult) { + when (result.resultCode) { + Activity.RESULT_OK -> { + val uri = result.data?.data + if (uri == null) { invoke.reject("no folder came back"); return } + try { + activity.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } catch (_: Exception) { /* the grant is a courtesy; the path is what counts */ } + val out = JSObject() + out.put("uri", uri.toString()) + out.put("path", pathOf(uri)) + invoke.resolve(out) + } + Activity.RESULT_CANCELED -> invoke.reject("cancelled") + else -> invoke.reject("the picker failed") + } + } + + /** "primary:Music/Flac" → /storage/emulated/0/Music/Flac; an SD card's + * "1234-ABCD:Music" → /storage/1234-ABCD/Music; anything else (a cloud + * provider's tree) has no path. */ + private fun pathOf(uri: Uri): String? { + val id = try { DocumentsContract.getTreeDocumentId(uri) } catch (_: Exception) { return null } + val i = id.indexOf(':') + if (i < 0) return null + val volume = id.substring(0, i) + val rel = id.substring(i + 1).trimEnd('/') + if (uri.authority != "com.android.externalstorage.documents") return null + val root = if (volume == "primary") Environment.getExternalStorageDirectory().absolutePath else "/storage/$volume" + return if (rel.isEmpty()) root else "$root/$rel" + } + + @Command + fun allFilesAccess(invoke: Invoke) { + val out = JSObject() + out.put("granted", Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager()) + invoke.resolve(out) + } + + @Command + fun requestAllFilesAccess(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val intent = Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:" + activity.packageName) + ) + activity.startActivity(intent) + } + invoke.resolve() + } +} diff --git a/crates/tauri-plugin-storage/build.rs b/crates/tauri-plugin-storage/build.rs new file mode 100644 index 0000000..72b7c1b --- /dev/null +++ b/crates/tauri-plugin-storage/build.rs @@ -0,0 +1,13 @@ +// The three commands, as the Rust functions are named; the ACL slugs them +// (`allow-pick-folder`, …) into permissions/autogenerated. +const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access"]; + +fn main() { + // Registers the command permissions and, on Android, hands the Gradle + // module under android/ to tauri-build, which includes it in the app + // project (tauri.settings.gradle) — only with TAURI_ANDROID_PROJECT_PATH + // set, see ANDROID.md. + tauri_plugin::Builder::new(COMMANDS) + .android_path("android") + .build(); +} diff --git a/crates/tauri-plugin-storage/permissions/autogenerated/commands/all_files_access.toml b/crates/tauri-plugin-storage/permissions/autogenerated/commands/all_files_access.toml new file mode 100644 index 0000000..d0841ac --- /dev/null +++ b/crates/tauri-plugin-storage/permissions/autogenerated/commands/all_files_access.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-all-files-access" +description = "Enables the all_files_access command without any pre-configured scope." +commands.allow = ["all_files_access"] + +[[permission]] +identifier = "deny-all-files-access" +description = "Denies the all_files_access command without any pre-configured scope." +commands.deny = ["all_files_access"] diff --git a/crates/tauri-plugin-storage/permissions/autogenerated/commands/pick_folder.toml b/crates/tauri-plugin-storage/permissions/autogenerated/commands/pick_folder.toml new file mode 100644 index 0000000..23aacce --- /dev/null +++ b/crates/tauri-plugin-storage/permissions/autogenerated/commands/pick_folder.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-pick-folder" +description = "Enables the pick_folder command without any pre-configured scope." +commands.allow = ["pick_folder"] + +[[permission]] +identifier = "deny-pick-folder" +description = "Denies the pick_folder command without any pre-configured scope." +commands.deny = ["pick_folder"] diff --git a/crates/tauri-plugin-storage/permissions/autogenerated/commands/request_all_files_access.toml b/crates/tauri-plugin-storage/permissions/autogenerated/commands/request_all_files_access.toml new file mode 100644 index 0000000..a21288f --- /dev/null +++ b/crates/tauri-plugin-storage/permissions/autogenerated/commands/request_all_files_access.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-request-all-files-access" +description = "Enables the request_all_files_access command without any pre-configured scope." +commands.allow = ["request_all_files_access"] + +[[permission]] +identifier = "deny-request-all-files-access" +description = "Denies the request_all_files_access command without any pre-configured scope." +commands.deny = ["request_all_files_access"] diff --git a/crates/tauri-plugin-storage/permissions/autogenerated/reference.md b/crates/tauri-plugin-storage/permissions/autogenerated/reference.md new file mode 100644 index 0000000..9f4810f --- /dev/null +++ b/crates/tauri-plugin-storage/permissions/autogenerated/reference.md @@ -0,0 +1,99 @@ +## Default Permission + +The phone's storage: pick a library folder (a Storage Access Framework tree, +resolved to a path when the volume allows), and Android's all-files access — +whether it is granted, and the system screen that grants it. + +#### This default permission set includes the following: + +- `allow-pick-folder` +- `allow-all-files-access` +- `allow-request-all-files-access` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`storage:allow-all-files-access` + + + +Enables the all_files_access command without any pre-configured scope. + +
+ +`storage:deny-all-files-access` + + + +Denies the all_files_access command without any pre-configured scope. + +
+ +`storage:allow-pick-folder` + + + +Enables the pick_folder command without any pre-configured scope. + +
+ +`storage:deny-pick-folder` + + + +Denies the pick_folder command without any pre-configured scope. + +
+ +`storage:allow-request-all-files-access` + + + +Enables the request_all_files_access command without any pre-configured scope. + +
+ +`storage:deny-request-all-files-access` + + + +Denies the request_all_files_access command without any pre-configured scope. + +
diff --git a/crates/tauri-plugin-storage/permissions/default.toml b/crates/tauri-plugin-storage/permissions/default.toml new file mode 100644 index 0000000..482705d --- /dev/null +++ b/crates/tauri-plugin-storage/permissions/default.toml @@ -0,0 +1,9 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +The phone's storage: pick a library folder (a Storage Access Framework tree, +resolved to a path when the volume allows), and Android's all-files access — +whether it is granted, and the system screen that grants it. +""" +permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access"] diff --git a/crates/tauri-plugin-storage/permissions/schemas/schema.json b/crates/tauri-plugin-storage/permissions/schemas/schema.json new file mode 100644 index 0000000..352d125 --- /dev/null +++ b/crates/tauri-plugin-storage/permissions/schemas/schema.json @@ -0,0 +1,342 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the all_files_access command without any pre-configured scope.", + "type": "string", + "const": "allow-all-files-access", + "markdownDescription": "Enables the all_files_access command without any pre-configured scope." + }, + { + "description": "Denies the all_files_access command without any pre-configured scope.", + "type": "string", + "const": "deny-all-files-access", + "markdownDescription": "Denies the all_files_access command without any pre-configured scope." + }, + { + "description": "Enables the pick_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-pick-folder", + "markdownDescription": "Enables the pick_folder command without any pre-configured scope." + }, + { + "description": "Denies the pick_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-pick-folder", + "markdownDescription": "Denies the pick_folder command without any pre-configured scope." + }, + { + "description": "Enables the request_all_files_access command without any pre-configured scope.", + "type": "string", + "const": "allow-request-all-files-access", + "markdownDescription": "Enables the request_all_files_access command without any pre-configured scope." + }, + { + "description": "Denies the request_all_files_access command without any pre-configured scope.", + "type": "string", + "const": "deny-request-all-files-access", + "markdownDescription": "Denies the request_all_files_access command without any pre-configured scope." + }, + { + "description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`", + "type": "string", + "const": "default", + "markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`" + } + ] + } + } +} \ No newline at end of file diff --git a/crates/tauri-plugin-storage/src/lib.rs b/crates/tauri-plugin-storage/src/lib.rs new file mode 100644 index 0000000..e9cfe8d --- /dev/null +++ b/crates/tauri-plugin-storage/src/lib.rs @@ -0,0 +1,111 @@ +//! Storage on the phone. +//! +//! The library on Android lives in the app's private folder unless the user +//! points it somewhere else. Pointing it somewhere else needs two things the +//! WebView cannot do: a folder picker (Android's document tree picker, whose +//! answer is a content URI — resolved here to a real path when the volume +//! allows, which the Rust scanner and the download pipeline work on), and +//! for a folder in shared storage, Android's all-files access, which only +//! the user can grant on a system screen. +//! +//! Three commands: `pick_folder` → `{ uri, path }` (path null when the tree +//! is not on a volume with a path), `all_files_access` → `{ granted }`, and +//! `request_all_files_access`, which opens the system screen. On the desktop +//! the plugin is registered so the permission set resolves, and every +//! command answers "not here". + +use serde::{Deserialize, Serialize}; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PickedFolder { + pub uri: String, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AccessState { + pub granted: bool, +} + +#[cfg(mobile)] +pub struct Storage(tauri::plugin::PluginHandle); + +#[tauri::command] +async fn pick_folder(app: tauri::AppHandle) -> Result { + #[cfg(mobile)] + { + use tauri::Manager; + let storage = app.state::>(); + storage + .0 + .run_mobile_plugin::("pickFolder", ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = app; + Err("the folder picker here is the desktop's dialog".into()) + } +} + +#[tauri::command] +fn all_files_access(app: tauri::AppHandle) -> Result { + #[cfg(mobile)] + { + use tauri::Manager; + let storage = app.state::>(); + storage + .0 + .run_mobile_plugin::("allFilesAccess", ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = app; + Ok(AccessState { granted: true }) + } +} + +#[tauri::command] +fn request_all_files_access(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(mobile)] + { + use tauri::Manager; + let storage = app.state::>(); + storage + .0 + .run_mobile_plugin::("requestAllFilesAccess", ()) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = app; + Ok(()) + } +} + +pub fn init() -> TauriPlugin { + Builder::new("storage") + .invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access]) + .setup(|app, api| { + #[cfg(target_os = "android")] + { + use tauri::Manager; + let handle = api.register_android_plugin("com.soulstream.storage", "StoragePlugin")?; + app.manage(Storage(handle)); + } + #[cfg(not(target_os = "android"))] + { + let _ = (app, api); + } + Ok(()) + }) + .build() +} diff --git a/mobile.html b/mobile.html new file mode 100644 index 0000000..c2589f3 --- /dev/null +++ b/mobile.html @@ -0,0 +1,13 @@ + + + + + + + SoulStream + + +
+ + + diff --git a/package-lock.json b/package-lock.json index e923150..a6eb016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.8.2-alpha", "license": "ISC", "dependencies": { - "@tauri-apps/api": "^2" + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2.5.5" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5", @@ -1137,6 +1138,14 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.5.tgz", + "integrity": "sha512-xvzGai5aQds8j8R8RsUK/lW6pGG50YgOYIPLzvkqmkwAj7dfySOD7sGtejRzvVdMmv1EQfKVEFh1MvmDp8QR0g==", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", diff --git a/package.json b/package.json index 0ae94b8..3f2aae9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "vite": "^6" }, "dependencies": { - "@tauri-apps/api": "^2" + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2.5.5" } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7bdd600..68a5f3f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,8 +43,6 @@ realfft = "3" # XSPF playlist import (playlist_formats.rs). quick-xml never resolves DTDs # or external entities, which is exactly the safety posture the importer wants. quick-xml = "0.37" -# LRCLIB lyrics client (lyrics.rs). Already in the tree via the metadata crate. -reqwest = { version = "0.12", features = ["json"] } keyring = { version = "3", features = ["windows-native"] } sysinfo = "0.33" soulseek-proto = { path = "../crates/soulseek-proto" } @@ -56,8 +54,29 @@ tagger = { path = "../crates/tagger" } metadata = { path = "../crates/metadata" } library = { path = "../crates/library" } audio-formats = { path = "../crates/audio-formats" } -tauri-plugin-updater = "2" +# The phone's lock-screen card and background playback (a Kotlin foreground +# service). Registered everywhere so the permission set resolves on every +# platform; on the desktop its commands answer "not here". +tauri-plugin-media-session = { path = "../crates/tauri-plugin-media-session" } +tauri-plugin-storage = { path = "../crates/tauri-plugin-storage" } semver = "1" +tauri-plugin-opener = "2.5.5" + +# LRCLIB lyrics client (lyrics.rs). Already in the tree via the metadata crate. +# Desktop keeps the platform TLS (schannel on Windows); Android has no OpenSSL +# to build against, so it takes rustls with the bundled roots instead. +[target.'cfg(not(target_os = "android"))'.dependencies] +reqwest = { version = "0.12", features = ["json"] } +# Release checks and the silent install are a desktop story; the phone gets +# its builds from the store or a sideload, and the plugin is desktop-only. +tauri-plugin-updater = "2" + +[target.'cfg(target_os = "android")'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# JNI_OnLoad (android_glue.rs): hands the JavaVM and the app Context to +# ndk-context, which cpal's AAudio backend reads and tao 0.35 never fills. +jni = "0.21" +ndk-context = "0.1" [target.'cfg(windows)'.dependencies] windows = { version = "0.61.3", features = [ diff --git a/src-tauri/android/build.sh b/src-tauri/android/build.sh new file mode 100644 index 0000000..49ed027 --- /dev/null +++ b/src-tauri/android/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# The detour build (ANDROID.md): cargo → strip → copy → gradle. Run from anywhere. +set -euo pipefail +cd /f/Coding/SoulStream +source src-tauri/android/env.sh + +echo "== cargo $(date +%T)" +cargo build --lib -p app --target aarch64-linux-android --features tauri/custom-protocol 2>&1 | grep -v "^\s*Compiling\|^\s*Fresh" +# the grep above hides cargo's exit code; a failed compile must not package +# the previous library as if it were this one +test "${PIPESTATUS[0]}" -eq 0 || { echo "cargo failed — not packaging"; exit 1; } +SO=target/aarch64-linux-android/debug/libapp_lib.so +test -f "$SO" || { echo "no .so built"; exit 1; } +echo "== strip $(date +%T)" +DEST=src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a +mkdir -p "$DEST" +cp "$SO" "$DEST/libapp_lib.so" +"$BIN/llvm-strip.exe" --strip-debug "$DEST/libapp_lib.so" +ls -la "$DEST/libapp_lib.so" +echo "== gradle $(date +%T)" +cd src-tauri/gen/android +./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug -q 2>&1 | grep -v "^$" | tail -20 || true +cd /f/Coding/SoulStream +APK=src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk +ls -la "$APK" +echo "== done $(date +%T)" diff --git a/src-tauri/android/deploy.sh b/src-tauri/android/deploy.sh new file mode 100644 index 0000000..830d915 --- /dev/null +++ b/src-tauri/android/deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# frontend → detour build → install on the connected phone → launch → screenshot +set -euo pipefail +cd /f/Coding/SoulStream +export MSYS_NO_PATHCONV=1 +ADB="$(cygpath -u "$LOCALAPPDATA")/Android/Sdk/platform-tools/adb.exe" +echo "== vite $(date +%T)" +npx vite build 2>&1 | grep -i "error\|built in" || true +bash src-tauri/android/build.sh +APK=src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk +echo "== install $(date +%T)" +"$ADB" install -r "$APK" 2>&1 | tail -2 +# the media card is a notification; a debug install grants the permission up +# front so the first play never stops on the system's dialog +"$ADB" shell pm grant com.soulstream.app android.permission.POST_NOTIFICATIONS 2>/dev/null || true +"$ADB" shell am force-stop com.soulstream.app +# launch and screenshot only when the phone is ours to drive: SoulStream or +# the launcher on screen. Never take the screen from another app. +FOCUS=$("$ADB" shell dumpsys window 2>/dev/null | grep -a "mCurrentFocus" | head -1) +if echo "$FOCUS" | grep -qi "soulstream\|launcher"; then + "$ADB" shell am start -n com.soulstream.app/.MainActivity >/dev/null + sleep 5 + OUT="${SS_SHOT_DIR:-$(cygpath -u "$TEMP")}" + "$ADB" exec-out screencap -p > "$OUT/px-1.png" + echo "== shot $(date +%T) -> $OUT/px-1.png" +else + echo "== installed; not launched (the phone is in use: $FOCUS)" +fi diff --git a/src-tauri/android/dev.sh b/src-tauri/android/dev.sh new file mode 100644 index 0000000..d5d0f23 --- /dev/null +++ b/src-tauri/android/dev.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# The DEV flavour (ANDROID.md → "Iterating without cargo"): the detour build +# WITHOUT tauri's custom-protocol feature, so the library embeds the dev URL +# (http://localhost:1421) instead of dist/. On the phone, `adb reverse` makes +# the phone's localhost:1421 THIS machine's Vite dev server over the USB +# cable — no LAN address, no firewall hole, hot reload — and cargo runs only +# when Rust changes. Both flavours share target/: switching flavours +# recompiles tauri and the app crate once (~3 min), the leaf crates stay +# fresh — cheaper than a second 15 GB cache on this drive. +# +# bash src-tauri/android/dev.sh # build (when Rust changed) + install + tunnel + launch +# bash src-tauri/android/dev.sh --no-build # reinstall the last dev library, re-tunnel, launch +# bash src-tauri/android/dev.sh --build-only # just the library (the phone is busy or absent) +# bash src-tauri/android/deploy.sh # the embedded build again (what the phone keeps) +# +# Then start the "frontend" dev server (npm run dev / the Browser pane's +# "frontend" launch config). The phone needs it running — and the cable. +set -euo pipefail +cd /f/Coding/SoulStream +export MSYS_NO_PATHCONV=1 +source src-tauri/android/env.sh + +if [ "${1:-}" != "--no-build" ]; then + echo "== cargo (dev flavour) $(date +%T)" + cargo build --lib -p app --target aarch64-linux-android 2>&1 | grep -v "^\s*Compiling\|^\s*Fresh" + test "${PIPESTATUS[0]}" -eq 0 || { echo "cargo failed — not packaging"; exit 1; } + [ "${1:-}" = "--build-only" ] && { echo "== built $(date +%T)"; exit 0; } +fi +SO=target/aarch64-linux-android/debug/libapp_lib.so +test -f "$SO" || { echo "no dev .so built"; exit 1; } +echo "== strip $(date +%T)" +DEST=src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a +mkdir -p "$DEST" +cp "$SO" "$DEST/libapp_lib.so" +"$BIN/llvm-strip.exe" --strip-debug "$DEST/libapp_lib.so" +echo "== gradle $(date +%T)" +cd src-tauri/gen/android +./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug -q 2>&1 | grep -v "^$" | tail -20 || true +cd /f/Coding/SoulStream +APK=src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk +echo "== install $(date +%T)" +"$ADB" install -r "$APK" 2>&1 | tail -1 +"$ADB" shell pm grant com.soulstream.app android.permission.POST_NOTIFICATIONS 2>/dev/null || true +# the tunnel: the phone's localhost:1421 → this machine's 1421 (Vite) +"$ADB" reverse tcp:1421 tcp:1421 +"$ADB" shell am force-stop com.soulstream.app +FOCUS=$("$ADB" shell dumpsys window 2>/dev/null | grep -a "mCurrentFocus" | head -1) +if echo "$FOCUS" | grep -qi "soulstream\|launcher"; then + "$ADB" shell am start -n com.soulstream.app/.MainActivity >/dev/null + echo "== launched $(date +%T) — serve the frontend on 1421 (npm run dev) if it is not up" +else + echo "== installed; not launched (the phone is in use: $FOCUS)" +fi diff --git a/src-tauri/android/env.sh b/src-tauri/android/env.sh new file mode 100644 index 0000000..a2acd4e --- /dev/null +++ b/src-tauri/android/env.sh @@ -0,0 +1,23 @@ +# The Android toolchain, for the detour scripts (build.sh, dev.sh). Sourced, +# never run. A bash session started before the user variables were set does +# not see them, so everything is spelled out here. +SDK="$(cygpath -u "$LOCALAPPDATA")/Android/Sdk" +NDKV=28.2.13676358 +BIN="$SDK/ndk/$NDKV/toolchains/llvm/prebuilt/windows-x86_64/bin" +export ANDROID_HOME="$(cygpath -w "$SDK")" +export ANDROID_SDK_ROOT="$ANDROID_HOME" +export NDK_HOME="$(cygpath -w "$SDK/ndk/$NDKV")" +export ANDROID_NDK_HOME="$NDK_HOME" +export ANDROID_NDK_ROOT="$NDK_HOME" +export JAVA_HOME="C:\Program Files\Java\jdk-17" +export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$(cygpath -w "$BIN/aarch64-linux-android26-clang.cmd")" +export CC_aarch64_linux_android="$(cygpath -w "$BIN/aarch64-linux-android26-clang.cmd")" +export CXX_aarch64_linux_android="$(cygpath -w "$BIN/aarch64-linux-android26-clang++.cmd")" +export AR_aarch64_linux_android="$(cygpath -w "$BIN/llvm-ar.exe")" +export PATH="$SDK/platform-tools:$PATH" +# tauri-build regenerates gen/android/tauri.settings.gradle and +# app/tauri.build.gradle.kts — the lines that include every plugin's Android +# module — ONLY when the CLI's variable names the project. Without it a new +# plugin (the media session) compiled fine and never reached the APK. +export TAURI_ANDROID_PROJECT_PATH="$(cygpath -w "$PWD/src-tauri/gen/android")" +ADB="$SDK/platform-tools/adb.exe" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index dc123fc..ac259ec 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,6 +19,9 @@ "core:window:allow-set-focus", "core:window:allow-set-size", "core:window:allow-set-always-on-top", - "dialog:allow-open" + "dialog:allow-open", + "media-session:default", + "opener:default", + "storage:default" ] } diff --git a/src-tauri/src/android_glue.rs b/src-tauri/src/android_glue.rs new file mode 100644 index 0000000..22d2845 --- /dev/null +++ b/src-tauri/src/android_glue.rs @@ -0,0 +1,56 @@ +//! Android's JNI hand-off. +//! +//! cpal's AAudio backend (and anything else built on the `ndk` crates) asks +//! the `ndk-context` crate for the JavaVM and an Android Context. Somebody has +//! to put them there; tao 0.37 does, but the tao this Tauri resolves (0.35) +//! keeps its own copy and never publishes it, so the audio thread panicked on +//! its first breath with "android context was not initialized". +//! +//! `JNI_OnLoad` runs the moment `System.loadLibrary("app_lib")` loads us, +//! before any activity exists. The Application object is reachable then +//! through `ActivityThread.currentApplication()`, which is what every +//! "give me a Context from native code" library relies on. Nothing else in +//! the tree defines `JNI_OnLoad` (checked: wry 0.55, tao 0.35), so the export +//! is ours to make. + +use jni::objects::JObject; +use jni::sys::{jint, JavaVM as RawJavaVm, JNI_VERSION_1_6}; +use jni::JavaVM; + +#[no_mangle] +pub extern "system" fn JNI_OnLoad(vm: *mut RawJavaVm, _reserved: *mut std::ffi::c_void) -> jint { + let Ok(jvm) = (unsafe { JavaVM::from_raw(vm) }) else { + return JNI_VERSION_1_6; + }; + // The loading thread is a Java thread, already attached. + let Ok(mut env) = jvm.get_env() else { + return JNI_VERSION_1_6; + }; + let app: Option = env + .call_static_method( + "android/app/ActivityThread", + "currentApplication", + "()Landroid/app/Application;", + &[], + ) + .and_then(|v| v.l()) + .ok() + .filter(|o| !o.is_null()); + match app.and_then(|a| env.new_global_ref(&a).ok()) { + Some(global) => { + unsafe { + ndk_context::initialize_android_context( + vm.cast(), + global.as_obj().as_raw().cast(), + ); + } + // The context lives as long as the process; the global ref must too. + std::mem::forget(global); + log::info!("android: JNI context published for the audio backend"); + } + None => { + eprintln!("android: could not reach ActivityThread.currentApplication(); audio output will be unavailable"); + } + } + JNI_VERSION_1_6 +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 31e76b0..8c40ce9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -284,11 +284,22 @@ pub fn forget_credentials(app: tauri::AppHandle) -> Result<(), String> { /// Open a native folder picker; returns the chosen path or null. #[tauri::command] pub async fn pick_folder(app: tauri::AppHandle) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - let (tx, rx) = tokio::sync::oneshot::channel(); - app.dialog().file().pick_folder(move |path| { - let _ = tx.send(path.map(|p| p.to_string())); - }); + let (tx, rx) = tokio::sync::oneshot::channel::>(); + // A folder picker is a desktop dialog; Android hands folders over through + // the Storage Access Framework, which is its own story (the mockup's + // Import step 1). Until then the phone answers "nothing picked". + #[cfg(desktop)] + { + use tauri_plugin_dialog::DialogExt; + app.dialog().file().pick_folder(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + } + #[cfg(mobile)] + { + let _ = app; + let _ = tx.send(None); + } Ok(rx.await.unwrap_or(None)) } @@ -5369,6 +5380,7 @@ pub async fn mini_close(app: tauri::AppHandle) -> Result<(), String> { /// modal move/size loop, and a maximize/restore (Kyle photographed the /// desktop punching through a maximized window on 2026-08-28 — the accent /// did not survive the transition on its own). +#[cfg(windows)] pub(crate) static GLASS_ON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -5378,12 +5390,14 @@ pub(crate) static GLASS_ON: std::sync::atomic::AtomicBool = /// swaps down to it for the length of a gesture and back up on release /// (see thumbbar.rs). Same struct, one field different. #[derive(Clone, Copy, PartialEq)] +#[cfg(windows)] pub(crate) enum Accent { Off, Blur, Acrylic, } +#[cfg(windows)] pub(crate) fn apply_accent(hwnd: *mut std::ffi::c_void, accent: Accent) -> Result<(), String> { #[repr(C)] struct AccentPolicy { @@ -5451,6 +5465,7 @@ pub(crate) fn apply_accent(hwnd: *mut std::ffi::c_void, accent: Accent) -> Resul /// (2026-08-29). So transitions are now explicitly forced ON (clearing any /// disable a previous build left on this window), and the discrete bracket /// in thumbbar.rs + GROUND_DISCRETE in glass.js covers the detach window. +#[cfg(windows)] fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) { #[allow(non_snake_case)] #[link(name = "kernel32")] @@ -5481,17 +5496,27 @@ fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) { #[tauri::command] pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String> { - // Clear any system backdrop a previous toggle/build may have applied. - let _ = window.set_effects(None::); - let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void; - // The mini calls this too (window_glass acts on the CALLING window), and - // the subclass re-asserting from thumbbar.rs is on the MAIN window only — - // so only the main window's state is tracked. - if window.label() == "main" { - GLASS_ON.store(on, std::sync::atomic::Ordering::Relaxed); + // The glass is the Windows compositor's acrylic; a phone's ground is the + // artwork itself (see mobile.css), so there is nothing to apply here. + #[cfg(not(windows))] + { + let _ = (window, on); + return Ok(()); + } + #[cfg(windows)] + { + // Clear any system backdrop a previous toggle/build may have applied. + let _ = window.set_effects(None::); + let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void; + // The mini calls this too (window_glass acts on the CALLING window), and + // the subclass re-asserting from thumbbar.rs is on the MAIN window only — + // so only the main window's state is tracked. + if window.label() == "main" { + GLASS_ON.store(on, std::sync::atomic::Ordering::Relaxed); + } + set_transitions_disabled(hwnd, false); + apply_accent(hwnd, if on { Accent::Acrylic } else { Accent::Off }) } - set_transitions_disabled(hwnd, false); - apply_accent(hwnd, if on { Accent::Acrylic } else { Accent::Off }) } #[cfg(test)] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 91f8008..a1653de 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -329,6 +329,16 @@ pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf { return PathBuf::from(d); } } + // Android's public Music folder is not a folder an app may write to by + // path (scoped storage), so the phone's library lives under the app's own + // data directory until the folder-picker story (SAF) lands. Everything + // the library keeps about itself travels with it there, as on the desktop. + #[cfg(target_os = "android")] + { + if let Ok(dir) = app.path().app_data_dir() { + return dir.join("Library"); + } + } let music = app .path() .audio_dir() @@ -494,6 +504,54 @@ pub fn library_layout(cfg: &AppConfig) -> &str { } // ---- password storage (Windows Credential Manager) ---- +// +// Android has no credential manager the keyring crate speaks to, so the +// phone keeps its secrets in a file under the app's private data directory — +// which is what Android's own account storage amounts to for an app: sealed +// per package, wiped with the app, never on a shared drive. The directory +// is remembered at start-up (`remember_secrets_dir`), because the password +// helpers have no app handle of their own. + +#[cfg(target_os = "android")] +static SECRETS_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Called once at start-up with the app data directory. +pub fn remember_secrets_dir(app: &tauri::AppHandle) { + #[cfg(target_os = "android")] + { + if let Ok(dir) = crate::paths::app_data(app) { + let _ = SECRETS_DIR.set(dir); + } + } + #[cfg(not(target_os = "android"))] + { + let _ = app; + } +} + +#[cfg(target_os = "android")] +fn secrets_path() -> Option { + let dir = SECRETS_DIR.get()?; + std::fs::create_dir_all(dir).ok()?; + Some(dir.join("secrets.json")) +} + +#[cfg(target_os = "android")] +fn secrets_read() -> std::collections::BTreeMap { + secrets_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +#[cfg(target_os = "android")] +fn secrets_write(map: &std::collections::BTreeMap) { + if let (Some(p), Ok(json)) = (secrets_path(), serde_json::to_string(map)) { + if let Err(e) = std::fs::write(&p, json) { + log::warn!("failed to write secrets: {e}"); + } + } +} fn entry(username: &str) -> Option { keyring::Entry::new(KEYRING_SERVICE, username).ok() @@ -505,6 +563,14 @@ fn entry(username: &str) -> Option { const UPDATE_TOKEN_USER: &str = "updater-token"; pub fn save_update_token(token: &str) { + #[cfg(target_os = "android")] + { + let mut m = secrets_read(); + m.insert(UPDATE_TOKEN_USER.to_string(), token.to_string()); + secrets_write(&m); + return; + } + #[allow(unreachable_code)] if let Some(e) = entry(UPDATE_TOKEN_USER) { if let Err(err) = e.set_password(token) { log::warn!("failed to store the update token in credential manager: {err}"); @@ -513,6 +579,11 @@ pub fn save_update_token(token: &str) { } pub fn load_update_token() -> Option { + #[cfg(target_os = "android")] + { + return secrets_read().get(UPDATE_TOKEN_USER).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + } + #[allow(unreachable_code)] entry(UPDATE_TOKEN_USER) .and_then(|e| e.get_password().ok()) .map(|t| t.trim().to_string()) @@ -520,12 +591,28 @@ pub fn load_update_token() -> Option { } pub fn clear_update_token() { + #[cfg(target_os = "android")] + { + let mut m = secrets_read(); + m.remove(UPDATE_TOKEN_USER); + secrets_write(&m); + return; + } + #[allow(unreachable_code)] if let Some(e) = entry(UPDATE_TOKEN_USER) { let _ = e.delete_credential(); } } pub fn save_password(username: &str, password: &str) { + #[cfg(target_os = "android")] + { + let mut m = secrets_read(); + m.insert(format!("slsk:{username}"), password.to_string()); + secrets_write(&m); + return; + } + #[allow(unreachable_code)] if let Some(e) = entry(username) { if let Err(err) = e.set_password(password) { log::warn!("failed to store password in credential manager: {err}"); @@ -534,6 +621,11 @@ pub fn save_password(username: &str, password: &str) { } pub fn load_password(username: &str) -> Option { + #[cfg(target_os = "android")] + { + return secrets_read().get(&format!("slsk:{username}")).cloned(); + } + #[allow(unreachable_code)] if let Some(pw) = entry(username).and_then(|e| e.get_password().ok()) { return Some(pw); } @@ -550,6 +642,14 @@ pub fn has_password(username: &str) -> bool { } pub fn delete_password(username: &str) { + #[cfg(target_os = "android")] + { + let mut m = secrets_read(); + m.remove(&format!("slsk:{username}")); + secrets_write(&m); + return; + } + #[allow(unreachable_code)] if let Some(e) = entry(username) { let _ = e.delete_credential(); } diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index f7120a4..c1daa07 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -265,6 +265,7 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS } /// Private commit ("reserved") of one process, bytes. 0 if unreadable. +#[cfg(windows)] fn private_commit(pid: u32) -> u64 { use windows::Win32::Foundation::CloseHandle; use windows::Win32::System::ProcessStatus::{ @@ -297,6 +298,7 @@ fn private_commit(pid: u32) -> u64 { /// number Task Manager labels "GPU". Instances are per-process-per-engine; /// summing the 3D ones gives machine-wide utilization. First sample after /// opening reads as None (PDH rate counters need two collections). +#[cfg(windows)] struct GpuCounter { query: windows::Win32::System::Performance::PDH_HQUERY, counter: windows::Win32::System::Performance::PDH_HCOUNTER, @@ -304,8 +306,10 @@ struct GpuCounter { // PDH handles are plain registry-ish handles; the query is only touched // under the DiagState mutex. +#[cfg(windows)] unsafe impl Send for GpuCounter {} +#[cfg(windows)] impl GpuCounter { fn open() -> Option { use windows::core::w; @@ -383,6 +387,7 @@ impl GpuCounter { } /// Parse the pid out of a "pid_1234_luid_…" counter-instance name. +#[cfg(windows)] unsafe fn instance_pid(name: windows::core::PWSTR) -> Option { let s = name.to_string().ok()?; let rest = s.strip_prefix("pid_")?; @@ -390,6 +395,7 @@ unsafe fn instance_pid(name: windows::core::PWSTR) -> Option { digits.parse().ok() } +#[cfg(windows)] impl Drop for GpuCounter { fn drop(&mut self) { use windows::Win32::System::Performance::PdhCloseQuery; @@ -398,3 +404,23 @@ impl Drop for GpuCounter { } } } + +// Off Windows there is no PDH and no process-memory API of this shape: the +// overlay's private-commit column reads 0 and the GPU column stays None. +#[cfg(not(windows))] +fn private_commit(_pid: u32) -> u64 { + 0 +} + +#[cfg(not(windows))] +struct GpuCounter; + +#[cfg(not(windows))] +impl GpuCounter { + fn open() -> Option { + None + } + fn sample(&mut self, _tree: &T) -> Option<(f32, f32)> { + None + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 17be2a8..c10a281 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,7 @@ +/// Publishes the JavaVM and the app Context to the `ndk-context` crate at +/// library load, which cpal's AAudio backend needs and tao 0.35 does not do. +#[cfg(target_os = "android")] +mod android_glue; mod commands; mod community; mod config; @@ -12,6 +16,12 @@ mod inbox; mod ledger; mod hero_detector; mod loudness; +/// The CTC aligner and the vocal separator run through Windows ML; off +/// Windows their stand-ins keep the names and answer "not here" honestly. +#[cfg(windows)] +mod lyric_align; +#[cfg(not(windows))] +#[path = "lyric_align_stub.rs"] mod lyric_align; mod lyric_prep; mod lyrics; @@ -34,7 +44,17 @@ mod overlap; mod holdings; mod state; mod thumbbar; +/// Release checks and in-place installs are a desktop story; the phone's +/// stand-in keeps the same names so nothing else needs a cfg. +#[cfg(desktop)] mod updater; +#[cfg(mobile)] +#[path = "updater_mobile.rs"] +mod updater; +#[cfg(windows)] +mod vocal_sep; +#[cfg(not(windows))] +#[path = "vocal_sep_stub.rs"] mod vocal_sep; mod youtube_playlist; mod youtube_setlist; @@ -56,9 +76,19 @@ pub fn run() { .build(), )?; app.handle().plugin(tauri_plugin_dialog::init())?; + app.handle().plugin(tauri_plugin_media_session::init())?; + // Links out of the app (an artist's socials on the phone): the + // official opener — an Android intent there, the desktop's own + // `open_external` stays for the desktop views. + app.handle().plugin(tauri_plugin_opener::init())?; + // The library folder on the phone: the picker and all-files access. + app.handle().plugin(tauri_plugin_storage::init())?; + #[cfg(desktop)] app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; // Must run before anything touches the app-data dir. config::migrate_app_data(app.handle()); + // The phone keeps its secrets in the app data dir; the helpers need its path. + config::remember_secrets_dir(app.handle()); // Prepare the small local vision model while the user is still on // the app shell, so a first artist visit pays only warm inference. hero_detector::warm_up(app.handle().clone()); @@ -245,6 +275,7 @@ pub fn run() { ytdlp::deno_download, playlists::playlist_export, playlists::library_track_search, + playlists::library_track_index, lyrics::lyrics_get, lyrics::lyrics_search, lyrics::lyrics_select, diff --git a/src-tauri/src/lyric_align_stub.rs b/src-tauri/src/lyric_align_stub.rs new file mode 100644 index 0000000..823e04c --- /dev/null +++ b/src-tauri/src/lyric_align_stub.rs @@ -0,0 +1,56 @@ +//! Lyric alignment, off Windows. The aligner (lyric_align.rs) runs the CTC +//! model through Windows ML, so on Android the model is never present, never +//! downloaded, and a passage never aligns — lyric_prep's own fallback (the +//! line estimate) carries the lyrics instead. This file stands in for the +//! module (`#[path]` in lib.rs) with the same names, so lyric_prep, vocal_sep +//! and the command list need no cfg of their own. + +use std::path::PathBuf; + +pub(crate) fn models_dir(app: &tauri::AppHandle) -> Result { + let dir = crate::paths::app_data(app) + .map_err(|e| e.to_string())? + .join("lyric-models"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +pub fn model_present(_app: &tauri::AppHandle) -> bool { + false +} + +#[tauri::command] +pub fn lyric_model_status(app: tauri::AppHandle) -> serde_json::Value { + serde_json::json!({ + "present": false, + "bytes": 0, + "aligner": crate::lyric_prep::active_aligner(&app), + "downloadBytes": 0, + "unsupported": "word alignment runs on the desktop for now", + }) +} + +#[tauri::command] +pub async fn lyric_model_download(_app: tauri::AppHandle) -> Result<(), String> { + Err("word alignment runs on the desktop for now (Windows ML); not on this platform".into()) +} + +pub struct AlignedWord { + pub start_ms: i64, + pub end_ms: i64, +} + +pub struct AlignedLine { + pub words: Vec, + pub confidence: f32, +} + +pub fn align_passage( + _app: &tauri::AppHandle, + _pcm: &[f32], + _lines: &[Vec], + _floors_ms: &[i64], + _ceils_ms: &[i64], +) -> Result, String> { + Err("aligner unavailable on this platform".into()) +} diff --git a/src-tauri/src/playlists.rs b/src-tauri/src/playlists.rs index 1bfcf2f..767d30b 100644 --- a/src-tauri/src/playlists.rs +++ b/src-tauri/src/playlists.rs @@ -699,11 +699,18 @@ pub async fn playlist_import_folder( app: tauri::AppHandle, lib: State<'_, LibraryState>, ) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - let (tx, rx) = tokio::sync::oneshot::channel(); - app.dialog().file().pick_folder(move |path| { - let _ = tx.send(path.map(|p| p.to_string())); - }); + let (tx, rx) = tokio::sync::oneshot::channel::>(); + #[cfg(desktop)] + { + use tauri_plugin_dialog::DialogExt; + app.dialog().file().pick_folder(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + } + #[cfg(mobile)] + { + let _ = tx.send(None); + } let Some(root) = rx.await.unwrap_or(None) else { return Ok(None); }; @@ -1503,6 +1510,19 @@ fn norm_text(s: &str) -> String { .collect() } +/// Every track the library knows, in index order — the phone's Tracks tab +/// reads it once and filters on the device. +#[tauri::command] +pub async fn library_track_index( + lib: State<'_, LibraryState>, +) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.track_index()) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) +} + /// Local-library track search for the unresolved-row repair panel ("Find /// match…"). Word-wise normalized substring match over artist/title/album, /// title hits first. Read-only, bounded to 30 rows. @@ -1590,7 +1610,8 @@ pub async fn playlist_export( c => c, }) .collect(); - let (tx, rx) = tokio::sync::oneshot::channel(); + let (tx, rx) = tokio::sync::oneshot::channel::>(); + // save_file exists on mobile too (the system's create-document sheet) app.dialog() .file() .set_file_name(format!("{safe_name}.{ext}")) diff --git a/src-tauri/src/updater_mobile.rs b/src-tauri/src/updater_mobile.rs new file mode 100644 index 0000000..1db094a --- /dev/null +++ b/src-tauri/src/updater_mobile.rs @@ -0,0 +1,83 @@ +//! The updater, on a phone: nothing. The phone gets its builds from a store or +//! a sideload, `tauri-plugin-updater` is desktop-only, and the desktop module +//! (updater.rs) leans on it in every function. This file stands in for that +//! module on mobile (`#[path]` in lib.rs) so the command list, the managed +//! state and `init` keep the same names and the rest of the app never needs a +//! `cfg`. Every command answers honestly: there is no update channel here. + +use serde::Serialize; +use tauri::{AppHandle, State}; + +#[derive(Default)] +pub struct UpdaterState; + +#[derive(Serialize, Clone)] +pub struct Status { + pub version: String, + pub channel: String, + pub auto_check: bool, + pub endpoint_stable: String, + pub endpoint_nightly: String, + pub default_stable: String, + pub default_nightly: String, + pub has_token: bool, + pub source_kind: String, + pub available: Option<()>, + pub checking: bool, + pub installing: bool, + pub last_check: Option, + pub last_error: Option, + pub progress: Option<()>, + pub dev_build: bool, +} + +fn status(app: &AppHandle) -> Status { + Status { + version: app.package_info().version.to_string(), + channel: "mobile".into(), + auto_check: false, + endpoint_stable: String::new(), + endpoint_nightly: String::new(), + default_stable: String::new(), + default_nightly: String::new(), + has_token: false, + source_kind: "none".into(), + available: None, + checking: false, + installing: false, + last_check: None, + last_error: None, + progress: None, + dev_build: cfg!(debug_assertions), + } +} + +pub fn init(_app: &AppHandle) {} + +#[tauri::command] +pub fn updater_status(app: AppHandle) -> Status { + status(&app) +} + +#[tauri::command] +pub async fn updater_check(app: AppHandle) -> Result { + Ok(status(&app)) +} + +#[tauri::command] +pub fn updater_set( + app: AppHandle, + _state: State<'_, UpdaterState>, + _channel: Option, + _auto_check: Option, + _endpoint_stable: Option, + _endpoint_nightly: Option, + _token: Option, +) -> Status { + status(&app) +} + +#[tauri::command] +pub async fn updater_install(_app: AppHandle) -> Result<(), String> { + Err("updates are not installed in place on this platform".into()) +} diff --git a/src-tauri/src/vocal_sep_stub.rs b/src-tauri/src/vocal_sep_stub.rs new file mode 100644 index 0000000..6f6c8d7 --- /dev/null +++ b/src-tauri/src/vocal_sep_stub.rs @@ -0,0 +1,31 @@ +//! Vocal separation, off Windows. The separator (vocal_sep.rs) runs the MDX +//! model through Windows ML; on Android there is no engine, so the model is +//! never present and a separation always fails soft — lyric_prep falls back +//! to the band-passed mixture, exactly as it does on a desktop without the +//! model. Same names as the real module (`#[path]` in lib.rs). + +pub fn model_present(_app: &tauri::AppHandle) -> bool { + false +} + +#[tauri::command] +pub fn vocal_sep_status(_app: tauri::AppHandle) -> serde_json::Value { + serde_json::json!({ + "present": false, + "bytes": 0, + "downloadBytes": 0, + "unsupported": "vocal separation runs on the desktop for now", + }) +} + +#[tauri::command] +pub async fn vocal_sep_download(_app: tauri::AppHandle) -> Result<(), String> { + Err("vocal separation runs on the desktop for now (Windows ML); not on this platform".into()) +} + +pub fn separate_vocals_16k( + _app: &tauri::AppHandle, + _path: &std::path::Path, +) -> Result, String> { + Err("separation model unavailable on this platform".into()) +} diff --git a/src-tauri/src/ytdlp.rs b/src-tauri/src/ytdlp.rs index 32a2b67..8e042f7 100644 --- a/src-tauri/src/ytdlp.rs +++ b/src-tauri/src/ytdlp.rs @@ -33,8 +33,19 @@ const RELEASE_URL: &str = const DENO_RELEASE_URL: &str = "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip"; /// Windows: no console window for spawned children. +#[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; +/// Never flash a console for a helper process on Windows; a no-op elsewhere. +fn quiet(cmd: &mut std::process::Command) -> &mut std::process::Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} + fn tools_dir(app: &tauri::AppHandle) -> Result { let dir = crate::paths::app_data(app) .map_err(|e| e.to_string())? @@ -61,10 +72,7 @@ pub fn locate(app: &tauri::AppHandle) -> Option { } fn tool_version(exe: &PathBuf) -> Option { - use std::os::windows::process::CommandExt; - let out = std::process::Command::new(exe) - .arg("--version") - .creation_flags(CREATE_NO_WINDOW) + let out = quiet(std::process::Command::new(exe).arg("--version")) .stdin(Stdio::null()) .output() .ok()?; @@ -142,10 +150,7 @@ pub async fn ytdlp_download(app: tauri::AppHandle) -> Result Option { - use std::os::windows::process::CommandExt; - let out = std::process::Command::new(exe) - .arg("--version") - .creation_flags(CREATE_NO_WINDOW) + let out = quiet(std::process::Command::new(exe).arg("--version")) .stdin(Stdio::null()) .output() .ok()?; @@ -327,7 +332,6 @@ pub fn fetch_audio_blocking( cancel: &std::sync::atomic::AtomicBool, ) -> Result { use std::io::BufRead; - use std::os::windows::process::CommandExt; if let Some(hit) = cached_audio(app, video_id) { return Ok(hit); } @@ -338,7 +342,7 @@ pub fn fetch_audio_blocking( )?; let dir = audio_cache_dir(app)?; let template = dir.join("%(id)s.%(ext)s"); - let mut child = std::process::Command::new(&exe) + let mut child = quiet(std::process::Command::new(&exe) .args([ "--no-playlist", "--newline", @@ -350,8 +354,7 @@ pub fn fetch_audio_blocking( "-o", &template.to_string_lossy(), &format!("https://www.youtube.com/watch?v={video_id}"), - ]) - .creation_flags(CREATE_NO_WINDOW) + ])) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/src-tauri/tauri.android.conf.json b/src-tauri/tauri.android.conf.json new file mode 100644 index 0000000..73ca382 --- /dev/null +++ b/src-tauri/tauri.android.conf.json @@ -0,0 +1,18 @@ +{ + "app": { + "windows": [ + { + "label": "main", + "title": "SoulStream", + "url": "mobile.html" + } + ] + }, + "bundle": { + "resources": [], + "createUpdaterArtifacts": false, + "android": { + "minSdkVersion": 26 + } + } +} diff --git a/src/lib/api.js b/src/lib/api.js index ec57eb8..4eceba2 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -1,6 +1,6 @@ // Thin wrapper over Tauri IPC so components never import tauri directly. import { invoke as tauriInvoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { listen } from "./events.js"; import { mockInvoke } from "./mockLibrary.js"; // mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1". @@ -327,6 +327,8 @@ export const lyricPrepRequest = (path, durationSecs, lines, stream = null) => }); // Local-library candidates for the unresolved-row repair panel. export const libraryTrackSearch = (query) => invoke("library_track_search", { query }); +// The whole index, for a list that filters on the device (the phone's Tracks tab). +export const libraryTrackIndex = () => invoke("library_track_index"); // ---- lyrics (see src-tauri/src/lyrics.rs) ---------------------------------- export const lyricsGet = (args) => @@ -489,6 +491,12 @@ export const importLocalArt = (sourceRoot, rel, parts) => invoke("import_local_art", { sourceRoot, rel, parts }); // Prune rows whose files are gone, then index anything new — the real rescan. export const rescanLibrary = () => invoke("rescan_library"); +// The phone's storage plugin (crates/tauri-plugin-storage): a picked folder +// as { uri, path } (path null when the tree has none), and Android's +// all-files access — { granted }, and the system screen that grants it. +export const pickLibraryFolder = () => invoke("plugin:storage|pick_folder"); +export const allFilesAccess = () => invoke("plugin:storage|all_files_access"); +export const requestAllFilesAccess = () => invoke("plugin:storage|request_all_files_access"); // Download a found album copy into (Cache)\_audition\\ for the version // comparison. Called track 1 first, then the rest — `startIndex` keeps the // second batch's indices continuing, since index 0 means "track 1". diff --git a/src/lib/events.js b/src/lib/events.js new file mode 100644 index 0000000..c876f12 --- /dev/null +++ b/src/lib/events.js @@ -0,0 +1,9 @@ +/* Backend events, behind the same switch as the IPC: with the mock library + on (localStorage.mockLibrary === "1") a listener is registered with the + mock's own bus, which the mock and the harness can fire; otherwise it is + Tauri's. Stores import `listen` from here so the phone's screens can be + walked in a plain browser with a fake network behind them. */ +import { listen as tauriListen } from "@tauri-apps/api/event"; +import { mockListen } from "./mockLibrary.js"; + +export const listen = (event, cb) => mockListen(event, cb) ?? tauriListen(event, cb); diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index 2b9f390..9fdcd5d 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -97,7 +97,6 @@ export function mockInvoke(cmd, args) { if (!enabled) return undefined; if (cmd === "library_albums") return Promise.resolve(albums); if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor)); - if (cmd === "followed_artists") return Promise.resolve([]); if (cmd === "recent_albums") return Promise.resolve([]); if (cmd === "album_tracks") return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60)); @@ -107,5 +106,256 @@ export function mockInvoke(cmd, args) { return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26)))); } if (cmd === "list_playlists") return Promise.resolve([]); + // The track index, as the library crate shapes it (TrackIndexRow) — the + // phone's Tracks tab and its library search read these. + if (cmd === "library_track_index" || cmd === "library_track_search") { + const rows = albums.flatMap((a) => + tracksFor(a.id).map((t) => ({ + title: t.title, artist: a.artist, album: a.title, + duration_secs: t.duration_secs, path: t.path, art_path: a.art_path, + }))); + if (cmd === "library_track_index") return Promise.resolve(rows); + const words = String(args?.query || "").toLowerCase().split(/\s+/).filter(Boolean); + return Promise.resolve(words.length + ? rows.filter((r) => { const hay = (r.artist + r.title + r.album).toLowerCase(); return words.every((w) => hay.includes(w)); }).slice(0, 40) + : []); + } + return mockCatalog(cmd, args) ?? mockNet(cmd, args); +} + +/* ── the fake network ─────────────────────────────────────────────────── + A Soulseek behind the phone's screens: a stored account that signs in at + boot, a room list, rooms with a few lines in them, a search answered by + five peers, and a stream that arrives over twelve seconds with its + waveform growing — enough to walk Search, Community and the rising lane + in a plain browser. Events go out over the bus below; the harness can + fire its own through window.__ssMock.emit. */ +const bus = new Map(); +/** Returns a Promise for a mocked page, or undefined to pass through. */ +export function mockListen(event, cb) { + if (!enabled) return undefined; + let set = bus.get(event); + if (!set) { set = new Set(); bus.set(event, set); } + set.add(cb); + return Promise.resolve(() => { set.delete(cb); }); +} +export function mockEmit(event, payload) { + for (const cb of bus.get(event) || []) { + try { cb({ event, payload }); } catch (e) { console.error(e); } + } +} +const later = (ms, fn) => setTimeout(fn, ms); +if (enabled && typeof window !== "undefined") window.__ssMock = { emit: mockEmit }; + +const MOCK_USER = "mockuser"; +const ROOMS = [["Lounge", 412], ["Electronic", 98], ["Vinyl Rips", 37], ["Jazz", 61], ["Lossless Only", 23]]; +const PEOPLE = ["ada", "bo", "cy", "dee"]; +const LINES = ["anyone got the 2024 remaster?", "the vinyl rip is warmer imo", "that folder is complete btw", "brb"]; +const PEERS = [ + { username: "hifi_hank", slot: true, speed: 2400, queue: 0, ext: "flac", depth: 24, rate: 96000 }, + { username: "dusty_crates", slot: true, speed: 900, queue: 0, ext: "flac", depth: 16, rate: 44100 }, + { username: "bo", slot: false, speed: 1500, queue: 3, ext: "flac", depth: 16, rate: 44100 }, + { username: "mp3_marge", slot: true, speed: 500, queue: 0, ext: "mp3", bitrate: 320 }, + { username: "slowpoke", slot: false, speed: 60, queue: 12, ext: "mp3", bitrate: 192 }, +]; +const TITLES = ["One More Time", "Aerodynamic", "Digital Love", "Harder, Better, Faster, Stronger", "Crescendolls", "Nightvision", "Superheroes", "High Life", "Something About Us", "Voyager", "Veridis Quo", "Short Circuit", "Face to Face", "Too Long"]; +function peerResponse(token, peer, query) { + const artist = /daft/i.test(query) ? "Daft Punk" : (query.split(/\s+/)[0] || "Various"); + const album = /discovery/i.test(query) ? "Discovery" : "Homework"; + const folder = "@@shared\\Music\\" + artist + "\\" + artist + " - 2001 - " + album + (peer.ext === "flac" ? " [FLAC]" : " [MP3 " + peer.bitrate + "]"); + const files = TITLES.map((t, i) => { + const name = String(i + 1).padStart(2, "0") + " - " + t + "." + peer.ext; + const duration = 200 + ((i * 37) % 180); + const size = peer.ext === "flac" ? Math.round(duration * (peer.depth === 24 ? 320000 : 110000)) : Math.round(duration * peer.bitrate * 125); + return { path: folder + "\\" + name, name, folder, size, extension: peer.ext, bitrate: peer.bitrate ?? null, duration, sample_rate: peer.rate ?? 44100, bit_depth: peer.depth ?? null, vbr: false }; + }); + return { token, username: peer.username, has_free_upload_slot: peer.slot, upload_speed: peer.speed, queue_length: peer.queue, files }; +} + +/* the catalog: one artist, two records, the songs of one of them */ +const CAT_ARTIST = { id: 27, name: "Daft Punk", picture: svgArt(210, "DP"), album_count: 4, fans: 4200000 }; +const CAT_NAMESAKE = { id: 28, name: "Daft Punk", picture: "https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000-000000-80-0-0.jpg", album_count: 1, fans: 120 }; +const CAT_ALBUMS = [ + { id: 302127, title: "Discovery", artist: "Daft Punk", cover: null, release_date: "2001-03-12", record_type: "album", track_count: 14, edition_group: 302127, is_soundtrack: false }, + { id: 302128, title: "Homework", artist: "Daft Punk", cover: null, release_date: "1997-01-20", record_type: "album", track_count: 16, edition_group: null, is_soundtrack: false }, +]; +const CAT_TRACKS = TITLES.map((t, i) => ({ + id: 3135550 + i, title: t, artist: "Daft Punk", artist_id: 27, rank: 900000 - i * 1000, + album: "Discovery", album_id: 302127, cover: null, duration_secs: 200 + ((i * 37) % 180), preview: null, track_no: i + 1, +})); +const CAT_DISCO = [ + ...CAT_ALBUMS, + { id: 302129, title: "Discovery (Deluxe Edition)", artist: "Daft Punk", cover: null, release_date: "2001-03-12", record_type: "album", track_count: 16, edition_group: 302127, is_soundtrack: false }, + { id: 302130, title: "Alive 1997", artist: "Daft Punk", cover: null, release_date: "2001-10-01", record_type: "album", track_count: 1, edition_group: null, is_soundtrack: false }, + { id: 302131, title: "One More Time", artist: "Daft Punk", cover: null, release_date: "2000-11-13", record_type: "single", track_count: 2, edition_group: null, is_soundtrack: false }, + { id: 302132, title: "Musique Vol. 1 1993–2005", artist: "Daft Punk", cover: null, release_date: "2006-04-03", record_type: "compile", track_count: 15, edition_group: null, is_soundtrack: false }, + { id: 302133, title: "TRON: Legacy (Original Motion Picture Soundtrack)", artist: "Daft Punk", cover: null, release_date: "2010-12-03", record_type: "album", track_count: 22, edition_group: null, is_soundtrack: true }, + { id: 302134, title: "Human After All", artist: "Daft Punk", cover: null, release_date: "2005-03-14", record_type: "album", track_count: 10, edition_group: null, is_soundtrack: false }, + { id: 302135, title: "Random Access Memories", artist: "Daft Punk", cover: null, release_date: "2013-05-17", record_type: "album", track_count: 13, edition_group: null, is_soundtrack: false }, +]; +/* the follows feed needs covers and dates near today */ +const dayIso = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10); +for (const a of CAT_DISCO) a.cover = svgArt((a.id * 31) % 360, a.title[0]); +CAT_DISCO.push( + { id: 302136, title: "Infinity Repeating", artist: "Daft Punk", cover: svgArt(300, "I"), release_date: dayIso(-9), record_type: "single", track_count: 2, edition_group: null, is_soundtrack: false }, + { id: 302137, title: "Alive 2027", artist: "Daft Punk", cover: svgArt(120, "A"), release_date: dayIso(21), record_type: "album", track_count: 12, edition_group: null, is_soundtrack: false }, +); +const NEWS = [ + { title: "Daft Punk announce Alive 2027 — a live record from the reunion shows", url: "https://example.com/news/1", source: "Pitchfork", date: Math.floor(Date.now() / 1000) - 3600 * 5 }, + { title: "Infinity Repeating gets a video", url: "https://example.com/news/2", source: "NME", date: Math.floor(Date.now() / 1000) - 86400 * 2 }, +]; +const RELATED = [["Justice", 411], ["Air", 412], ["Kavinsky", 413], ["Chemical Brothers", 414]].map(([name, id], i) => ({ id, name, picture: i % 2 ? null : svgArt(40 + id, name[0]), album_count: 3, fans: 900000 - id * 100 })); +let follows = []; +function mockCatalog(cmd, args) { + if (cmd === "catalog_artist") { + const id = Number(args?.artistId); + const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id); + if (!a) return Promise.reject(new Error("catalog_artist: unknown artist")); + const own = a === CAT_ARTIST; + return new Promise((r) => setTimeout(() => r({ + artist: a, banner: null, banners: [], banner_custom: false, logos: [], + popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [], + albums: own ? CAT_DISCO : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }], + top_genre: "Electronic", socials: [], + }), 220)); + } + if (cmd === "catalog_artist_socials") { + const id = Number(args?.artistId); + return new Promise((r) => setTimeout(() => r(id === CAT_ARTIST.id ? [{ kind: "website", url: "https://example.com" }, { kind: "wikipedia", url: "https://en.wikipedia.org/wiki/Daft_Punk" }] : []), 300)); + } + if (cmd === "home_warm") return Promise.resolve(); + if (cmd === "home_artist_news") return new Promise((r) => setTimeout(() => r(Object.fromEntries((args?.artists || []).map((n) => [n, /daft/i.test(n) ? NEWS : []]))), 150)); + if (cmd === "catalog_artist_identity") return Promise.resolve({ artist_id: Number(args?.artistId), albums: [], socials: [], type_hints: [], owned_album_ids: [] }); + if (cmd === "catalog_artist_profile") return Promise.resolve({ listeners: 3_100_000, plays: 210_000_000, tags: ["electronic", "house", "french house", "dance"] }); + if (cmd === "catalog_artist_bio") return Promise.resolve("Daft Punk were a French electronic music duo formed in Paris in 1993 by Guy-Manuel de Homem-Christo and Thomas Bangalter. They achieved popularity in the late 1990s as part of the French house movement."); + if (cmd === "catalog_related") return Promise.resolve(RELATED); + if (cmd === "followed_artists") return Promise.resolve(follows.slice()); + if (cmd === "follow_artist") { follows.push({ artist_id: args?.artistId, name: args?.name, portrait: args?.portrait ?? null, followed_at: Date.now() / 1000 }); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); } + if (cmd === "unfollow_artist") { follows = follows.filter((f) => f.artist_id !== args?.artistId); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); } + if (cmd === "catalog_search") { + const q = String(args?.query || "").toLowerCase(); + const hit = /daft|discovery|one more|punk/.test(q); + return new Promise((r) => setTimeout(() => r(hit + ? { artists: [CAT_ARTIST, CAT_NAMESAKE], albums: CAT_ALBUMS, tracks: CAT_TRACKS.slice(0, 8) } + : { artists: [], albums: [], tracks: [] }), 120)); + } + if (cmd === "catalog_album") { + const a = CAT_ALBUMS.find((x) => x.id === Number(args?.albumId)); + if (!a) return Promise.reject(new Error("catalog_album: unknown album")); + return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => ({ ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false }); + } + if (cmd === "catalog_album_info") { + const t = String(args?.title || ""); + return new Promise((r) => setTimeout(() => r(/discovery/i.test(t) + ? { description: "Discovery is the second studio album by the French electronic music duo Daft Punk, released on 12 March 2001. It marks a shift from the Chicago house of their debut to a house style more heavily inspired by disco, post-disco, garage house and synth-pop. The album's singles included the worldwide hit One More Time.", genre: "Electronic", label: "Virgin", source: "wikipedia" } + : null), 150)); + } + if (cmd === "download_file") { + const id = ++downloadSeq; + const total = args?.size || 20e6; + const name = String(args?.path || "").split("\\").pop(); + const base = { id, username: args?.username, path: args?.path, name, queue_place: null, local_path: null, error: null }; + later(200, () => mockEmit("soulseek:download", { ...base, state: "connecting", received: 0, total })); + let got = 0; + const step = setInterval(() => { + got = Math.min(total, got + total / 8); + mockEmit("soulseek:download", { ...base, state: got >= total ? "complete" : "transferring", received: Math.round(got), total, local_path: got >= total ? "C:\\mock\\library\\" + name : null }); + if (got >= total) { clearInterval(step); mockEmit("soulseek:library-changed", {}); } + }, 400 + (id % 3) * 120); + return Promise.resolve(id); + } + if (cmd === "cancel_download") return Promise.resolve(); + if (cmd === "rescan_library") return Promise.resolve(); + if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D")); + if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total)); + if (cmd === "preload_stream") { + const path = args?.path; + if (!cacheReady.has(path) && !preloading.has(path)) { preloading.add(path); later(4000, () => { preloading.delete(path); cacheReady.add(path); }); } + return Promise.resolve(); + } + if (cmd === "preload_cached_stream") return Promise.resolve(cacheReady.has(args?.path)); + if (cmd === "excluded_search_phrases") return Promise.resolve([]); + if (cmd === "get_settings") return Promise.resolve({ download_dir: "C:\\mock\\Library" }); + if (cmd === "set_settings") return Promise.resolve(); + if (cmd === "plugin:storage|pick_folder") return Promise.resolve({ uri: "content://mock/tree/primary%3AMusic", path: "C:\\mock\\Music" }); + if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true }); + if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve(); + return undefined; +} + +let token = 0; +let downloadSeq = 0; +const cacheReady = new Set(); // paths a preload has fully fetched +const preloading = new Set(); +let xfer = null; // the one fake transfer: { received, total, timer } +let posTimer = null; // the fake engine's position ticks +function mockNet(cmd, args) { + if (cmd === "get_credentials") return Promise.resolve({ username: MOCK_USER, has_password: true }); + if (cmd === "connect_stored" || cmd === "connect_and_login") { + later(400, () => mockEmit("soulseek:state", { kind: "connected", username: MOCK_USER })); + return Promise.resolve(); + } + if (cmd === "disconnect") { later(50, () => mockEmit("soulseek:state", { kind: "disconnected" })); return Promise.resolve(); } + if (cmd === "forget_credentials") return Promise.resolve(); + if (cmd === "buddy_list") return Promise.resolve(["ada", "cy"]); + if (cmd === "buddy_add") return Promise.resolve(["ada", "cy", args?.username].filter(Boolean)); + if (cmd === "buddy_remove") return Promise.resolve(["ada", "cy"].filter((u) => u !== args?.username)); + if (cmd === "slsk_room_list") { + later(200, () => mockEmit("soulseek:room-list", ROOMS.map(([name, users]) => ({ name, users })))); + return Promise.resolve(); + } + if (cmd === "slsk_join_room") { + const room = args?.room; + later(250, () => mockEmit("soulseek:room-joined", { room, users: [...PEOPLE, MOCK_USER].map((username) => ({ username })) })); + LINES.forEach((message, i) => later(400 + i * 350, () => mockEmit("soulseek:room-message", { room, username: PEOPLE[i % PEOPLE.length], message, ts: Math.floor(Date.now() / 1000) }))); + return Promise.resolve(); + } + if (cmd === "slsk_leave_room") { later(50, () => mockEmit("soulseek:room-left", { room: args?.room })); return Promise.resolve(); } + if (cmd === "slsk_say_room") { + later(120, () => mockEmit("soulseek:room-message", { room: args?.room, username: MOCK_USER, message: args?.message, ts: Math.floor(Date.now() / 1000) })); + return Promise.resolve(); + } + if (cmd === "slsk_message_user") { + later(900, () => mockEmit("soulseek:private-message", { username: args?.username, message: "sure, sending", timestamp: Math.floor(Date.now() / 1000) })); + return Promise.resolve(); + } + if (cmd === "start_search") { + const t = ++token; + const q = String(args?.query || ""); + later(300, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[1], q)])); + later(700, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[0], q), peerResponse(t, PEERS[3], q)])); + later(1300, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[2], q), peerResponse(t, PEERS[4], q)])); + return Promise.resolve(t); + } + if (cmd === "stop_search") return Promise.resolve(); + if (cmd === "play_stream") { + if (xfer) clearInterval(xfer.timer); + clearInterval(posTimer); + const total = args?.size || 30e6; + const duration = args?.duration || 240; + xfer = { received: 0, total, timer: null, path: args?.path }; + xfer.timer = setInterval(() => { + xfer.received = Math.min(total, xfer.received + total / 30); + const done = xfer.received >= total; + mockEmit("soulseek:stream", { received: Math.round(xfer.received), total, done, failed: null }); + if (done) clearInterval(xfer.timer); + }, 400); + let secs = 0; + later(1200, () => { + mockEmit("soulseek:player", { kind: "playing" }); + posTimer = setInterval(() => { secs += 0.5; mockEmit("soulseek:player", { kind: "position", secs, duration }); }, 500); + }); + return Promise.resolve(); + } + if (cmd === "stream_waveform") { + const coverage = xfer ? xfer.received / xfer.total : 0; + const n = Math.round((args?.buckets || 900) * coverage); + const peaks = Array.from({ length: n }, (_, i) => 0.25 + 0.7 * Math.abs(Math.sin(i * 0.37) * Math.cos(i * 0.011))); + return Promise.resolve({ coverage, peaks }); + } + if (cmd === "stream_cache_len") return Promise.resolve(xfer ? Math.round(xfer.received) : 0); + if (cmd === "stream_embedded_art") return Promise.resolve(xfer && xfer.received > 200000 ? svgArt(200, "D") : null); + if (cmd === "record_play") return Promise.resolve(1); + if (cmd === "add_play_seconds") return Promise.resolve(); return undefined; } diff --git a/src/lib/sources.js b/src/lib/sources.js new file mode 100644 index 0000000..8e2ea13 --- /dev/null +++ b/src/lib/sources.js @@ -0,0 +1,872 @@ +/* The source rules — the code behind every play button that turns a catalog + track or record into a peer's copy: the query ladder and its poisoned-term + hygiene, the paced search with its early exit, the quality window, the + candidate scoring for songs and albums, the remembered stream sources, and + the small text helpers they all share. + + ONE copy, for the desktop (state.svelte.js) and the phone (src/mobile): + the rules moved here verbatim from state.svelte.js on 2026-09-12 so the + phone could play through the same resolver instead of a fork of it. + Nothing in here touches a view or a store — the callers own the player. */ +import * as api from "./api.js"; + +export const RESOLVER_AUDIO_QUALITY = { + flac: 4, ape: 4, aiff: 3.5, wav: 3.5, + m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5, +}; + +// Formats that decode progressively while the file is still downloading. +// MP4-family files (m4a/aac) put their index at the end and only play once +// complete, so they're save-only. +// +// `opus` was here and must not be: this build has no Opus decoder, so the +// resolver would pick an Opus source to stream, download it, and fail at the +// moment of play. Streamable is a subset of PLAYABLE, never a superset — +// see crates/audio-formats. +const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]); + +// Lossless for LABELLING — what the file claims to be, which is a different +// question from whether we can decode it. `.ape` and `.wv` are genuinely +// lossless and genuinely unplayable here; a peer's file is still worth naming +// accurately in search results. +const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]); + +// User source-selection preferences, independent per mode. Loaded from +// settings at startup and refreshed by the Settings view after a commit; +// defaults reproduce the pre-feature behavior exactly. +const DEFAULT_SOURCE_PREFS = { max: "any", min: "any", priority: "quality" }; + +export const sourcePrefs = { + stream: { ...DEFAULT_SOURCE_PREFS }, + download: { ...DEFAULT_SOURCE_PREFS }, +}; + +export async function refreshSourcePrefs() { + try { + const s = await api.getSettings(); + sourcePrefs.stream = { ...DEFAULT_SOURCE_PREFS, ...(s?.stream_source_prefs ?? {}) }; + sourcePrefs.download = { ...DEFAULT_SOURCE_PREFS, ...(s?.download_source_prefs ?? {}) }; + } catch { + // settings unavailable (fresh install / backend restart) → defaults + } +} + +// ---- remembered stream sources ------------------------------------------ +// The last source each catalog track streamed from, persisted so a track can +// play again WITHOUT a Soulseek search: its bytes are already in the stream +// cache, and the resolve would only rediscover the peer that put them there. +// The memory is a HINT, not a promise — before any search is skipped the +// backend confirms the exact file is byte-complete on disk, so eviction, a +// half-finished transfer, or a source that never delivered all fall back to +// the normal resolver on their own. +const STREAM_SOURCE_STORE = "soulstream.streamSources.v1"; + +const STREAM_SOURCE_CAP = 400; + +let streamSources = null; // Map key → {username, path, size, duration, quality} + +function streamSourceKeys(trackId, artist, title) { + const keys = []; + if (trackId != null) keys.push(`t:${trackId}`); + if (artist && title) keys.push(`n:${artist}|${title}`.toLowerCase()); + return keys; +} + +function loadStreamSources() { + if (streamSources) return streamSources; + streamSources = new Map(); + try { + for (const [k, v] of JSON.parse(localStorage.getItem(STREAM_SOURCE_STORE) ?? "[]")) { + streamSources.set(k, v); + } + } catch { + // corrupt store — start over; losing it only costs one search per track + } + return streamSources; +} + +function saveStreamSources() { + try { + const entries = [...streamSources.entries()]; + localStorage.setItem( + STREAM_SOURCE_STORE, + JSON.stringify(entries.slice(-STREAM_SOURCE_CAP)), + ); + } catch { + // quota — same story, the memory is only a shortcut + } +} + +/** Called every time a stream item is queued to PLAY (not when it proves + * itself) — eager on purpose: a source that never delivers leaves no complete + * cache file, so the probe rejects it later and nothing was lost. Insertion + * order doubles as the LRU for the cap in saveStreamSources. */ +function rememberStreamSource(item) { + if (!item?.stream?.username || !item.path || !(item.stream.size > 0)) return; + const map = loadStreamSources(); + const record = { + username: item.stream.username, + path: item.path, + size: item.stream.size, + duration: item.duration ?? null, + quality: item.quality ?? null, + }; + for (const key of streamSourceKeys(item.trackId ?? null, item.artist, item.title)) { + map.delete(key); + map.set(key, record); + } + saveStreamSources(); +} + +/** The remembered source for this track, but ONLY if its cache file is + * byte-complete right now. Anything else is null: resolve normally. */ +async function cachedStreamSourceFor(trackId, artist, title) { + const map = loadStreamSources(); + for (const key of streamSourceKeys(trackId, artist, title)) { + const src = map.get(key); + if (!src) continue; + // A remembered file whose NAME does not even contain the track's + // base title is a mis-paired memory (the 2026-08-20 incident: + // three unrelated rows each "remembered" the same Weezer FLAC and + // played it from the warm cache forever). Delete it and resolve + // normally — a wrongly-dropped legit source only costs one search. + const base = normText(asciiFold((title ?? "").split(/[([]/)[0])); + const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? "")); + if (base.length >= 4 && fileName && !fileName.includes(base)) { + map.delete(key); + saveStreamSources(); + continue; + } + try { + if (await api.streamCacheReady(src.username, src.path, src.size)) return src; + } catch { + // backend unavailable — resolve normally + } + } + return null; +} + +/** Whether a peer file satisfies the user's quality window for this mode. + * Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN + * bitrate at or above it — a peer that hides its bitrate does not get the + * benefit of the doubt when the user asked for a minimum. Thresholds sit + * slightly under nominal so VBR files reporting 317 kbps still count as 320. */ +function passesSourcePrefs(f, prefs) { + const lossless = LOSSLESS_EXTS.has(f.extension); + if (prefs.max === "lossy_only" && lossless) return false; + switch (prefs.min) { + case "lossless": + if (!lossless) return false; + break; + case "kbps320": + if (!lossless && (f.bitrate ?? 0) < 315) return false; + break; + case "kbps256": + if (!lossless && (f.bitrate ?? 0) < 250) return false; + break; + case "kbps192": + if (!lossless && (f.bitrate ?? 0) < 186) return false; + break; + } + return true; +} + +/** Diacritics folded to ASCII ("Jóga" → "Joga", "Björk" → "Bjork"). Peers + * overwhelmingly name files unaccented; without folding, normText simply + * DELETED accented letters ("Jóga" → "jga") and the request could never + * match the file. */ +const asciiFold = (s) => (s ?? "").normalize("NFKD").replace(/\p{M}/gu, ""); + +const normText = (s) => + asciiFold(s).toLowerCase().replace(/\(.*?\)|\[.*?\]/g, "").replace(/[^a-z0-9]/g, ""); + +/** Catalog titles are often verbose editions ("Album; or, The Long Subtitle…", + * "Album (Deluxe Edition)"). Peer searches AND-match every word, so query + * with just the primary segment, capped to a few words. */ +function simplifyTitle(s) { + const first = (s ?? "").split(/[;:(\[]/)[0].trim(); + const words = (first || s || "").split(/\s+/).slice(0, 6).join(" "); + return words || s || ""; +} + +// Words that mark an *edition* of a release, not a distinct release. Used to +// tell an edition suffix (noise, safe to drop) from a variant name that names +// a different album — critical for self-titled records where the base title +// alone can't tell "Weezer (Blue Album)" from "Weezer (Red Album)". +const ALBUM_EDITION_RE = + /\b(?:deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|bonus|reissue|super|complete|definitive|legacy|special|collector'?s?|mono|stereo|redux|remix(?:es|ed)?)\b/i; + +/** The disambiguating variant name inside a title's brackets — e.g. "Blue + * Album" from "Weezer (Blue Album)", or "Red Album" from "Weezer (Deluxe + * Edition / Red Album)" — skipping pure edition markers. "" when the brackets + * only hold edition noise (or there are none). */ +export function albumDisambiguator(title) { + const groups = [...(title ?? "").matchAll(/[([]([^)\]]+)[)\]]/g)].map((m) => m[1]); + for (const inner of groups) { + for (const part of inner.split(/\s*[/|;]\s*/)) { + const p = part.trim(); + if (p && !ALBUM_EDITION_RE.test(p)) return p; + } + } + return ""; +} + +/** Search-query title-part for a release: the simplified title, plus the + * disambiguating variant name when there is one, so distinct self-titled + * albums search for their own colour instead of colliding on the band name. */ +function albumQueryTitle(album) { + const base = simplifyTitle(album.title); + // Fall back to a caller-supplied variant (the family's colour) when the + // title itself dropped it — e.g. "Weezer (Deluxe Edition)" is the Red Album + // but names no colour, so without this it searches bare "Weezer". + const d = albumDisambiguator(album.title) || album.variant || ""; + return d ? `${base} ${d}` : base; +} + +// Server-distributed excluded search phrases (Soulseek message 160). A query +// containing one is NEVER routed by the server — it returns zero responses +// network-wide with no error. Verified live: every query containing "mgmt" +// was silent while "little dark age" alone returned 738 responses. The list +// arrives by event after login and is also fetchable on demand (covers a +// WebView reload); the resolver strips these terms before they reach the wire. +let excludedSearchPhrases = []; + +export function setExcludedSearchPhrases(list) { + excludedSearchPhrases = (Array.isArray(list) ? list : []) + .map((p) => String(p ?? "").toLowerCase().trim()) + .filter(Boolean); +} + +void api.excludedSearchPhrases?.().then(setExcludedSearchPhrases, () => {}); + +const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** Query with server-excluded phrases removed (whole-word, any position) and + * whitespace collapsed. Returns "" when nothing usable remains. */ +export function sanitizeSearchQuery(query) { + let out = ` ${query ?? ""} `; + for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) { + out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " "); + } + return out.replace(/\s+/g, " ").trim(); +} + +/** Run a background source search (own token — the Search view is untouched) + * and collect responses. Ends early once `goodCount()` finds enough. + * + * The early exit is evaluated on a 250ms TICK, not only when a batch + * arrives: peers deliver most results within the first second and then go + * quiet, and the old on-batch-only check meant a plentiful-but-fast search + * still sat out the full window waiting for one more batch that never came + * (measured: 8.1s click-to-play on Discovery, ~7s of it idle waiting). + * Thresholds are adaptive — a flood of good candidates releases at 900ms, + * a workable set at 1800ms, and the full window only runs when results + * are genuinely sparse. */ +/** Minimum gap between Soulseek searches, whoever asks. A search is SERVER + * traffic, and the server drops sessions that flood it — measured + * 2026-08-03: a fresh login was killed inside ~90 seconds while background + * album fills re-searched on every rapid track click, and the reconnect + * throttle then locked the account out. User-triggered resolves feel + * instant (the first search never waits); only bursts are spread. */ +const SEARCH_MIN_GAP_MS = 1500; + +let lastSearchStartedAt = 0; + +// Exported for the import finder (import-audio.js): the pacing, poisoned- +// term hygiene and early-exit logic must exist ONCE, not be re-coded. +export async function collectSources(query, maxMs, goodCount) { + const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now(); + if (gap > 0) await new Promise((r) => setTimeout(r, gap)); + lastSearchStartedAt = performance.now(); + const token = await api.startSearch(query); + const responses = []; + let unlisten = null; + await new Promise((resolve) => { + const started = performance.now(); + const timer = setTimeout(() => { + clearInterval(tick); + resolve(); + }, maxMs); + const tick = setInterval(() => { + if (!goodCount) return; + const elapsed = performance.now() - started; + const good = goodCount(responses); + if ((elapsed >= 900 && good >= 12) || (elapsed >= 1800 && good >= 5)) { + clearTimeout(timer); + clearInterval(tick); + resolve(); + } + }, 250); + unlisten = api.onEvent("soulseek:search-results", (batch) => { + for (const r of batch) if (r.token === token) responses.push(r); + }); + }); + unlisten?.(); + api.stopSearch(token).catch(() => {}); + return responses; +} + +/** Queries measured silent (zero responses) recently — skipped for a while + * so a poisoned rung ("mgmt …") costs its discovery timeout once, not on + * every album/track of the same artist. */ +const silentQueries = new Map(); + +const SILENT_QUERY_TTL_MS = 10 * 60 * 1000; + +function querySilent(q) { + const at = silentQueries.get(q); + if (at == null) return false; + if (performance.now() - at > SILENT_QUERY_TTL_MS) { + silentQueries.delete(q); + return false; + } + return true; +} + +// Terms PROVEN poisoned this session: a rung containing the term was silent +// while a term-less rung answered — the network is fine, the term is dead +// (the "mgmt" case; the server's own excluded list does not admit it). +// Applied exactly like server-excluded phrases, so the second album by the +// same artist never pays the discovery timeout again. +const learnedPoisonedTerms = new Set(); + +/** Estimated seconds until this peer's file has fully arrived: transfer time + * from the file's size and the peer's advertised upload speed (bytes/sec), + * plus a rough wait for a busy slot / queued transfers. Pessimistic where the + * peer hides its numbers — an unknown speed must not look like a fast one. */ +function estimatedTransferSecs(r, file) { + const size = file.size > 0 ? file.size : 60_000_000; // unknown → assume large + const speed = (r.upload_speed ?? 0) > 0 ? r.upload_speed : 75_000; // unknown → slow + let secs = size / speed; + if (!r.has_free_upload_slot) secs += 90; + secs += Math.min(r.queue_length ?? 0, 50) * 45; + return secs; +} + +function scoreSource(r, file, prefs = null) { + const q = RESOLVER_AUDIO_QUALITY[file.extension] ?? 0; + if (!q) return -1; + const bitrate = file.bitrate; + // Speed priority ranks by ESTIMATED ARRIVAL TIME — size ÷ peer speed plus + // queue wait — not by raw peer speed. A 40 MB FLAC from a 5 MB/s peer + // (~8s) legitimately beats a 7 MB MP3 from a 100 KB/s peer (~70s). This + // only reorders sources that already passed the user's min/max quality + // window (passesSourcePrefs runs before scoring), so "fastest" can never + // dip below the configured floor. Format/bitrate remain as tie-breakers. + if (prefs?.priority === "speed") { + // Positive baseline: callers treat score <= 0 as "reject this + // format", and a slow, deep-queued peer must rank last, not vanish. + const eta = Math.min(estimatedTransferSecs(r, file), 3_600); + let s = 100_000 - eta * 25; + s += q * 50; + if (bitrate) s += Math.min(bitrate, 1500) / 50; + return s; + } + let s = q * 1000; + if (bitrate) s += Math.min(bitrate, 1500) / 10; + if (r.has_free_upload_slot) s += 600; + s += Math.min(r.upload_speed ?? 0, 5_000_000) / 20_000; + s -= Math.min(r.queue_length ?? 0, 50) * 12; + return s; +} + +// Markers that name a DIFFERENT RECORDING of the same song — live takes, +// remixes, demos, covers, radio edits… `normText` strips brackets, so +// "Island in the Sun (Live at X).mp3" matches the studio request perfectly; +// these are tested against the RAW filename + folder to catch that. Matching +// is symmetric: a candidate must carry exactly the markers the requested +// title carries — no live file for a studio request, no studio file for an +// explicitly "(Live)" catalog track. +const TRACK_VARIANT_MARKERS = [ + /\blive\b/i, + /\bunplugged\b/i, + /\bacoustic\b/i, + /\bremix(?:es|ed)?\b/i, + /\brmx\b/i, + /\brework(?:ed)?\b/i, + /\bdemo(?:s)?\b/i, + /\binstrumental\b/i, + /\bkaraoke\b/i, + /\bcover(?:s|ed)?\b/i, + /\btribute\b/i, + /\b(?:radio|single)\s+edit\b/i, + /\b(?:club|extended|dub|dance)\s+mix\b/i, + /\bmedley\b/i, + /\bmashup\b/i, + /\bsession(?:s)?\b/i, + /\brehearsal\b/i, + /\bouttake(?:s)?\b/i, + /\balternate\b/i, + /\btake\s+\d+\b/i, + /\bre-?recorded\b/i, + // "'s Version" re-records (Taylor's Version and kin): a different + // recording of the song, matched symmetrically like every marker here — + // found live by the source audit when a request for "22" top-ranked + // "22 (Taylor's Version)". + /\b\w+['’]s\s+version\b/i, + /\bsped\s+up\b/i, + /\bslowed\b/i, + /\b8[\s-]?bit\b/i, +]; + +// Checked against the filename only: "Reprise" is also a record label +// (Weezer's!), so folder paths like "… (Reprise, 1996)" must not reject. +const TRACK_VARIANT_NAME_ONLY = [/\breprise\b/i]; + +/** True when the candidate names a different version of the song than the + * request asked for (either direction). */ +function variantMismatch(wantedTitle, candidateName, candidateFolder) { + const cand = `${candidateFolder}\\${candidateName}`; + for (const re of TRACK_VARIANT_MARKERS) { + if (re.test(cand) !== re.test(wantedTitle)) return true; + } + for (const re of TRACK_VARIANT_NAME_ONLY) { + if (re.test(candidateName) !== re.test(wantedTitle)) return true; + } + return false; +} + +/** A peer file's playtime is the wrong recording when it's far from the + * catalog's known length — most often a ripped 30-second streaming *preview* + * whose filename matches the real track (e.g. a 30s "Long Season" for a + * 41-minute live track). Only judges when BOTH durations are known; the band + * is generous (±20%, min 45s) so gapless trims and a few seconds of trailing + * silence never trip it. */ +function durationMismatch(expectSecs, candSecs) { + if (!expectSecs || !candSecs || expectSecs <= 0 || candSecs <= 0) return false; + const tol = Math.max(45, expectSecs * 0.2); + return Math.abs(candSecs - expectSecs) > tol; +} + +/** Ranked candidate files for a song across all responses (best first). + * `album` (title string, optional) biases toward folders of the release the + * user is actually looking at. `expectSecs` (optional) is the catalog track's + * known length, used to drop preview clips / wrong-length rips. */ +function songCandidates( + responses, + title, + streamOnly = false, + album = null, + expectSecs = null, + artist = null, + requireIdentityEvidence = false, +) { + const want = normText(title); + const wantedRaw = title ?? ""; + const wantAlbum = album ? normText(simplifyTitle(album)) : ""; + const artistKey = artist ? normText(artist) : ""; + const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download; + const out = []; + for (const r of responses) { + for (const f of r.files) { + if (!normText(f.name).includes(want)) continue; + if (streamOnly && !RESOLVER_STREAMABLE.has(f.extension)) continue; + if (!passesSourcePrefs(f, prefs)) continue; + if (variantMismatch(wantedRaw, f.name, f.folder)) continue; + if (durationMismatch(expectSecs, f.duration)) continue; + let s = scoreSource(r, f, prefs); + if (s <= 0) continue; + const albumEvidence = wantAlbum && normText(f.folder).includes(wantAlbum); + const artistEvidence = + artistKey && normText(`${f.folder}\\${f.name}`).includes(artistKey); + // A rung that dropped the artist term (server-excluded phrase) + // matches by title alone, which happily returns other artists' + // covers of the song. The peer's PATH must then prove identity: + // the artist somewhere in the folder/filename, or the exact + // release folder. + if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) { + continue; + } + // The requested release's own folder beats compilation/misc rips + // (below a format-tier jump, so quality still wins). + if (albumEvidence) s += 800; + if (artistEvidence) s += 400; + // Filename ends with the title → it IS this song, not a longer + // title containing it ("Holiday" vs "Holiday in Cambodia"). + if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250; + out.push({ username: r.username, file: f, score: s, slotFree: !!r.has_free_upload_slot }); + } + } + out.sort((a, b) => b.score - a.score); + // STREAMING needs bytes now: a peer with no free upload slot usually + // queues the request and never starts inside the failover window, and the + // quality-first score let busy lossless peers occupy the whole failover + // list — "Da Funk", with hundreds of sources, spent over a minute walking + // dead candidates (2026-08-03). Stable partition: peers that can deliver + // immediately first, best quality within each half. Downloads keep the + // pure ranking — a save can afford to wait in a queue for the better file. + if (streamOnly) { + return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)]; + } + return out; +} + +/** Ranked candidate folders for an album (best first). `trackCountOverride` + * is the authoritative track count from the catalog tracklist fetch; prefer it + * over `album.track_count`, which is routinely null when Play is pressed on the + * artist page (Deezer's artist-album LIST endpoint omits nb_tracks). */ +function albumCandidates( + responses, + album, + streamOnly = false, + trackCountOverride = null, + requireIdentityEvidence = false, +) { + const artistKey = normText(album.artist ?? ""); + const wantAlbum = normText(simplifyTitle(album.title)); + const wanted = trackCountOverride ?? album.track_count ?? null; + const year = album.release_date?.slice(0, 4) || null; + // Self-titled disambiguation: the "(Blue Album)" variant, and its bare + // colour word, so we can prefer the right folder and reject a wrong-colour + // one that a fuzzy peer search returns anyway. + const variant = (albumDisambiguator(album.title) || album.variant || "").toLowerCase(); + const wantColour = variant.match(/([a-z]+)\s+album\b/)?.[1] || null; + const folders = new Map(); + for (const r of responses) { + for (const f of r.files) { + const key = `${r.username}|${f.folder}`; + let g = folders.get(key); + if (!g) { + g = { username: r.username, folder: f.folder, files: [], r }; + folders.set(key, g); + } + g.files.push(f); + } + } + const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download; + const out = []; + for (const g of folders.values()) { + const audio = g.files.filter( + (f) => + (streamOnly + ? RESOLVER_STREAMABLE.has(f.extension) + : RESOLVER_AUDIO_QUALITY[f.extension]) + && passesSourcePrefs(f, prefs), + ); + // Require enough streamable files to plausibly be this release, but + // NEVER default an unknown count to 3 — that silently rejected every + // single/2-track release (whose peer folders hold 1–2 matching files) + // and is exactly why "No streamable album sources found" fired for + // real, obviously-streamable singles like ICARUS. Known count → at most + // 3, floor 1; unknown → just 1. + const minAudio = wanted ? Math.min(3, Math.max(1, wanted)) : 1; + if (audio.length < minAudio) continue; + const top = audio + .map((f) => scoreSource(g.r, f, prefs)) + .reduce((a, b) => a + b, 0) / audio.length; + let s = top; + const folderNorm = normText(g.folder); + const albumEvidence = folderNorm.includes(wantAlbum); + const artistEvidence = artistKey && folderNorm.includes(artistKey); + // Artist-less query rung (server-excluded phrase): the folder path + // must prove the release's identity — see songCandidates. + if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) { + continue; + } + if (albumEvidence) s += 400; + if (artistEvidence) s += 300; + // Prefer folders whose track count matches the release. + if (wanted) s -= Math.min(Math.abs(audio.length - wanted), 20) * 60; + // The release year in the folder path is a strong per-release signal + // (distinguishes reissues and same-named albums). + if (year && g.folder.includes(year)) s += 300; + // Self-titled colour disambiguation: reward the matching "(X Album)" + // folder, and strongly reject a folder that clearly names a different + // colour, so "Weezer (Blue Album)" can't stream from a Red folder. + if (wantColour) { + const folderColours = [ + ...g.folder.toLowerCase().matchAll(/\b([a-z]+)\s+album\b/g), + ].map((m) => m[1]); + if (folderColours.includes(wantColour)) s += 500; + else if (folderColours.length) s -= 900; + } + audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + out.push({ + username: g.username, + folder: g.folder, + files: g.files, + audio, + score: s, + slotFree: !!g.r.has_free_upload_slot, + }); + } + out.sort((a, b) => b.score - a.score); + // Same availability partition as songCandidates, same reason: a streamed + // album must START; a busy peer's better folder is a save-time choice. + if (streamOnly) { + return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)]; + } + return out; +} + +/** Distill a peer file's tags into the quality info the Now Playing panel + * shows (format/bitrate/sample-rate/bit-depth). All fields are optional — + * many clients only report some, so the panel hides what's missing. */ +function qualityOf(f) { + if (!f) return null; + const ext = f.extension || (f.name || f.path || "").split(".").pop() || ""; + return { + format: ext.toUpperCase(), + bitrate: f.bitrate ?? null, + sampleRate: f.sample_rate ?? null, + bitDepth: f.bit_depth ?? null, + vbr: !!f.vbr, + size: f.size ?? null, + }; +} + +/** Deezer's CDN renders the same cover at whatever size the URL's size + * segment asks for. Track objects carry the 250px list thumb + * (`cover_medium`), which looks terrible blown up in the player — rewrite it + * to the 1000px xl the album pages use. Non-Deezer URLs pass through + * untouched. */ +function hqCover(url) { + if (!url) return url; + try { + if (!new URL(url).hostname.endsWith("dzcdn.net")) return url; + } catch { + return url; + } + return url.replace( + /\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i, + "/1000x1000$1.$2", + ); +} + +/** Match a peer file to a catalog track, so queue items carry the tagged + * title instead of the filename. + * + * A track title contained in the filename normally identifies the track — but + * only if it's *specific* to this file. When peers embed the album name in + * every filename ("… - Eggbeater Jesus - 01 Happy Hour.flac"), and the album + * name is also a track title, that album name is a substring of every filename + * and — being long — used to out-rank each track's real, shorter title, so the + * whole album collapsed to one repeated title. So: ignore any title that + * appears in (roughly) every filename, take the longest *specific* match, and + * otherwise fall back to album order when the folder is a complete album. */ +function catalogTrackFor(file, i, files, tracks) { + if (!tracks?.length) return null; + const fname = normText(file.name); + const base = (t) => normText(t.title); + const named = (t) => base(t).length >= 3 && fname.includes(base(t)); + + // PRIMARY: the catalog (Deezer) tracklist, in order. When the folder holds + // exactly the album's track count it's almost always the whole album in + // order, so file `i` is track `i`. That's the only reliable way to tell + // same-named variants apart — "My Name Is Jonas (2024 Remaster)", + // "(Live On BBC…)" and "(Kitchen Tape Demo)" all normalize to one base + // title, so filename matching alone can't say which belongs here (it used + // to pick the longest title, i.e. the verbose live/demo one). Only distrust + // the position if the filename clearly names a DIFFERENT song than the one + // sitting there (a reordered/mismatched folder — handled by the backup). + if (files.length === tracks.length) { + const at = tracks[i]; + if (!fname || named(at) || !tracks.some(named)) return at; + } + + // BACKUP: fuzzy filename→title matching for a partial/reordered folder. + // Drop any title that appears in ~every filename (that's the album name), + // then among the rest prefer the closest duration (disambiguates same-named + // variants), then the longest title. + const contained = tracks.filter(named); + const ubiquity = (t) => files.reduce((n, f) => n + (normText(f.name).includes(base(t)) ? 1 : 0), 0); + const ubiquitous = Math.max(2, Math.ceil(files.length * 0.5)); + let pool = contained.filter((t) => ubiquity(t) < ubiquitous); + if (!pool.length) pool = contained; + if (pool.length) { + const dur = file.duration; + if (dur) { + const off = (t) => Math.abs((t.duration_secs ?? 1e9) - dur); + // Closest duration wins; on a tie prefer the shorter title, i.e. the + // plain studio version over a longer "(Live…)" / "(Demo…)" variant. + return pool.reduce((a, b) => + off(b) < off(a) || (off(b) === off(a) && b.title.length < a.title.length) ? b : a, + ); + } + return pool.reduce((a, b) => (b.title.length > a.title.length ? b : a)); + } + return files.length === tracks.length ? tracks[i] : null; +} + +/** Turn a peer filename into a readable title when no catalog track matches + * it (a genuine bonus/extra file on an expanded edition): drop the extension + * and a leading track-number prefix ("04. ", "1 - ", "07 ") so it reads as a + * title rather than "04. Dragon Ball Z…". Conservative — only strips a number + * that's clearly a track index (has a separator, or a leading zero), so a title + * like "99 Luftballons" is left intact. */ +function cleanTrackName(name) { + const stem = (name ?? "").replace(/\.[^.]+$/, ""); + const stripped = stem.replace(/^\s*(?:\d{1,2}\s*[.\-)]|0\d)\s+/, "").trim(); + return stripped || stem; +} + +/* ── the ladder ─────────────────────────────────────────────────────── + The query shapes a caller hands over, each also ASCII-folded, each + sanitised of server-excluded and learned-poisoned terms; the rungs run in + order with short windows, a silent rung is remembered, and a whole silent + ladder gets one quiet retry for the post-login warm-up. `status` is the + caller's resolver state ({ active, label, error }) — the desktop's toast, + the phone's bar line. */ +export async function resolveSources(label, queries, goodCount, artistTerm = null, status = null) { + if (status) { status.active = true; status.label = label; status.error = ""; } + try { + const artistKey = artistTerm ? normText(artistTerm) : ""; + const seen = new Set(); + const ladder = []; + for (const raw of [].concat(queries).filter(Boolean)) { + // Each shape also gets an ASCII-folded rung: peers name files + // unaccented, and the server AND-matches query tokens, so + // "Björk Jóga" finds nothing that "Bjork Joga" finds. + for (const variant of [raw, asciiFold(raw)]) { + const q = sanitizeSearchQuery(variant); + if (!q || seen.has(q)) continue; + seen.add(q); + ladder.push(q); + } + } + // Non-final rungs get a short window: a rung the network answers at + // all answers within ~2s, so a silent (poisoned/warm-up) rung should + // cost 3.5s, not the full budget. Recently-silent rungs are skipped + // outright. The post-login warm-up retry sits BEHIND the ladder: if + // ANY rung answered, connectivity is proven and no retry is owed. + let sawSilentArtistRung = false; + for (let index = 0; index < ladder.length; index++) { + const q = ladder[index]; + if (querySilent(q)) continue; + const final = index === ladder.length - 1; + const hasArtist = artistKey && normText(q).includes(artistKey); + const responses = await collectSources(q, final ? 7000 : 3500, goodCount); + if (responses.length) { + // An answered term-less rung after a silent artist rung PROVES + // the artist term is what kills the query — learn it, so the + // artist's other releases skip the dead rung entirely. + if (sawSilentArtistRung && !hasArtist && artistTerm) { + learnedPoisonedTerms.add(artistTerm.toLowerCase().trim()); + } + return { + responses, + artistIncluded: !artistKey || normText(q).includes(artistKey), + }; + } + if (hasArtist) sawSilentArtistRung = true; + silentQueries.set(q, performance.now()); + } + // Whole ladder silent: either the terms are all poisoned or we are in + // the post-login window where the server has not attached us to a + // search parent yet. One quiet retry of the primary rung covers the + // latter (and clears its silent mark on success). + if (ladder.length) { + const q = ladder[0]; + const responses = await collectSources(q, 7000, goodCount); + if (responses.length) { + silentQueries.delete(q); + return { + responses, + artistIncluded: !artistKey || normText(q).includes(artistKey), + }; + } + } + return { responses: [], artistIncluded: true }; + } finally { + if (status) { status.active = false; status.label = ""; } + } +} + +/* ── the library first ──────────────────────────────────────────────── + Over whatever album list the caller holds (the desktop's library store, + the phone's). */ +/** The library's copy of a catalog record, or null. */ +export function libraryAlbumIn(albums, artist, title, releaseDate = null) { + const a = normText(artist); + const t1 = normText(title); + if (!a || !t1) return null; + // normText strips ALL bracket content, so base-title comparison alone + // cannot tell self-titled variants apart: "Weezer (Blue Album)" and + // "Weezer (Green Album)" both normalize to "weezer" and would exact-match + // a library copy saved under the bare provider title "Weezer". After the + // base titles agree, identity therefore needs the variant name + // (albumDisambiguator) to agree — or, when either side lacks one, the + // release YEAR to corroborate. A self-titled base carries no identity at + // all (the title is just the artist), so even a bare↔bare title match + // requires the year there. No usable evidence → no local match; streaming + // the right album beats instantly playing the wrong one. + const wantD = normText(albumDisambiguator(title)); + // The simplified-title fallback lets a library copy saved under a terse + // name match a verbose catalog EDITION (library "Album" ↔ catalog "Album + // (Deluxe Edition)"); disambiguated variants never take it. + const t2 = wantD ? null : normText(simplifyTitle(title)); + const wantYear = releaseDate ? Number(String(releaseDate).slice(0, 4)) || null : null; + const selfTitled = t1 === a; + return ( + albums.find((al) => { + if (normText(al.artist) !== a) return false; + const lt = normText(al.title); + if (lt !== t1 && !(t2 && lt === t2)) return false; + const libD = normText(albumDisambiguator(al.title)); + if (wantD && wantD === libD) return true; + if (wantD !== libD || selfTitled) { + const libYear = Number(al.year) || null; + return wantYear != null && libYear != null && libYear === wantYear; + } + return true; + }) ?? null + ); +} +/** The library's copy of a song, as { album, tracks, index }, or null. + * `albumTracks(id)` fetches a record's rows. */ +export async function libraryTrackIn(albums, albumTracks, artist, title) { + const a = normText(artist); + const t = normText(title); + if (!a || !t) return null; + for (const al of albums) { + if (normText(al.artist) !== a) continue; + try { + const tracks = await albumTracks(al.id); + const index = tracks.findIndex((tr) => normText(tr.title) === t); + if (index >= 0) return { album: al, tracks, index }; + } catch { + // library unavailable — fall through to streaming + } + } + return null; +} + +export { + RESOLVER_STREAMABLE, + LOSSLESS_EXTS, + DEFAULT_SOURCE_PREFS, + STREAM_SOURCE_STORE, + STREAM_SOURCE_CAP, + streamSources, + streamSourceKeys, + loadStreamSources, + saveStreamSources, + rememberStreamSource, + cachedStreamSourceFor, + passesSourcePrefs, + asciiFold, + normText, + simplifyTitle, + ALBUM_EDITION_RE, + albumQueryTitle, + excludedSearchPhrases, + escapeRegExp, + SEARCH_MIN_GAP_MS, + lastSearchStartedAt, + silentQueries, + SILENT_QUERY_TTL_MS, + querySilent, + learnedPoisonedTerms, + estimatedTransferSecs, + scoreSource, + TRACK_VARIANT_MARKERS, + TRACK_VARIANT_NAME_ONLY, + variantMismatch, + durationMismatch, + songCandidates, + albumCandidates, + qualityOf, + hqCover, + catalogTrackFor, + cleanTrackName, +}; diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index f55271c..9ade5c1 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -1,5 +1,34 @@ // Shared reactive app state (Svelte 5 runes module). import * as api from "./api.js"; +import { + sourcePrefs, + refreshSourcePrefs, + rememberStreamSource, + cachedStreamSourceFor, + asciiFold, + normText, + simplifyTitle, + albumDisambiguator, + albumQueryTitle, + setExcludedSearchPhrases, + sanitizeSearchQuery, + collectSources, + silentQueries, + querySilent, + learnedPoisonedTerms, + variantMismatch, + durationMismatch, + songCandidates, + albumCandidates, + qualityOf, + hqCover, + catalogTrackFor, + cleanTrackName, + resolveSources, + libraryAlbumIn, + libraryTrackIn, +} from "./sources.js"; +export { RESOLVER_AUDIO_QUALITY, sourcePrefs, refreshSourcePrefs, albumDisambiguator, setExcludedSearchPhrases, sanitizeSearchQuery, collectSources } from "./sources.js"; import { nameNorm, pickCatalogMatch } from "./catalogMatch.js"; import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js"; import { @@ -4083,133 +4112,7 @@ export const resolver = $state({ error: "", }); -export const RESOLVER_AUDIO_QUALITY = { - flac: 4, ape: 4, aiff: 3.5, wav: 3.5, - m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5, -}; -// Formats that decode progressively while the file is still downloading. -// MP4-family files (m4a/aac) put their index at the end and only play once -// complete, so they're save-only. -// -// `opus` was here and must not be: this build has no Opus decoder, so the -// resolver would pick an Opus source to stream, download it, and fail at the -// moment of play. Streamable is a subset of PLAYABLE, never a superset — -// see crates/audio-formats. -const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]); - -// Lossless for LABELLING — what the file claims to be, which is a different -// question from whether we can decode it. `.ape` and `.wv` are genuinely -// lossless and genuinely unplayable here; a peer's file is still worth naming -// accurately in search results. -const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]); - -// User source-selection preferences, independent per mode. Loaded from -// settings at startup and refreshed by the Settings view after a commit; -// defaults reproduce the pre-feature behavior exactly. -const DEFAULT_SOURCE_PREFS = { max: "any", min: "any", priority: "quality" }; -export const sourcePrefs = { - stream: { ...DEFAULT_SOURCE_PREFS }, - download: { ...DEFAULT_SOURCE_PREFS }, -}; -export async function refreshSourcePrefs() { - try { - const s = await api.getSettings(); - sourcePrefs.stream = { ...DEFAULT_SOURCE_PREFS, ...(s?.stream_source_prefs ?? {}) }; - sourcePrefs.download = { ...DEFAULT_SOURCE_PREFS, ...(s?.download_source_prefs ?? {}) }; - } catch { - // settings unavailable (fresh install / backend restart) → defaults - } -} - -// ---- remembered stream sources ------------------------------------------ -// The last source each catalog track streamed from, persisted so a track can -// play again WITHOUT a Soulseek search: its bytes are already in the stream -// cache, and the resolve would only rediscover the peer that put them there. -// The memory is a HINT, not a promise — before any search is skipped the -// backend confirms the exact file is byte-complete on disk, so eviction, a -// half-finished transfer, or a source that never delivered all fall back to -// the normal resolver on their own. -const STREAM_SOURCE_STORE = "soulstream.streamSources.v1"; -const STREAM_SOURCE_CAP = 400; -let streamSources = null; // Map key → {username, path, size, duration, quality} - -function streamSourceKeys(trackId, artist, title) { - const keys = []; - if (trackId != null) keys.push(`t:${trackId}`); - if (artist && title) keys.push(`n:${artist}|${title}`.toLowerCase()); - return keys; -} -function loadStreamSources() { - if (streamSources) return streamSources; - streamSources = new Map(); - try { - for (const [k, v] of JSON.parse(localStorage.getItem(STREAM_SOURCE_STORE) ?? "[]")) { - streamSources.set(k, v); - } - } catch { - // corrupt store — start over; losing it only costs one search per track - } - return streamSources; -} -function saveStreamSources() { - try { - const entries = [...streamSources.entries()]; - localStorage.setItem( - STREAM_SOURCE_STORE, - JSON.stringify(entries.slice(-STREAM_SOURCE_CAP)), - ); - } catch { - // quota — same story, the memory is only a shortcut - } -} -/** Called every time a stream item is queued to PLAY (not when it proves - * itself) — eager on purpose: a source that never delivers leaves no complete - * cache file, so the probe rejects it later and nothing was lost. Insertion - * order doubles as the LRU for the cap in saveStreamSources. */ -function rememberStreamSource(item) { - if (!item?.stream?.username || !item.path || !(item.stream.size > 0)) return; - const map = loadStreamSources(); - const record = { - username: item.stream.username, - path: item.path, - size: item.stream.size, - duration: item.duration ?? null, - quality: item.quality ?? null, - }; - for (const key of streamSourceKeys(item.trackId ?? null, item.artist, item.title)) { - map.delete(key); - map.set(key, record); - } - saveStreamSources(); -} -/** The remembered source for this track, but ONLY if its cache file is - * byte-complete right now. Anything else is null: resolve normally. */ -async function cachedStreamSourceFor(trackId, artist, title) { - const map = loadStreamSources(); - for (const key of streamSourceKeys(trackId, artist, title)) { - const src = map.get(key); - if (!src) continue; - // A remembered file whose NAME does not even contain the track's - // base title is a mis-paired memory (the 2026-08-20 incident: - // three unrelated rows each "remembered" the same Weezer FLAC and - // played it from the warm cache forever). Delete it and resolve - // normally — a wrongly-dropped legit source only costs one search. - const base = normText(asciiFold((title ?? "").split(/[([]/)[0])); - const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? "")); - if (base.length >= 4 && fileName && !fileName.includes(base)) { - map.delete(key); - saveStreamSources(); - continue; - } - try { - if (await api.streamCacheReady(src.username, src.path, src.size)) return src; - } catch { - // backend unavailable — resolve normally - } - } - return null; -} void refreshSourcePrefs(); // ---- artwork for a stream that arrived without any ----------------------- @@ -4301,225 +4204,6 @@ async function ensureStreamArt(item) { } -/** Whether a peer file satisfies the user's quality window for this mode. - * Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN - * bitrate at or above it — a peer that hides its bitrate does not get the - * benefit of the doubt when the user asked for a minimum. Thresholds sit - * slightly under nominal so VBR files reporting 317 kbps still count as 320. */ -function passesSourcePrefs(f, prefs) { - const lossless = LOSSLESS_EXTS.has(f.extension); - if (prefs.max === "lossy_only" && lossless) return false; - switch (prefs.min) { - case "lossless": - if (!lossless) return false; - break; - case "kbps320": - if (!lossless && (f.bitrate ?? 0) < 315) return false; - break; - case "kbps256": - if (!lossless && (f.bitrate ?? 0) < 250) return false; - break; - case "kbps192": - if (!lossless && (f.bitrate ?? 0) < 186) return false; - break; - } - return true; -} - -/** Diacritics folded to ASCII ("Jóga" → "Joga", "Björk" → "Bjork"). Peers - * overwhelmingly name files unaccented; without folding, normText simply - * DELETED accented letters ("Jóga" → "jga") and the request could never - * match the file. */ -const asciiFold = (s) => (s ?? "").normalize("NFKD").replace(/\p{M}/gu, ""); - -const normText = (s) => - asciiFold(s).toLowerCase().replace(/\(.*?\)|\[.*?\]/g, "").replace(/[^a-z0-9]/g, ""); - -/** Catalog titles are often verbose editions ("Album; or, The Long Subtitle…", - * "Album (Deluxe Edition)"). Peer searches AND-match every word, so query - * with just the primary segment, capped to a few words. */ -function simplifyTitle(s) { - const first = (s ?? "").split(/[;:(\[]/)[0].trim(); - const words = (first || s || "").split(/\s+/).slice(0, 6).join(" "); - return words || s || ""; -} - -// Words that mark an *edition* of a release, not a distinct release. Used to -// tell an edition suffix (noise, safe to drop) from a variant name that names -// a different album — critical for self-titled records where the base title -// alone can't tell "Weezer (Blue Album)" from "Weezer (Red Album)". -const ALBUM_EDITION_RE = - /\b(?:deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|bonus|reissue|super|complete|definitive|legacy|special|collector'?s?|mono|stereo|redux|remix(?:es|ed)?)\b/i; - -/** The disambiguating variant name inside a title's brackets — e.g. "Blue - * Album" from "Weezer (Blue Album)", or "Red Album" from "Weezer (Deluxe - * Edition / Red Album)" — skipping pure edition markers. "" when the brackets - * only hold edition noise (or there are none). */ -export function albumDisambiguator(title) { - const groups = [...(title ?? "").matchAll(/[([]([^)\]]+)[)\]]/g)].map((m) => m[1]); - for (const inner of groups) { - for (const part of inner.split(/\s*[/|;]\s*/)) { - const p = part.trim(); - if (p && !ALBUM_EDITION_RE.test(p)) return p; - } - } - return ""; -} - -/** Search-query title-part for a release: the simplified title, plus the - * disambiguating variant name when there is one, so distinct self-titled - * albums search for their own colour instead of colliding on the band name. */ -function albumQueryTitle(album) { - const base = simplifyTitle(album.title); - // Fall back to a caller-supplied variant (the family's colour) when the - // title itself dropped it — e.g. "Weezer (Deluxe Edition)" is the Red Album - // but names no colour, so without this it searches bare "Weezer". - const d = albumDisambiguator(album.title) || album.variant || ""; - return d ? `${base} ${d}` : base; -} - -// Server-distributed excluded search phrases (Soulseek message 160). A query -// containing one is NEVER routed by the server — it returns zero responses -// network-wide with no error. Verified live: every query containing "mgmt" -// was silent while "little dark age" alone returned 738 responses. The list -// arrives by event after login and is also fetchable on demand (covers a -// WebView reload); the resolver strips these terms before they reach the wire. -let excludedSearchPhrases = []; -export function setExcludedSearchPhrases(list) { - excludedSearchPhrases = (Array.isArray(list) ? list : []) - .map((p) => String(p ?? "").toLowerCase().trim()) - .filter(Boolean); -} -void api.excludedSearchPhrases?.().then(setExcludedSearchPhrases, () => {}); - -const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -/** Query with server-excluded phrases removed (whole-word, any position) and - * whitespace collapsed. Returns "" when nothing usable remains. */ -export function sanitizeSearchQuery(query) { - let out = ` ${query ?? ""} `; - for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) { - out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " "); - } - return out.replace(/\s+/g, " ").trim(); -} - -/** Run a background source search (own token — the Search view is untouched) - * and collect responses. Ends early once `goodCount()` finds enough. - * - * The early exit is evaluated on a 250ms TICK, not only when a batch - * arrives: peers deliver most results within the first second and then go - * quiet, and the old on-batch-only check meant a plentiful-but-fast search - * still sat out the full window waiting for one more batch that never came - * (measured: 8.1s click-to-play on Discovery, ~7s of it idle waiting). - * Thresholds are adaptive — a flood of good candidates releases at 900ms, - * a workable set at 1800ms, and the full window only runs when results - * are genuinely sparse. */ -/** Minimum gap between Soulseek searches, whoever asks. A search is SERVER - * traffic, and the server drops sessions that flood it — measured - * 2026-08-03: a fresh login was killed inside ~90 seconds while background - * album fills re-searched on every rapid track click, and the reconnect - * throttle then locked the account out. User-triggered resolves feel - * instant (the first search never waits); only bursts are spread. */ -const SEARCH_MIN_GAP_MS = 1500; -let lastSearchStartedAt = 0; - -// Exported for the import finder (import-audio.js): the pacing, poisoned- -// term hygiene and early-exit logic must exist ONCE, not be re-coded. -export async function collectSources(query, maxMs, goodCount) { - const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now(); - if (gap > 0) await new Promise((r) => setTimeout(r, gap)); - lastSearchStartedAt = performance.now(); - const token = await api.startSearch(query); - const responses = []; - let unlisten = null; - await new Promise((resolve) => { - const started = performance.now(); - const timer = setTimeout(() => { - clearInterval(tick); - resolve(); - }, maxMs); - const tick = setInterval(() => { - if (!goodCount) return; - const elapsed = performance.now() - started; - const good = goodCount(responses); - if ((elapsed >= 900 && good >= 12) || (elapsed >= 1800 && good >= 5)) { - clearTimeout(timer); - clearInterval(tick); - resolve(); - } - }, 250); - unlisten = api.onEvent("soulseek:search-results", (batch) => { - for (const r of batch) if (r.token === token) responses.push(r); - }); - }); - unlisten?.(); - api.stopSearch(token).catch(() => {}); - return responses; -} - -/** Queries measured silent (zero responses) recently — skipped for a while - * so a poisoned rung ("mgmt …") costs its discovery timeout once, not on - * every album/track of the same artist. */ -const silentQueries = new Map(); -const SILENT_QUERY_TTL_MS = 10 * 60 * 1000; -function querySilent(q) { - const at = silentQueries.get(q); - if (at == null) return false; - if (performance.now() - at > SILENT_QUERY_TTL_MS) { - silentQueries.delete(q); - return false; - } - return true; -} - -// Terms PROVEN poisoned this session: a rung containing the term was silent -// while a term-less rung answered — the network is fine, the term is dead -// (the "mgmt" case; the server's own excluded list does not admit it). -// Applied exactly like server-excluded phrases, so the second album by the -// same artist never pays the discovery timeout again. -const learnedPoisonedTerms = new Set(); - -/** Estimated seconds until this peer's file has fully arrived: transfer time - * from the file's size and the peer's advertised upload speed (bytes/sec), - * plus a rough wait for a busy slot / queued transfers. Pessimistic where the - * peer hides its numbers — an unknown speed must not look like a fast one. */ -function estimatedTransferSecs(r, file) { - const size = file.size > 0 ? file.size : 60_000_000; // unknown → assume large - const speed = (r.upload_speed ?? 0) > 0 ? r.upload_speed : 75_000; // unknown → slow - let secs = size / speed; - if (!r.has_free_upload_slot) secs += 90; - secs += Math.min(r.queue_length ?? 0, 50) * 45; - return secs; -} - -function scoreSource(r, file, prefs = null) { - const q = RESOLVER_AUDIO_QUALITY[file.extension] ?? 0; - if (!q) return -1; - const bitrate = file.bitrate; - // Speed priority ranks by ESTIMATED ARRIVAL TIME — size ÷ peer speed plus - // queue wait — not by raw peer speed. A 40 MB FLAC from a 5 MB/s peer - // (~8s) legitimately beats a 7 MB MP3 from a 100 KB/s peer (~70s). This - // only reorders sources that already passed the user's min/max quality - // window (passesSourcePrefs runs before scoring), so "fastest" can never - // dip below the configured floor. Format/bitrate remain as tie-breakers. - if (prefs?.priority === "speed") { - // Positive baseline: callers treat score <= 0 as "reject this - // format", and a slow, deep-queued peer must rank last, not vanish. - const eta = Math.min(estimatedTransferSecs(r, file), 3_600); - let s = 100_000 - eta * 25; - s += q * 50; - if (bitrate) s += Math.min(bitrate, 1500) / 50; - return s; - } - let s = q * 1000; - if (bitrate) s += Math.min(bitrate, 1500) / 10; - if (r.has_free_upload_slot) s += 600; - s += Math.min(r.upload_speed ?? 0, 5_000_000) / 20_000; - s -= Math.min(r.queue_length ?? 0, 50) * 12; - return s; -} - function requireOnline() { if (app.connection !== "online") { app.showLogin = true; @@ -4528,135 +4212,6 @@ function requireOnline() { return true; } -// Markers that name a DIFFERENT RECORDING of the same song — live takes, -// remixes, demos, covers, radio edits… `normText` strips brackets, so -// "Island in the Sun (Live at X).mp3" matches the studio request perfectly; -// these are tested against the RAW filename + folder to catch that. Matching -// is symmetric: a candidate must carry exactly the markers the requested -// title carries — no live file for a studio request, no studio file for an -// explicitly "(Live)" catalog track. -const TRACK_VARIANT_MARKERS = [ - /\blive\b/i, - /\bunplugged\b/i, - /\bacoustic\b/i, - /\bremix(?:es|ed)?\b/i, - /\brmx\b/i, - /\brework(?:ed)?\b/i, - /\bdemo(?:s)?\b/i, - /\binstrumental\b/i, - /\bkaraoke\b/i, - /\bcover(?:s|ed)?\b/i, - /\btribute\b/i, - /\b(?:radio|single)\s+edit\b/i, - /\b(?:club|extended|dub|dance)\s+mix\b/i, - /\bmedley\b/i, - /\bmashup\b/i, - /\bsession(?:s)?\b/i, - /\brehearsal\b/i, - /\bouttake(?:s)?\b/i, - /\balternate\b/i, - /\btake\s+\d+\b/i, - /\bre-?recorded\b/i, - // "'s Version" re-records (Taylor's Version and kin): a different - // recording of the song, matched symmetrically like every marker here — - // found live by the source audit when a request for "22" top-ranked - // "22 (Taylor's Version)". - /\b\w+['’]s\s+version\b/i, - /\bsped\s+up\b/i, - /\bslowed\b/i, - /\b8[\s-]?bit\b/i, -]; -// Checked against the filename only: "Reprise" is also a record label -// (Weezer's!), so folder paths like "… (Reprise, 1996)" must not reject. -const TRACK_VARIANT_NAME_ONLY = [/\breprise\b/i]; - -/** True when the candidate names a different version of the song than the - * request asked for (either direction). */ -function variantMismatch(wantedTitle, candidateName, candidateFolder) { - const cand = `${candidateFolder}\\${candidateName}`; - for (const re of TRACK_VARIANT_MARKERS) { - if (re.test(cand) !== re.test(wantedTitle)) return true; - } - for (const re of TRACK_VARIANT_NAME_ONLY) { - if (re.test(candidateName) !== re.test(wantedTitle)) return true; - } - return false; -} - -/** A peer file's playtime is the wrong recording when it's far from the - * catalog's known length — most often a ripped 30-second streaming *preview* - * whose filename matches the real track (e.g. a 30s "Long Season" for a - * 41-minute live track). Only judges when BOTH durations are known; the band - * is generous (±20%, min 45s) so gapless trims and a few seconds of trailing - * silence never trip it. */ -function durationMismatch(expectSecs, candSecs) { - if (!expectSecs || !candSecs || expectSecs <= 0 || candSecs <= 0) return false; - const tol = Math.max(45, expectSecs * 0.2); - return Math.abs(candSecs - expectSecs) > tol; -} - -/** Ranked candidate files for a song across all responses (best first). - * `album` (title string, optional) biases toward folders of the release the - * user is actually looking at. `expectSecs` (optional) is the catalog track's - * known length, used to drop preview clips / wrong-length rips. */ -function songCandidates( - responses, - title, - streamOnly = false, - album = null, - expectSecs = null, - artist = null, - requireIdentityEvidence = false, -) { - const want = normText(title); - const wantedRaw = title ?? ""; - const wantAlbum = album ? normText(simplifyTitle(album)) : ""; - const artistKey = artist ? normText(artist) : ""; - const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download; - const out = []; - for (const r of responses) { - for (const f of r.files) { - if (!normText(f.name).includes(want)) continue; - if (streamOnly && !RESOLVER_STREAMABLE.has(f.extension)) continue; - if (!passesSourcePrefs(f, prefs)) continue; - if (variantMismatch(wantedRaw, f.name, f.folder)) continue; - if (durationMismatch(expectSecs, f.duration)) continue; - let s = scoreSource(r, f, prefs); - if (s <= 0) continue; - const albumEvidence = wantAlbum && normText(f.folder).includes(wantAlbum); - const artistEvidence = - artistKey && normText(`${f.folder}\\${f.name}`).includes(artistKey); - // A rung that dropped the artist term (server-excluded phrase) - // matches by title alone, which happily returns other artists' - // covers of the song. The peer's PATH must then prove identity: - // the artist somewhere in the folder/filename, or the exact - // release folder. - if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) { - continue; - } - // The requested release's own folder beats compilation/misc rips - // (below a format-tier jump, so quality still wins). - if (albumEvidence) s += 800; - if (artistEvidence) s += 400; - // Filename ends with the title → it IS this song, not a longer - // title containing it ("Holiday" vs "Holiday in Cambodia"). - if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250; - out.push({ username: r.username, file: f, score: s, slotFree: !!r.has_free_upload_slot }); - } - } - out.sort((a, b) => b.score - a.score); - // STREAMING needs bytes now: a peer with no free upload slot usually - // queues the request and never starts inside the failover window, and the - // quality-first score let busy lossless peers occupy the whole failover - // list — "Da Funk", with hundreds of sources, spent over a minute walking - // dead candidates (2026-08-03). Stable partition: peers that can deliver - // immediately first, best quality within each half. Downloads keep the - // pure ranking — a save can afford to wait in a queue for the better file. - if (streamOnly) { - return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)]; - } - return out; -} /** Exposed for the source-resolution audit harness (session-scratchpad CDP * probes drive the REAL matching/scoring code against live search responses). @@ -4691,103 +4246,6 @@ function waitForStreamStart(ms) { }); } -/** Ranked candidate folders for an album (best first). `trackCountOverride` - * is the authoritative track count from the catalog tracklist fetch; prefer it - * over `album.track_count`, which is routinely null when Play is pressed on the - * artist page (Deezer's artist-album LIST endpoint omits nb_tracks). */ -function albumCandidates( - responses, - album, - streamOnly = false, - trackCountOverride = null, - requireIdentityEvidence = false, -) { - const artistKey = normText(album.artist ?? ""); - const wantAlbum = normText(simplifyTitle(album.title)); - const wanted = trackCountOverride ?? album.track_count ?? null; - const year = album.release_date?.slice(0, 4) || null; - // Self-titled disambiguation: the "(Blue Album)" variant, and its bare - // colour word, so we can prefer the right folder and reject a wrong-colour - // one that a fuzzy peer search returns anyway. - const variant = (albumDisambiguator(album.title) || album.variant || "").toLowerCase(); - const wantColour = variant.match(/([a-z]+)\s+album\b/)?.[1] || null; - const folders = new Map(); - for (const r of responses) { - for (const f of r.files) { - const key = `${r.username}|${f.folder}`; - let g = folders.get(key); - if (!g) { - g = { username: r.username, folder: f.folder, files: [], r }; - folders.set(key, g); - } - g.files.push(f); - } - } - const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download; - const out = []; - for (const g of folders.values()) { - const audio = g.files.filter( - (f) => - (streamOnly - ? RESOLVER_STREAMABLE.has(f.extension) - : RESOLVER_AUDIO_QUALITY[f.extension]) - && passesSourcePrefs(f, prefs), - ); - // Require enough streamable files to plausibly be this release, but - // NEVER default an unknown count to 3 — that silently rejected every - // single/2-track release (whose peer folders hold 1–2 matching files) - // and is exactly why "No streamable album sources found" fired for - // real, obviously-streamable singles like ICARUS. Known count → at most - // 3, floor 1; unknown → just 1. - const minAudio = wanted ? Math.min(3, Math.max(1, wanted)) : 1; - if (audio.length < minAudio) continue; - const top = audio - .map((f) => scoreSource(g.r, f, prefs)) - .reduce((a, b) => a + b, 0) / audio.length; - let s = top; - const folderNorm = normText(g.folder); - const albumEvidence = folderNorm.includes(wantAlbum); - const artistEvidence = artistKey && folderNorm.includes(artistKey); - // Artist-less query rung (server-excluded phrase): the folder path - // must prove the release's identity — see songCandidates. - if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) { - continue; - } - if (albumEvidence) s += 400; - if (artistEvidence) s += 300; - // Prefer folders whose track count matches the release. - if (wanted) s -= Math.min(Math.abs(audio.length - wanted), 20) * 60; - // The release year in the folder path is a strong per-release signal - // (distinguishes reissues and same-named albums). - if (year && g.folder.includes(year)) s += 300; - // Self-titled colour disambiguation: reward the matching "(X Album)" - // folder, and strongly reject a folder that clearly names a different - // colour, so "Weezer (Blue Album)" can't stream from a Red folder. - if (wantColour) { - const folderColours = [ - ...g.folder.toLowerCase().matchAll(/\b([a-z]+)\s+album\b/g), - ].map((m) => m[1]); - if (folderColours.includes(wantColour)) s += 500; - else if (folderColours.length) s -= 900; - } - audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); - out.push({ - username: g.username, - folder: g.folder, - files: g.files, - audio, - score: s, - slotFree: !!g.r.has_free_upload_slot, - }); - } - out.sort((a, b) => b.score - a.score); - // Same availability partition as songCandidates, same reason: a streamed - // album must START; a busy peer's better folder is a save-time choice. - if (streamOnly) { - return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)]; - } - return out; -} /** Walk a LADDER of query shapes until one returns responses. Each rung is * sanitized against the server's excluded-phrase list first (a poisoned term @@ -4799,74 +4257,8 @@ function albumCandidates( * * `queries` may be a string (single rung) or an array, best-first. */ async function resolve(label, queries, goodCount, artistTerm = null) { - resolver.active = true; - resolver.label = label; - resolver.error = ""; player.error = ""; - try { - const artistKey = artistTerm ? normText(artistTerm) : ""; - const seen = new Set(); - const ladder = []; - for (const raw of [].concat(queries).filter(Boolean)) { - // Each shape also gets an ASCII-folded rung: peers name files - // unaccented, and the server AND-matches query tokens, so - // "Björk Jóga" finds nothing that "Bjork Joga" finds. - for (const variant of [raw, asciiFold(raw)]) { - const q = sanitizeSearchQuery(variant); - if (!q || seen.has(q)) continue; - seen.add(q); - ladder.push(q); - } - } - // Non-final rungs get a short window: a rung the network answers at - // all answers within ~2s, so a silent (poisoned/warm-up) rung should - // cost 3.5s, not the full budget. Recently-silent rungs are skipped - // outright. The post-login warm-up retry moved BEHIND the ladder: if - // ANY rung answered, connectivity is proven and no retry is owed — - // the old first-rung retry burned 7 extra seconds on every poisoned - // artist query before the fallback rung even ran. - let sawSilentArtistRung = false; - for (let index = 0; index < ladder.length; index++) { - const q = ladder[index]; - if (querySilent(q)) continue; - const final = index === ladder.length - 1; - const hasArtist = artistKey && normText(q).includes(artistKey); - const responses = await collectSources(q, final ? 7000 : 3500, goodCount); - if (responses.length) { - // An answered term-less rung after a silent artist rung PROVES - // the artist term is what kills the query — learn it, so the - // artist's other releases skip the dead rung entirely. - if (sawSilentArtistRung && !hasArtist && artistTerm) { - learnedPoisonedTerms.add(artistTerm.toLowerCase().trim()); - } - return { - responses, - artistIncluded: !artistKey || normText(q).includes(artistKey), - }; - } - if (hasArtist) sawSilentArtistRung = true; - silentQueries.set(q, performance.now()); - } - // Whole ladder silent: either the terms are all poisoned or we are in - // the post-login window where the server has not attached us to a - // search parent yet. One quiet retry of the primary rung covers the - // latter (and clears its silent mark on success). - if (ladder.length) { - const q = ladder[0]; - const responses = await collectSources(q, 7000, goodCount); - if (responses.length) { - silentQueries.delete(q); - return { - responses, - artistIncluded: !artistKey || normText(q).includes(artistKey), - }; - } - } - return { responses: [], artistIncluded: true }; - } finally { - resolver.active = false; - resolver.label = ""; - } + return resolveSources(label, queries, goodCount, artistTerm, resolver); } // ---- library-first playback ---- @@ -4877,40 +4269,7 @@ async function resolve(label, queries, goodCount, artistTerm = null) { * artist page's ✓ badges, plus the simplified-title variant so verbose * catalog editions still match). */ function libraryAlbumFor(artist, title, releaseDate = null) { - const a = normText(artist); - const t1 = normText(title); - if (!a || !t1) return null; - // normText strips ALL bracket content, so base-title comparison alone - // cannot tell self-titled variants apart: "Weezer (Blue Album)" and - // "Weezer (Green Album)" both normalize to "weezer" and would exact-match - // a library copy saved under the bare provider title "Weezer". After the - // base titles agree, identity therefore needs the variant name - // (albumDisambiguator) to agree — or, when either side lacks one, the - // release YEAR to corroborate. A self-titled base carries no identity at - // all (the title is just the artist), so even a bare↔bare title match - // requires the year there. No usable evidence → no local match; streaming - // the right album beats instantly playing the wrong one. - const wantD = normText(albumDisambiguator(title)); - // The simplified-title fallback lets a library copy saved under a terse - // name match a verbose catalog EDITION (library "Album" ↔ catalog "Album - // (Deluxe Edition)"); disambiguated variants never take it. - const t2 = wantD ? null : normText(simplifyTitle(title)); - const wantYear = releaseDate ? Number(String(releaseDate).slice(0, 4)) || null : null; - const selfTitled = t1 === a; - return ( - libraryStore.albums.find((al) => { - if (normText(al.artist) !== a) return false; - const lt = normText(al.title); - if (lt !== t1 && !(t2 && lt === t2)) return false; - const libD = normText(albumDisambiguator(al.title)); - if (wantD && wantD === libD) return true; - if (wantD !== libD || selfTitled) { - const libYear = Number(al.year) || null; - return wantYear != null && libYear != null && libYear === wantYear; - } - return true; - }) ?? null - ); + return libraryAlbumIn(libraryStore.albums, artist, title, releaseDate); } /** The library album matching a catalog release, or null — the same evidence @@ -4930,37 +4289,9 @@ export function libraryHasAlbum(artist, album) { /** Locate a track in the library by artist + title, searching only that * artist's albums. Returns { album, tracks, index } or null. */ async function libraryTrackFor(artist, title) { - const a = normText(artist); - const t = normText(title); - if (!a || !t) return null; - for (const al of libraryStore.albums) { - if (normText(al.artist) !== a) continue; - try { - const tracks = await api.albumTracks(al.id); - const index = tracks.findIndex((tr) => normText(tr.title) === t); - if (index >= 0) return { album: al, tracks, index }; - } catch { - // library unavailable — fall through to streaming - } - } - return null; + return libraryTrackIn(libraryStore.albums, (id) => api.albumTracks(id), artist, title); } -/** Distill a peer file's tags into the quality info the Now Playing panel - * shows (format/bitrate/sample-rate/bit-depth). All fields are optional — - * many clients only report some, so the panel hides what's missing. */ -function qualityOf(f) { - if (!f) return null; - const ext = f.extension || (f.name || f.path || "").split(".").pop() || ""; - return { - format: ext.toUpperCase(), - bitrate: f.bitrate ?? null, - sampleRate: f.sample_rate ?? null, - bitDepth: f.bit_depth ?? null, - vbr: !!f.vbr, - size: f.size ?? null, - }; -} /** Quality info for a local library track: format from the path, the rest * from the DB (sample-rate/bit-depth/size backfilled for older files at @@ -4980,23 +4311,6 @@ function localQuality(t) { }; } -/** Deezer's CDN renders the same cover at whatever size the URL's size - * segment asks for. Track objects carry the 250px list thumb - * (`cover_medium`), which looks terrible blown up in the player — rewrite it - * to the 1000px xl the album pages use. Non-Deezer URLs pass through - * untouched. */ -function hqCover(url) { - if (!url) return url; - try { - if (!new URL(url).hostname.endsWith("dzcdn.net")) return url; - } catch { - return url; - } - return url.replace( - /\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i, - "/1000x1000$1.$2", - ); -} /** The tier gridCover asks for, chosen by device pixel ratio. * @@ -5236,72 +4550,6 @@ export async function saveSong(artist, title) { return true; } -/** Match a peer file to a catalog track, so queue items carry the tagged - * title instead of the filename. - * - * A track title contained in the filename normally identifies the track — but - * only if it's *specific* to this file. When peers embed the album name in - * every filename ("… - Eggbeater Jesus - 01 Happy Hour.flac"), and the album - * name is also a track title, that album name is a substring of every filename - * and — being long — used to out-rank each track's real, shorter title, so the - * whole album collapsed to one repeated title. So: ignore any title that - * appears in (roughly) every filename, take the longest *specific* match, and - * otherwise fall back to album order when the folder is a complete album. */ -function catalogTrackFor(file, i, files, tracks) { - if (!tracks?.length) return null; - const fname = normText(file.name); - const base = (t) => normText(t.title); - const named = (t) => base(t).length >= 3 && fname.includes(base(t)); - - // PRIMARY: the catalog (Deezer) tracklist, in order. When the folder holds - // exactly the album's track count it's almost always the whole album in - // order, so file `i` is track `i`. That's the only reliable way to tell - // same-named variants apart — "My Name Is Jonas (2024 Remaster)", - // "(Live On BBC…)" and "(Kitchen Tape Demo)" all normalize to one base - // title, so filename matching alone can't say which belongs here (it used - // to pick the longest title, i.e. the verbose live/demo one). Only distrust - // the position if the filename clearly names a DIFFERENT song than the one - // sitting there (a reordered/mismatched folder — handled by the backup). - if (files.length === tracks.length) { - const at = tracks[i]; - if (!fname || named(at) || !tracks.some(named)) return at; - } - - // BACKUP: fuzzy filename→title matching for a partial/reordered folder. - // Drop any title that appears in ~every filename (that's the album name), - // then among the rest prefer the closest duration (disambiguates same-named - // variants), then the longest title. - const contained = tracks.filter(named); - const ubiquity = (t) => files.reduce((n, f) => n + (normText(f.name).includes(base(t)) ? 1 : 0), 0); - const ubiquitous = Math.max(2, Math.ceil(files.length * 0.5)); - let pool = contained.filter((t) => ubiquity(t) < ubiquitous); - if (!pool.length) pool = contained; - if (pool.length) { - const dur = file.duration; - if (dur) { - const off = (t) => Math.abs((t.duration_secs ?? 1e9) - dur); - // Closest duration wins; on a tie prefer the shorter title, i.e. the - // plain studio version over a longer "(Live…)" / "(Demo…)" variant. - return pool.reduce((a, b) => - off(b) < off(a) || (off(b) === off(a) && b.title.length < a.title.length) ? b : a, - ); - } - return pool.reduce((a, b) => (b.title.length > a.title.length ? b : a)); - } - return files.length === tracks.length ? tracks[i] : null; -} - -/** Turn a peer filename into a readable title when no catalog track matches - * it (a genuine bonus/extra file on an expanded edition): drop the extension - * and a leading track-number prefix ("04. ", "1 - ", "07 ") so it reads as a - * title rather than "04. Dragon Ball Z…". Conservative — only strips a number - * that's clearly a track index (has a separator, or a leading zero), so a title - * like "99 Luftballons" is left intact. */ -function cleanTrackName(name) { - const stem = (name ?? "").replace(/\.[^.]+$/, ""); - const stripped = stem.replace(/^\s*(?:\d{1,2}\s*[.\-)]|0\d)\s+/, "").trim(); - return stripped || stem; -} /** Find the best folder for an album; stream it as a queue, failing over to * the next-best folder when a peer never starts sending. Albums already in diff --git a/src/mobile-main.js b/src/mobile-main.js new file mode 100644 index 0000000..cdd05a8 --- /dev/null +++ b/src/mobile-main.js @@ -0,0 +1,9 @@ +// The phone's document. Same modules as the desktop shell and the mini +// player, mounted through its own entry so the layout is the phone's own +// (research/mobile-mockup is the design it implements). +import "./mobile/mobile.css"; +import "./mobile/shell.css"; +import { mount } from "svelte"; +import Mobile from "./mobile/Mobile.svelte"; + +mount(Mobile, { target: document.getElementById("mobile") }); diff --git a/src/mobile/Cover.svelte b/src/mobile/Cover.svelte new file mode 100644 index 0000000..701cf79 --- /dev/null +++ b/src/mobile/Cover.svelte @@ -0,0 +1,26 @@ + + +{#if eager} + {#if url}{:else}{/if} +{:else} + + {#if url}{:else}{/if} + +{/if} + + diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte new file mode 100644 index 0000000..caea356 --- /dev/null +++ b/src/mobile/HalfSheet.svelte @@ -0,0 +1,211 @@ + + +{#snippet row(icon, t, m, fn)} + +{/snippet} + +
+
+
+ {#if kind === "track" && trackArg} + {@const album = trackArg.album} + {@const track = trackArg.track} +
+ {#if isCatalog(track) && (track.catalog?.cover || album?.art_path)}{:else}{/if} + {track.title}{track.artist || album?.artist} · {album?.title} +
+
+ {#if isCatalog(track)} + {@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))} + {@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack(track, album); })} +
+ {#if album?.catalogId != null} + {@render row("disc", "Go to record", album.title, act(() => push("catalogAlbum", { id: album.catalogId, artist: album.artist, title: album.title })))} + {/if} + {#if album?.artist || track.artist} + {@render row("users", "Go to artist", track.artist || album.artist, act(() => push("artist", { name: track.artist || album.artist })))} + {/if} + {:else} + {@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))} + {@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([{ t: track, album }])))} +
+ {#if album?.id != null && !onThisAlbum(album.id)} + {@render row("disc", "Go to album", album.title, act(() => push("album", { id: album.id })))} + {/if} + {#if album?.artist || track.artist} + {@render row("users", "Go to artist", track.artist || album.artist, act(() => push("artist", { name: track.artist || album.artist })))} + {/if} + {/if} +
+ {#if !isCatalog(track)} +
+
Format{fmtOf(track) || "—"}
+ {#if track.size}
Size{fmtBytes(track.size)}
{/if} +
File{basename(track.path)}
+
Folder{dirname(track.path)}
+
+ {/if} + {:else if (kind === "album" || kind === "album-more") && albumArg} + {@const album = albumArg.album} + {@const tracks = albumArg.tracks || []} +
+ + {album.title}{album.artist} · {album.year ?? "—"} · {tracks.length || album.track_count} tracks +
+
+ {@render row("play", "Play next", "", act(() => playNext(tracks.map((t) => ({ t, album })))))} + {@render row("list-plus", "Add to queue", tracks.length + " tracks", act(() => enqueue(tracks.map((t) => ({ t, album })))))} +
+ {@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))} +
+
+
Format{albumFmt(tracks) || "—"}
+
Length{longDur(tracks.reduce((n, t) => n + (t.duration_secs || 0), 0))}
+ {#if tracks[0]?.path}
Folder{dirname(tracks[0].path)}
{/if} +
+ {:else if kind === "librarysort"} +
Sort & view
+
Order
+
+ {#each SORTS as [k, l] (k)} + + {/each} +
+
View
+
+ {#each [["grid", "Grid"], ["list", "List"]] as [k, l] (k)} + + {/each} +
+
+ {:else if kind === "library-more"} +
Library
+
+ {@render row("layers", "Sort and view", "", () => openHalf("librarysort"))} + {@render row("playlist", "Playlists", "", act(() => patch({ tab: "playlists" })))} +
+ {@render row("refresh", "Rescan library", "looks for new files in the folder", act(rescan))} +
+ {:else if kind === "home-more"} +
SoulStream
+
+ {#if player.track} + {@render row("list-plus", "Queue", after + " after this", act(() => { nav.face = "queue"; openSheet(null); }))} + {/if} + {@render row("clock", "Sleep timer", sleepLabel(), () => openHalf("sleep"))} + {@render row("settings", "Settings", "", act(() => go("you")))} +
+ {:else if kind === "community"} +
Community
+
+ {@render row("refresh", "Refresh the rooms", slsk.rooms.length + " on the server", act(refreshRooms))} + {@render row("x", "Sign out", slsk.username, act(disconnect))} +
+
Spaces, holdings and the sealed inbox wait for the private layer on the phone.
+ {:else if kind === "roomusers" && shown?.arg?.name} + {@const room = slsk.joined[shown.arg.name]} +
#{shown.arg.name} · {room?.users.length ?? 0} here
+
+ {#each (room?.users ?? []).slice(0, 120) as u (u.username)} + {@const buddy = slsk.buddies.includes(u.username)} + + {/each} +
+
+ {:else if kind === "sleep"} +
Sleep timer
+
Pause after
+
+ {#each [[0, "Off"], [15, "15 min"], [30, "30 min"], [45, "45 min"], [60, "1 hour"], [90, "1½ hours"]] as [m, l] (m)} + + {/each} +
+ {#if player.sleepAt}
Playback pauses in {sleepLabel().replace(" left", "")}.
{/if} + {/if} +
+
diff --git a/src/mobile/Lane.svelte b/src/mobile/Lane.svelte new file mode 100644 index 0000000..7d7607f --- /dev/null +++ b/src/mobile/Lane.svelte @@ -0,0 +1,109 @@ + + + + {#each Array(bars) as _, i (i)} + + {/each} + diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte new file mode 100644 index 0000000..7114cc2 --- /dev/null +++ b/src/mobile/Mobile.svelte @@ -0,0 +1,282 @@ + + +
+ + + +
+
+ {#key nav.seq} +
{ scrolled = e.currentTarget.scrollTop; }} + > + {#if entry.n === "home"} + + {:else if entry.n === "search"} + + {:else if entry.n === "library"} + + {:else if entry.n === "album"} + + {:else if entry.n === "artist"} + + {:else if entry.n === "catalogAlbum"} + + {:else if entry.n === "playlist"} + + {:else if entry.n === "community"} + + {:else if entry.n === "room"} + + {:else if entry.n === "thread"} + + {:else if entry.n === "you"} + + {:else if entry.n === "transfers"} + + {:else} + + {/if} +
+ {/key} +
+ + +
+ + {meta.title}{#if meta.kicker && meta.kicker !== meta.title}{meta.kicker}{/if} + +
+
+ + {#if player.track} +
{ e.preventDefault(); openHalf("track", { album: player.album, track: player.track }); }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Now playing"> + + + {player.track.title} + {#if resolver.active && !player.track.stream} + Finding the best source… + {:else if player.track.stream && (player.phase === "hold" || player.phase === "loading")} + Finding {player.track.stream.username}’s copy… + {:else if player.track.stream} + {player.track.artist || player.album?.artist || ""} · streaming from {player.track.stream.username} + {:else} + {player.track.artist || player.album?.artist || ""} + {/if} + + + + + +
+ {/if} + + + + + + + + {#if player.error || lib.error} +
{ player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}
+ {/if} +
diff --git a/src/mobile/Release.svelte b/src/mobile/Release.svelte new file mode 100644 index 0000000..81000e1 --- /dev/null +++ b/src/mobile/Release.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/mobile/Sheet.svelte b/src/mobile/Sheet.svelte new file mode 100644 index 0000000..2490df2 --- /dev/null +++ b/src/mobile/Sheet.svelte @@ -0,0 +1,275 @@ + + +
+ {#if player.album} + +
+
+ + + +
+ +
+
+
+ + {#if !small} +
+ {#if nextEntry} + + {/if} +
+ {:else} +
+ {#if face === "lyrics"} +
+ {#if lyr.status === "loading"} +
Looking for the words…
+ {:else if lyr.lines.length} + {#each lyr.lines as l, i (i)} + {#if !l.text.trim()} +
+ {:else} + + {/if} + {/each} + {:else if lyr.plain} +
{lyr.plain}
+
Plain lyrics — nothing timed for this recording.
+ {:else if lyr.status === "instrumental"} +
Instrumental.
+ {:else} +
No lyrics found for this track.
+ {/if} +
+ {:else} +
+
+

Up next · from {player.from || player.album.title}

+ +
+ {#each player.queue.slice(player.qi + 1, player.qi + 41) as e, k (player.qi + 1 + k)} + {@const prevAlbum = k === 0 ? player.album?.title : player.queue[player.qi + k]?.album?.title} + {#if e.album?.title !== prevAlbum} +
{e.album?.artist} · {e.album?.title}
+ {/if} + + {/each} + {#if player.qi + 1 >= player.queue.length} +
End of the queueNothing after this.
+ {/if} +
+ {/if} +
+ {/if} + +
+
+
{player.track?.title}
+ {#if artistInLibrary} + + {:else} + {artistName} + {/if} +
+
+ +
+ +
{mmss(player.position)}-{mmss(remaining)}
+
+ +
+ + + + + +
+ +
+
+ {#each [["art", "disc", "Art"], ["lyrics", "lyrics", "Lyrics"], ["queue", "playlist", "Queue"]] as [f, ic, lb] (f)} + + {/each} +
+
+
+ {/if} +
diff --git a/src/mobile/art.svelte.js b/src/mobile/art.svelte.js new file mode 100644 index 0000000..b805b7f --- /dev/null +++ b/src/mobile/art.svelte.js @@ -0,0 +1,124 @@ +/* Artwork on the phone: the thumbnail cache behind a gate, and the accent + each cover yields. + + Three decodes in flight at most, newest request first — the cover that + just scrolled into view is the one the eye is on (the desktop's library + art gate, LIFO, same reason). A cover is asked for when its card is about + to be seen (the `visible` action) or at once when something needs it now + (the ground, the accent, the mini bar). */ +import * as api from "../lib/api.js"; +import { accentFromImage } from "../lib/color.js"; + +/** art_path -> data URL; "" once a fetch failed, so it is never retried */ +export const art = $state({}); + +export const DEFAULT_ACCENT = { accent: "#1f9ad6", hover: "#41b7ee" }; + +const pending = new Map(); // path -> { promise, resolve } +const waiting = []; // LIFO +let inFlight = 0; + +/** The data URL for a cover, fetching it if needed. `urgent` moves it to + * the front of the line. Resolves "" when there is nothing to show. */ +export function loadArt(path, urgent = false) { + if (!path) return Promise.resolve(""); + if (art[path] !== undefined) return Promise.resolve(art[path]); + /* a streamed record's cover comes out of the file's own tags once enough + of it is here (player.svelte.js sets it); nothing to fetch, and a miss + must not be remembered */ + if (isStreamKey(path)) return Promise.resolve(""); + const p = pending.get(path); + if (p) { + if (urgent) { + const i = waiting.indexOf(path); + if (i >= 0) { waiting.splice(i, 1); waiting.push(path); } + } + return p.promise; + } + let resolve; + const promise = new Promise((r) => { resolve = r; }); + pending.set(path, { promise, resolve }); + waiting.push(path); + pump(); + return promise; +} + +/* a library cover comes from the thumbnail cache; a catalog picture (an + https URL) comes through the desktop's art proxy, which caches it and + hands back a data URL — so the ground and the accent can read it too */ +function fetchArt(p) { + return /^https?:\/\//.test(p) ? api.catalogArt(p) : api.albumArt(p, 360); +} + +function pump() { + while (inFlight < 3 && waiting.length) { + const p = waiting.pop(); + inFlight++; + fetchArt(p) + .then((u) => u || "", () => "") + .then((u) => { + art[p] = u; + const w = pending.get(p); + pending.delete(p); + if (w) w.resolve(u); + }) + .finally(() => { inFlight--; pump(); }); + } +} + +export function wantArt(path) { void loadArt(path); } + +/** use:visible={path} — ask for a cover when its card is about to be seen. */ +export function visible(node, path) { + let io = null; + const arm = (p) => { + if (io) { io.disconnect(); io = null; } + if (!p || art[p] !== undefined) return; + io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + wantArt(p); + io.disconnect(); + io = null; + } + }, + { rootMargin: "320px" }, + ); + io.observe(node); + }; + arm(path); + return { + update(p) { arm(p); }, + destroy() { if (io) io.disconnect(); }, + }; +} + +/* ── the accent ───────────────────────────────────────────────────────── + src/lib/color.js, the desktop's own extraction, on the same 360px thumb + the cards show. Cached per cover: a record's colour never changes. */ +const accents = new Map(); // path -> Promise<{ accent, hover }> + +export const isStreamKey = (path) => typeof path === "string" && path.startsWith("stream:"); + +export function accentFor(path) { + if (!path) return Promise.resolve(DEFAULT_ACCENT); + if (isStreamKey(path) && !art[path]) return Promise.resolve(DEFAULT_ACCENT); + if (!accents.has(path)) { + accents.set( + path, + loadArt(path, true) + .then((u) => (u ? accentFromImage(u) : null)) + .then((c) => (c ? { accent: c.accent, hover: c.hover } : DEFAULT_ACCENT)) + .catch(() => DEFAULT_ACCENT), + ); + } + return accents.get(path); +} + +/** Set the app's accent. The registered property + the transition on html + * (mobile.css) crossfade it on the compositor. */ +export function applyAccent(c) { + const s = document.documentElement.style; + s.setProperty("--accent", c.accent); + s.setProperty("--accent-hover", c.hover); +} diff --git a/src/mobile/catalog.svelte.js b/src/mobile/catalog.svelte.js new file mode 100644 index 0000000..4ecfcc8 --- /dev/null +++ b/src/mobile/catalog.svelte.js @@ -0,0 +1,160 @@ +/* The catalog on the phone: the desktop's lookups (Deezer through the + metadata crate, the art proxy for pictures), cached for the session. + + An artist page opens cold — the library's own shell, instantly — and the + lookup fills the rest in: the picture, Popular, the discography. The + identity rule is the desktop's (`openArtistByName`): among the search's + exact-name matches, the most-followed wins, so a shared name resolves to + the canonical artist and never to a namesake the search listed first. */ +import * as api from "../lib/api.js"; + +export const catalog = $state({ + /** lowercase name → { status: loading | ready | none | error, id, seed, page, error } */ + artists: {}, + /** catalog album id → { status, page, error } */ + albums: {}, + /** artist id → { status, related, profile, bio } — fans also like, the + * Last.fm profile (listeners, plays, tags), the bio; once per artist */ + extras: {}, + /** artist id → { status, listeners, plays, tags } — the profile alone, + * for the search hero */ + profiles: {}, + /** lowercase name → CatalogArtist | null — a portrait for a byline */ + seeds: {}, + /** "artist\u0000title" → { status, info: { description, genre, label, source } } */ + albumInfo: {}, +}); + +export function artistKey(name) { + return String(name || "").trim().toLowerCase(); +} + +/** The most-followed exact match for a name, or the best the search has. */ +async function bestArtistFor(name) { + const k = artistKey(name); + const res = await api.catalogSearch(name); + const artists = res?.artists ?? []; + const exact = artists.filter((a) => artistKey(a.name) === k); + const pool = exact.length ? exact : artists; + return pool.reduce((top, a) => ((a.fans ?? 0) > (top?.fans ?? -1) ? a : top), null); +} + +/** Open an artist by name — or by the search's own row (`seed`, a + * CatalogArtist with its id), which skips the identity search: the row + * IS the identity, namesake-safe, and its portrait is on screen at once. */ +export function lookupArtist(name, seed = null) { + const k = artistKey(name); + if (!k) return; + const cur = catalog.artists[k]; + if (cur && cur.status !== "error") return; + catalog.artists[k] = { status: "loading", id: seed?.id ?? null, seed: seed ?? null, page: null }; + (async () => { + try { + const best = seed?.id != null ? seed : await bestArtistFor(name); + if (!best) { catalog.artists[k] = { status: "none", id: null, seed: null, page: null }; return; } + catalog.artists[k] = { status: "loading", id: best.id, seed: best, page: null }; + const page = await api.catalogArtist(best.id); + catalog.artists[k] = { status: "ready", id: best.id, seed: best, page }; + } catch (e) { + /* transport failures stay retryable — the next visit asks again */ + catalog.artists[k] = { status: "error", id: null, seed: null, page: null, error: String(e) }; + } + })(); +} + +/** Fans also like, the Last.fm profile and the bio — the desktop's + * ArtistExtras, asked once per artist after the page is on screen. */ +export function loadExtras(id, name) { + if (id == null || catalog.extras[id]) return; + catalog.extras[id] = { status: "loading", related: [], profile: null, bio: "", socials: [] }; + Promise.all([ + api.catalogRelated(id).catch(() => []), + api.catalogArtistProfile(id).catch(() => null), + api.catalogArtistBio(id, name).catch(() => null), + /* the socials are a deferred MusicBrainz pass on the desktop too: + rate-limited upstream, asked after the page is on screen */ + api.catalogArtistSocials(id).catch(() => []), + ]).then(([r, p, b, l]) => { + const text = typeof b === "string" ? b : (b?.text ?? b?.bio ?? ""); + catalog.extras[id] = { status: "ready", related: r ?? [], profile: p ?? null, bio: text ?? "", socials: Array.isArray(l) ? l : [] }; + if (p) catalog.profiles[id] = { status: "ready", ...p }; + }); +} + +/** The profile alone: the search hero's tags and totals. */ +export function loadProfile(id) { + if (id == null || catalog.profiles[id]) return; + catalog.profiles[id] = { status: "loading" }; + api.catalogArtistProfile(id).then( + (p) => { catalog.profiles[id] = { status: "ready", ...(p || {}) }; }, + () => { catalog.profiles[id] = { status: "error" }; }, + ); +} + +/** A portrait for a byline — the same identity rule, search only. */ +export function seedFor(name) { + const k = artistKey(name); + if (!k || k in catalog.seeds) return; + catalog.seeds[k] = null; + const known = catalog.artists[k]?.seed; + if (known) { catalog.seeds[k] = known; return; } + bestArtistFor(name).then((a) => { catalog.seeds[k] = a || null; }, () => {}); +} + +export const infoKey = (artist, title) => String(artist || "").toLowerCase() + "\u0000" + String(title || "").toLowerCase(); +/** About this record: Wikipedia's words, the genre and the label, through + * the desktop's own lookup. Once per record. */ +export function loadAlbumInfo(artist, title) { + const k = infoKey(artist, title); + if (!artist || !title || catalog.albumInfo[k]) return; + catalog.albumInfo[k] = { status: "loading", info: null }; + api.catalogAlbumInfo(null, artist, title).then( + (info) => { catalog.albumInfo[k] = { status: "ready", info: info || null }; }, + () => { catalog.albumInfo[k] = { status: "error", info: null }; }, + ); +} + +export function lookupAlbum(id) { + if (id == null) return; + const cur = catalog.albums[id]; + if (cur && cur.status !== "error") return; + catalog.albums[id] = { status: "loading", page: null }; + api.catalogAlbum(id).then( + (page) => { catalog.albums[id] = { status: "ready", page }; }, + (e) => { catalog.albums[id] = { status: "error", page: null, error: String(e) }; }, + ); +} + +/** Deezer answers a missing artist photo with a grey silhouette IMAGE under + * the hash of the empty string (the metadata crate's + * DEEZER_EMPTY_PICTURE_HASH), which reads as a light disc against the dark + * UI. Treat it as no picture, so the app's own placeholder shows. */ +const DEEZER_EMPTY_PICTURE_HASH = "d41d8cd98f00b204e9800998ecf8427e"; +export function picture(url) { + if (!url) return null; + const u = String(url); + return u.includes(DEEZER_EMPTY_PICTURE_HASH) || /\/images\/[a-z]+\/\//.test(u) ? null : u; +} + +/** "1.2M fans", the desktop's stat chip */ +export function fans(n) { + if (!n) return ""; + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M fans"; + if (n >= 1000) return Math.round(n / 1000) + "k fans"; + return n + " fans"; +} + +export function yearOf(date) { + const m = /^(\d{4})/.exec(String(date || "")); + return m ? m[1] : ""; +} + +/** Titles compared loosely: case, punctuation and bracketed suffixes + * ("(Remastered)") do not make a different record. */ +export function looseTitle(s) { + return String(s || "") + .toLowerCase() + .replace(/\s*[([].*?[)\]]\s*/g, " ") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} diff --git a/src/mobile/disco.js b/src/mobile/disco.js new file mode 100644 index 0000000..0c39257 --- /dev/null +++ b/src/mobile/disco.js @@ -0,0 +1,94 @@ +/* The discography as the desktop files it (CatalogArtist.svelte's tabs): + one category per release, the categories' order and labels from the + shared TAB_DEFS, editions folded into families by editions.js. The + desktop also weighs MusicBrainz type hints and each record's own + description before the title heuristics; the phone files on what the + catalog page carries — the edition link, the announced date, the + mixtape flag, then the same title rules the library uses. */ +import { categoryForLocal, TAB_DEFS } from "../lib/localReleaseType.js"; +import { releaseFamilies } from "../lib/editions.js"; + +const today = () => new Date().toISOString().slice(0, 10); + +/** An announced full-length: dated, and the date is still ahead. */ +export const isUpcoming = (a) => + (a.record_type || "").toLowerCase() === "album" + && typeof a.release_date === "string" && a.release_date.length >= 10 + && a.release_date.slice(0, 10) > today(); + +function ownCategory(album) { + if (isUpcoming(album)) return "upcoming"; + if (album.is_mixtape) return "mixtape"; + return categoryForLocal(album); +} + +/** A linked edition files WITH its base release (one hop). */ +export function categoryFor(album, albums) { + if (album.edition_group != null && album.id !== album.edition_group) { + const base = (albums || []).find((o) => o.id === album.edition_group); + if (base) return ownCategory(base); + } + return ownCategory(album); +} + +/** [{ key, label, items: families }] — only the tabs with something in them. */ +export function discoTabs(albums) { + const list = albums || []; + const by = {}; + for (const def of TAB_DEFS) by[def.key] = []; + for (const a of list) (by[categoryFor(a, list)] ?? by.other).push(a); + return TAB_DEFS + .map((def) => ({ key: def.key, label: def.label, items: releaseFamilies(def.cats.flatMap((c) => by[c] || []), def.key) })) + .filter((t) => t.items.length > 0); +} + +/** "1997–present" / "1997–2013": the released records' span. */ +export function activeYears(albums) { + const years = (albums || []) + .filter((a) => !isUpcoming(a)) + .map((a) => Number(String(a.release_date || "").slice(0, 4))) + .filter((y) => Number.isFinite(y) && y > 1900); + if (!years.length) return null; + const first = Math.min(...years); + const last = Math.max(...years); + const nowYear = new Date().getFullYear(); + return last >= nowYear - 1 ? `${first}–present` : `${first}–${last}`; +} + +/** The newest dated record. */ +export function latestOf(albums) { + let best = null; + for (const a of albums || []) { + if (!a.release_date) continue; + if (!best || a.release_date > best.release_date) best = a; + } + return best; +} + +/** One column per year across the whole span, gaps included — null under + * three years, when it would say nothing. */ +export function timelineOf(albums) { + const byYear = new Map(); + for (const a of albums || []) { + const y = String(a.release_date || "").slice(0, 4); + if (y) byYear.set(y, (byYear.get(y) ?? 0) + 1); + } + const years = [...byYear.keys()].sort(); + if (years.length < 3) return null; + const first = Number(years[0]); + const last = Number(years[years.length - 1]); + const peak = Math.max(...byYear.values()); + const cols = []; + for (let y = first; y <= last; y++) { + const n = byYear.get(String(y)) ?? 0; + cols.push({ year: y, n, h: n ? Math.max(8, Math.round((n / peak) * 44)) : 2 }); + } + return { cols, first, last, total: (albums || []).length }; +} + +/** The desktop's social chips: a label per link kind. */ +export const SOCIAL_LABEL = { + website: "Website", wikipedia: "Wikipedia", bandcamp: "Bandcamp", soundcloud: "SoundCloud", + youtube: "YouTube", instagram: "Instagram", twitter: "X", x: "X", facebook: "Facebook", tiktok: "TikTok", + spotify: "Spotify", apple: "Apple Music", discogs: "Discogs", lastfm: "Last.fm", musicbrainz: "MusicBrainz", +}; diff --git a/src/mobile/home.svelte.js b/src/mobile/home.svelte.js new file mode 100644 index 0000000..a77703c --- /dev/null +++ b/src/mobile/home.svelte.js @@ -0,0 +1,148 @@ +/* Home's material on the phone — the desktop's homeStore: one slot per + followed artist (their catalog page's records, the MusicBrainz-only + ones, six related artists), the news across all of them, warmed the + same way (home_warm fills the backend caches this walk reads). The rows + themselves — the feed, the back catalogue, the recommendations, the + set to complete — are derived in Home.svelte from these slots with the + desktop's own rules, copied. */ +import * as api from "../lib/api.js"; +import { listen } from "../lib/events.js"; +import { lib, refreshFollows } from "./lib.svelte.js"; + +export const home = $state({ + started: false, + /** artistId → { id, name, picture, albums, mb, related, error } */ + slots: {}, + order: [], + /** [{ artist, title, url, source, date (unix secs) }] newest first */ + news: [], + newsLoaded: false, + loading: false, +}); + +async function loadArtist(f) { + const slot = { id: f.artist_id, name: f.name, picture: f.portrait ?? null, albums: [], mb: [], related: [] }; + try { + const page = await api.catalogArtist(f.artist_id); + slot.name = page?.artist?.name ?? f.name; + slot.picture = page?.artist?.picture ?? slot.picture; + slot.albums = (page?.albums ?? []).filter((a) => !a.appears_on); + } catch { slot.error = true; } + try { + const identity = await api.catalogArtistIdentity(f.artist_id, slot.name); + slot.mb = identity?.albums ?? []; + } catch { /* the Deezer page stands alone */ } + try { + slot.related = ((await api.catalogRelated(f.artist_id)) ?? []).slice(0, 6); + } catch { /* fine */ } + home.slots[f.artist_id] = slot; +} + +let loadSeq = 0; +/** Bring the landing page's material in. Safe to call again: warm caches + * make a re-walk cost milliseconds. `force` re-reads every slot. */ +export async function loadHome(force = false) { + if (home.started && !force) return; + home.started = true; + const my = ++loadSeq; + home.loading = true; + void api.homeWarm().catch(() => {}); + await refreshFollows(); + const follows = lib.follows; + home.order = follows.map((f) => f.artist_id); + for (const f of follows) { + if (my !== loadSeq) return; + if (force || !home.slots[f.artist_id]) await loadArtist(f); + } + if ((!home.newsLoaded || force) && follows.length) { + try { + const byArtist = await api.homeArtistNews(follows.map((f) => f.name)); + if (my !== loadSeq) return; + home.news = Object.entries(byArtist || {}) + .flatMap(([artist, items]) => (items ?? []).map((n) => ({ ...n, artist }))) + .sort((a, b) => b.date - a.date); + home.newsLoaded = true; + } catch { /* the page reads fine without news */ } + } + if (my === loadSeq) home.loading = false; +} + +let bound = false; +/** A follow made anywhere (the artist page, the desktop's copy of the + * library) re-walks the slots. */ +export async function bindHome() { + if (bound) return; + bound = true; + try { await listen("soulseek:follows-changed", () => { void loadHome(true); }); } catch { /* not a phone */ } +} + +/* ── the desktop's date words ─────────────────────────────────────── */ +const DAY = 86400000; +export const daysFrom = (d) => Math.floor((Date.now() - new Date(d).getTime()) / DAY); +export const future = (d) => !!d && new Date(d).getTime() > Date.now(); +export function age(d) { + if (!d) return ""; + const a = daysFrom(d); + if (a <= 0) return "today"; + if (a === 1) return "yesterday"; + if (a < 7) return a + "d ago"; + if (a < 31) return Math.round(a / 7) + "w ago"; + if (a < 365) return Math.round(a / 30.4) + "mo ago"; + return (a / 365).toFixed(a < 730 ? 1 : 0) + "y ago"; +} +export function ageShort(ts) { + const h = (Date.now() - ts * 1000) / 3600000; + if (h < 1) return "just now"; + if (h < 24) return Math.round(h) + "h ago"; + const a = Math.floor(h / 24); + if (a === 1) return "yesterday"; + if (a < 7) return a + "d ago"; + return Math.round(a / 7) + "w ago"; +} +export function inDays(d) { + const n = -daysFrom(d); + return n === 1 ? "tomorrow" : "in " + n + " days"; +} +export function upcomingLabel(album) { + const value = album.release_date; + if (!value) return "Upcoming · Date TBA"; + if (value.length === 4) return `Expected ${value} · Date TBA`; + if (value.length < 10) { + const month = new Date(`${value.slice(0, 7)}-01T00:00:00`).toLocaleDateString(undefined, { month: "short", year: "numeric" }); + return `Expected ${month} · Date TBA`; + } + const date = new Date(`${value.slice(0, 10)}T00:00:00`); + return `Releases ${date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`; +} +const KIND = { album: "Album", ep: "EP", single: "Single" }; +export const kindLabel = (t) => KIND[t] || "Album"; +export const RANK = { album: 3, ep: 2, single: 1 }; +export const baseTitle = (t) => String(t).replace(/\s*[([].*$/, "").trim().toLowerCase(); + +/** The feed: every follow's records inside the window, upcoming first, the + * editions of one work folded to the best-ranked / newest / longer-titled + * one — the desktop's assembleFeed, verbatim. */ +export function assembleFeed(slots, windowDays) { + const out = []; + for (const slot of slots) { + const kept = []; + const seen = [...(slot.albums ?? []), ...((slot.mb ?? []).map((a) => ({ ...a, mbOnly: true })))]; + for (const r of seen) { + if (!r.release_date || !r.cover) continue; + const upcoming = future(r.release_date); + if (!upcoming && daysFrom(r.release_date) > windowDays) continue; + const cand = { ...r, artistName: slot.name, artistId: slot.id, upcoming }; + const rival = kept.find((k) => k.cover === cand.cover || baseTitle(k.title) === baseTitle(cand.title)); + if (!rival) { kept.push(cand); continue; } + const rr = RANK[rival.record_type] || 0; + const cr = RANK[cand.record_type] || 0; + if (cr > rr || (cr === rr && String(cand.release_date) > String(rival.release_date))) { + kept[kept.indexOf(rival)] = cand; + } else if (cr === rr && cand.release_date === rival.release_date && cand.title.length > rival.title.length) { + rival.title = cand.title; + } + } + out.push(...kept); + } + return out.sort((x, y) => (y.upcoming ? 1 : 0) - (x.upcoming ? 1 : 0) || String(y.release_date).localeCompare(String(x.release_date))); +} diff --git a/src/mobile/lib.svelte.js b/src/mobile/lib.svelte.js new file mode 100644 index 0000000..8e57c66 --- /dev/null +++ b/src/mobile/lib.svelte.js @@ -0,0 +1,123 @@ +/* The library, as the phone reads it: albums from library.db, the recents + from the play history, follows and playlists. One store, loaded once at + launch; the pieces that change (recents) are re-read when they do. */ +import * as api from "../lib/api.js"; +import { listen } from "../lib/events.js"; + +export const lib = $state({ + albums: [], + recents: [], // library rows only, with an album_id + follows: [], + playlists: [], + version: "", + username: null, + loaded: false, + error: "", +}); + +const tracksCache = new Map(); // album id -> Promise + +/** An album's tracks, cached for the session (a record's rows do not + * change under the phone). */ +export function tracksOf(id) { + if (!tracksCache.has(id)) { + tracksCache.set( + id, + api.albumTracks(id).catch((e) => { tracksCache.delete(id); throw e; }), + ); + } + return tracksCache.get(id); +} + +export function albumById(id) { + return lib.albums.find((a) => a.id === id) || null; +} + +/** Newest first — "recently added" everywhere. */ +export function newest() { + return lib.albums.slice().sort((a, b) => b.added_at - a.added_at); +} + +/** An artist's records, newest year first. */ +export function albumsBy(artist) { + const k = String(artist || "").toLowerCase(); + return lib.albums + .filter((a) => a.artist.toLowerCase() === k) + .sort((x, y) => (y.year || 0) - (x.year || 0) || y.added_at - x.added_at); +} + +/** Every artist in the library, biggest first, with the portrait the + * library already holds (artist_art_path) when it has one. Casing splits in + * the tags ("King Gizzard & The/the Lizard Wizard") merge here. */ +export function artists() { + const m = new Map(); + for (const a of lib.albums) { + const k = a.artist.toLowerCase(); + let e = m.get(k); + if (!e) { e = { name: a.artist, albums: 0, tracks: 0, art: null, cover: null }; m.set(k, e); } + e.albums++; + e.tracks += a.track_count || 0; + if (!e.art && a.artist_art_path) e.art = a.artist_art_path; + if (!e.cover && a.art_path) e.cover = a.art_path; + } + return [...m.values()].sort((x, y) => y.albums - x.albums || y.tracks - x.tracks || x.name.localeCompare(y.name)); +} + +export function isFollowed(name) { + const k = String(name || "").toLowerCase(); + return lib.follows.some((f) => f.name.toLowerCase() === k); +} +export function followedEntry(name) { + const k = String(name || "").toLowerCase(); + return lib.follows.find((f) => f.name.toLowerCase() === k) || null; +} +export async function refreshFollows() { + try { lib.follows = (await api.followedArtists()) || []; } catch { /* keep what we had */ } +} +/** Follow or unfollow, by the catalog identity; the list is re-read. */ +export async function toggleFollow(artistId, name, portrait = null) { + const cur = followedEntry(name); + if (cur) await api.unfollowArtist(cur.artist_id); + else await api.followArtist(artistId, name, portrait ?? null); + await refreshFollows(); +} + +export async function refreshRecents() { + try { + const rows = await api.recentAlbums(14); + lib.recents = (rows || []).filter((r) => r.kind === "library" && r.album_id != null); + } catch { + /* the rail simply stays as it was */ + } +} + +/** A download the pipeline just filed, a rescan, an import: the backend + * says so (soulseek:library-changed) and the albums are re-read. */ +let watching = false; +export async function watchLibrary() { + if (watching) return; + watching = true; + let timer = null; + try { + await listen("soulseek:library-changed", () => { + clearTimeout(timer); + timer = setTimeout(async () => { + try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } + await refreshRecents(); + }, 600); + }); + } catch { /* not a phone */ } +} + +export async function loadLibrary() { + try { + const st = await api.appStatus(); + lib.version = st?.version ?? ""; + lib.username = st?.connected_as ?? null; + } catch (e) { lib.error = String(e); } + try { lib.albums = await api.libraryAlbums(); } catch (e) { lib.error = String(e); } + await refreshRecents(); + try { lib.follows = (await api.followedArtists()) || []; } catch { lib.follows = []; } + try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; } + lib.loaded = true; +} diff --git a/src/mobile/media.svelte.js b/src/mobile/media.svelte.js new file mode 100644 index 0000000..433ceb9 --- /dev/null +++ b/src/mobile/media.svelte.js @@ -0,0 +1,75 @@ +/* The phone's media session: the lock-screen card, the shade's transport, + the headset buttons, audio focus. The card is a mirror of the bar — fed + on every change of track or transport, topped up now and then so its + progress does not drift — and its buttons come back as actions the + player carries out. Everything real lives in the plugin + (crates/tauri-plugin-media-session); this is the wiring. */ +import { invoke, addPluginListener } from "@tauri-apps/api/core"; +import { player, hooks, toggle, next, prev, seekSecs } from "./player.svelte.js"; +import { art } from "./art.svelte.js"; + +let lastArtKey = null; +let timer = null; +let bound = false; + +async function push() { + timer = null; + const t = player.track; + if (!t) { + lastArtKey = null; + await invoke("plugin:media-session|clear").catch(() => {}); + return; + } + const key = player.album?.art_path || null; + const url = key ? art[key] : ""; + const state = { + title: t.title, + artist: t.artist || player.album?.artist || "", + album: player.album?.title || "", + playing: player.status === "playing" || player.status === "buffering", + position: player.position, + duration: player.duration, + artKey: key, + art: null, + hasNext: player.qi + 1 < player.queue.length || player.repeat > 0, + hasPrev: true, + }; + /* the cover's bytes ride along once per record, and only once they exist */ + if (key && key !== lastArtKey && url && url.startsWith("data:image")) { + state.art = url.slice(url.indexOf(",") + 1); + lastArtKey = key; + } + await invoke("plugin:media-session|update", { state }).catch(() => {}); +} + +/** Coalesce a burst of changes (track, then status, then position) into one card update. */ +export function syncMedia() { + if (timer) clearTimeout(timer); + timer = setTimeout(push, 60); +} + +/** Wire the card to the player. Harmless where there is no plugin. */ +export async function bindMedia() { + if (bound) return; + bound = true; + hooks.mediaSync = syncMedia; + try { + await addPluginListener("media-session", "action", (p) => { + const playing = player.status === "playing" || player.status === "buffering"; + switch (p?.action) { + case "play": if (!playing) toggle(); break; + case "pause": if (playing) toggle(); break; + case "toggle": toggle(); break; + case "next": next(); break; + case "prev": prev(); break; + case "seek": if (typeof p.position === "number" && p.position >= 0) seekSecs(p.position); break; + case "stop": if (playing) toggle(); break; + } + }); + } catch { + /* not a phone */ + } + /* the card interpolates its progress from the last update; a top-up + every half minute keeps a long track honest */ + setInterval(() => { if (player.track) syncMedia(); }, 30_000); +} diff --git a/src/mobile/mobile.css b/src/mobile/mobile.css new file mode 100644 index 0000000..8efddbe --- /dev/null +++ b/src/mobile/mobile.css @@ -0,0 +1,142 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + SoulStream on Android — foundations. + + The palette is the desktop palette, unchanged (global.css / the mockup's + tokens.css): same greys, same accent, same surface ladder. What is new is + spatial — radii open up, type goes up a step, every tappable thing is 48dp + — and the material: the page ground is the artwork in play, blurred. + Text is authored in px; the Android WebView applies the system font scale + to CSS px on its own, so there is no sp arithmetic here. + ═══════════════════════════════════════════════════════════════════════════ */ + +/* the desktop's face, from the same files */ +@font-face { font-family: "Nunito"; font-style: normal; font-weight: 400; font-display: swap; src: url("/assets/fonts/nunito-400.woff2") format("woff2"); } +@font-face { font-family: "Nunito"; font-style: normal; font-weight: 600; font-display: swap; src: url("/assets/fonts/nunito-600.woff2") format("woff2"); } +@font-face { font-family: "Nunito"; font-style: normal; font-weight: 700; font-display: swap; src: url("/assets/fonts/nunito-700.woff2") format("woff2"); } + +/* Typed registration so the per-artwork accent can TRANSITION. The desktop + crossfades its accent on a rAF loop (state.svelte.js); on the phone the + compositor does it — see the transition on html below. */ +@property --accent { syntax: ""; inherits: true; initial-value: #1f9ad6; } +@property --accent-hover { syntax: ""; inherits: true; initial-value: #41b7ee; } + +:root { + --bg-base: #121212; + --bg-elevated: #181818; + --bg-highlight: #242424; + --bg-press: #2a2a2a; + --text-primary: #ffffff; + --text-secondary: #b3b3b3; + --text-muted: #7a7a7a; + --accent: #1f9ad6; + --accent-hover: #41b7ee; + --accent-soft: color-mix(in srgb, var(--accent) 15%, transparent); + --border: #2a2a2a; + + /* the surface ladder: the same three alphas the desktop's glass mode + uses, because the ground under a surface is the ambience here */ + --surface: rgba(255, 255, 255, 0.03); + --hover: rgba(255, 255, 255, 0.08); + --press: rgba(255, 255, 255, 0.11); + --hairline: rgba(255, 255, 255, 0.1); + --ring: inset 0 0 0 1px rgba(255, 255, 255, 0.06); + --well: rgba(0, 0, 0, 0.18); + + --r-art: 10px; + --r-card: 14px; + --r-sheet: 26px; + --r-pill: 999px; + --pad: 18px; + --gap: 10px; + + --nav-h: 60px; + --mini-h: 70px; /* a 62dp row over a 16dp lane strip */ + --bar-h: 52px; /* the glass bar */ + --tap: 48px; + + /* Android's own insets: the status bar above, the gesture handle below. */ + --inset-top: env(safe-area-inset-top, 0px); + --inset-bottom: env(safe-area-inset-bottom, 0px); + --top-h: calc(var(--bar-h) + var(--inset-top)); + + /* ── motion: real springs, sampled ──────────────────────────────────── + Material 3 Expressive specifies motion as springs; CSS takes a sampled + curve through linear(). These are the mockup's generated tokens + (research/mobile-mockup/tools/springs.mjs): the spatial ones overshoot + because real objects do, the effects ones never do. */ + --sp-spatial-fast: linear(0, 0.0512, 0.176, 0.3375, 0.5079, 0.6679, 0.8057, 0.9155, 0.9963, 1.0502, 1.081, 1.0937, 1.0931, 1.0838, 1.0696, 1.0535, 1.0378, 1.0237, 1.0121, 1.0033, 0.9971, 0.9933, 0.9915, 0.991, 0.9916, 0.9928, 0.9943, 0.9958, 0.9972, 0.9984, 0.9994, 1, 1.0005, 1.0008, 1); + --spd-spatial-fast: 413ms; + --sp-spatial-default: linear(0, 0.0329, 0.1132, 0.2191, 0.335, 0.4503, 0.558, 0.6543, 0.7374, 0.8069, 0.8634, 0.9081, 0.9425, 0.9681, 0.9865, 0.9992, 1.0074, 1.0122, 1.0145, 1.0152, 1.0146, 1.0134, 1.0118, 1.01, 1.0083, 1.0067, 1.0052, 1.0039, 1.0029, 1.002, 1.0014, 1.0009, 1.0005, 1.0002, 1); + --spd-spatial-default: 482ms; + --sp-spatial-slow: linear(0, 0.0309, 0.1068, 0.2078, 0.3194, 0.4314, 0.5372, 0.6329, 0.7165, 0.7874, 0.8459, 0.893, 0.9298, 0.9579, 0.9787, 0.9935, 1.0035, 1.0098, 1.0134, 1.0149, 1.0151, 1.0143, 1.013, 1.0114, 1.0097, 1.008, 1.0065, 1.0051, 1.0039, 1.0029, 1.002, 1.0014, 1.0009, 1.0005, 1); + --spd-spatial-slow: 641ms; + --sp-effects-fast: linear(0, 0.0527, 0.1672, 0.3005, 0.4304, 0.5464, 0.6448, 0.7255, 0.7901, 0.8409, 0.8803, 0.9105, 0.9334, 0.9507, 0.9637, 0.9733, 0.9804, 0.9857, 0.9896, 0.9924, 0.9945, 0.996, 0.9971, 0.9979, 0.9985, 0.9989, 0.9992, 0.9994, 0.9996, 0.9997, 0.9998, 0.9999, 0.9999, 0.9999, 1); + --spd-effects-fast: 202ms; + --sp-effects-default: linear(0, 0.0436, 0.1412, 0.2593, 0.3786, 0.4892, 0.5866, 0.6696, 0.7385, 0.7947, 0.84, 0.876, 0.9044, 0.9266, 0.9439, 0.9572, 0.9675, 0.9754, 0.9814, 0.986, 0.9894, 0.9921, 0.9941, 0.9956, 0.9967, 0.9975, 0.9982, 0.9986, 0.999, 0.9992, 0.9994, 0.9996, 0.9997, 0.9998, 1); + --spd-effects-default: 280ms; + --sp-effects-slow: linear(0, 0.0388, 0.1273, 0.2365, 0.3492, 0.4558, 0.5517, 0.635, 0.7056, 0.7644, 0.8128, 0.8521, 0.8837, 0.9089, 0.929, 0.9448, 0.9572, 0.9669, 0.9745, 0.9804, 0.9849, 0.9885, 0.9912, 0.9933, 0.9949, 0.9961, 0.997, 0.9977, 0.9983, 0.9987, 0.999, 0.9993, 0.9994, 0.9996, 1); + --spd-effects-slow: 371ms; + + --m-fast: var(--spd-effects-fast) var(--sp-effects-fast); + --m-base: var(--spd-spatial-fast) var(--sp-spatial-fast); + --m-sheet: var(--spd-spatial-default) var(--sp-spatial-default); + --m-spring: var(--spd-spatial-fast) var(--sp-spatial-fast); + --art-grow: 1.035; + + --ambience: 0.55; /* how much of the artwork the page ground admits */ + --grain: 0.05; + + color-scheme: dark; +} + +/* The registered property only makes --accent animatable; this is the + crossfade. Slow on purpose — it is the app changing colour, and it should + read as a change rather than a flicker. */ +html { + transition: + --accent var(--spd-effects-slow) linear, + --accent-hover var(--spd-effects-slow) linear; +} +/* Appearance → Accent from artwork OFF: the brand blue everywhere */ +html[data-accent="brand"] { --accent: #1f9ad6 !important; --accent-hover: #41b7ee !important; } +/* Appearance → Ambience OFF: the honest floor, the desktop's measured greys */ +html[data-material="solid"] { + --ambience: 0; + --surface: var(--bg-elevated); + --hover: var(--bg-highlight); + --press: var(--bg-press); + --hairline: rgba(255, 255, 255, 0.07); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg-base); + color: var(--text-primary); + font-family: "Nunito", -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 15px; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + overscroll-behavior: none; + user-select: none; + -webkit-user-select: none; +} +#mobile { height: 100%; } + +/* :where() so the reset carries zero specificity and never beats a + component rule (the mockup lost every accent-filled control to this once) */ +:where(button) { + font: inherit; + color: inherit; + background: none; + border: 0; + padding: 0; + margin: 0; + cursor: default; + touch-action: manipulation; +} +:where(input) { font: inherit; color: inherit; } +img { display: block; } diff --git a/src/mobile/nav.svelte.js b/src/mobile/nav.svelte.js new file mode 100644 index 0000000..d83b5fe --- /dev/null +++ b/src/mobile/nav.svelte.js @@ -0,0 +1,171 @@ +/* Navigation: a stack per tab, the way Android does it, plus the two + sheets. The state here is the truth; the browser history is a MIRROR of + it, kept one entry per back-able layer, so the system back gesture — which + reaches a Tauri WebView as WebView.goBack() — peels exactly one layer: + the player sheet, then a half sheet, then the pushed screen, then the + tab (back to Home). With nothing left to peel there is no history entry + left either, and the activity goes to the background as Android expects. */ + +export const TABS = [ + ["home", "home", "Home"], + ["search", "search", "Search"], + ["library", "library", "Library"], + ["community", "users", "Community"], + ["you", "settings", "You"], +]; + +const root = (n) => ({ n, c: {}, scroll: 0 }); + +export const nav = $state({ + tab: "home", + stacks: { + home: [root("home")], + search: [root("search")], + library: [root("library")], + community: [root("community")], + you: [root("you")], + }, + /* the transition the next render plays, and a counter that forces it */ + tr: "tab", + seq: 0, + /* the player sheet; `morph` = it opened from the mini bar's artwork */ + sheet: false, + morph: false, + face: "art", + /* the half sheet: { kind, arg } */ + half: null, +}); + +/** Where the container transform starts — the mini bar's art, measured the + * moment it was tapped (the bar starts sliding away at once, so a later + * measurement would be short). Plain, not reactive: a rect is not state. */ +export const morphSource = { rect: null }; + +let depth = 0; // history entries this module pushed +let suppress = 0; // popstates to ignore (our own history.go) +let booted = false; + +export function top() { + const s = nav.stacks[nav.tab]; + return s[s.length - 1]; +} +export function isRoot() { + return nav.stacks[nav.tab].length === 1; +} + +function backables() { + return (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0) + + (nav.stacks[nav.tab].length - 1) + (nav.tab !== "home" ? 1 : 0); +} + +/* make the history depth equal the number of layers the back gesture can peel */ +function sync() { + if (!booted) return; + const want = backables(); + while (depth < want) { + depth++; + try { history.pushState({ d: depth }, ""); } catch { /* file:// previews */ } + } + if (want < depth) { + const k = depth - want; + depth = want; + suppress++; + try { history.go(-k); } catch { suppress--; } + } +} + +export function bootNav() { + booted = true; + try { history.replaceState({ d: 0 }, ""); } catch { /* ignore */ } + window.addEventListener("popstate", () => { + if (suppress > 0) { suppress--; return; } + depth = Math.max(0, depth - 1); + if (nav.sheet) nav.sheet = false; + else if (nav.half) nav.half = null; + else if (!popNow() && nav.tab !== "home") switchNow("home"); + sync(); + }); +} + +function switchNow(tab) { + nav.half = null; + nav.tab = tab; + nav.tr = "tab"; + nav.seq++; +} +function popNow() { + const s = nav.stacks[nav.tab]; + if (s.length <= 1) return false; + s.pop(); + nav.tr = "pop"; + nav.seq++; + return true; +} + +/** A tab. Tapping the tab you are on pops it to its root. */ +export function go(tab) { + if (tab === nav.tab) { + if (nav.stacks[tab].length > 1) { + nav.stacks[tab] = [nav.stacks[tab][0]]; + nav.tr = "pop"; + nav.seq++; + } + nav.half = null; + sync(); + return; + } + switchNow(tab); + sync(); +} + +/** Push a screen onto the current tab. `screenEl` is the scroller of the + * screen being left, so a pop can put it back where it was. */ +export function push(n, c = {}, screenEl = null) { + nav.half = null; + const t = top(); + if (screenEl) t.scroll = screenEl.scrollTop; + nav.stacks[nav.tab].push({ n, c, scroll: 0 }); + nav.tr = "push"; + nav.seq++; + sync(); +} + +/** The in-app back chevron. Same order as the system gesture. */ +export function back() { + if (nav.sheet) nav.sheet = false; + else if (nav.half) nav.half = null; + else if (!popNow() && nav.tab !== "home") switchNow("home"); + sync(); +} + +/** Same screen, different arguments — a swap, not a journey. */ +export function patch(c) { + Object.assign(top().c, c); +} + +export function openSheet(fromEl = null) { + if (nav.sheet) return; + let rect = null; + if (fromEl && typeof fromEl.getBoundingClientRect === "function") { + const r = fromEl.getBoundingClientRect(); + if (r.width) rect = { left: r.left, top: r.top, width: r.width, height: r.height }; + } + morphSource.rect = rect; + nav.morph = !!rect; + nav.sheet = true; + sync(); +} +export function closeSheet() { + if (!nav.sheet) return; + nav.sheet = false; + sync(); +} +export function openHalf(kind, arg = null) { + nav.half = { kind, arg }; + sync(); +} +export function closeHalf() { + if (!nav.half) return; + nav.half = null; + sync(); +} diff --git a/src/mobile/net.svelte.js b/src/mobile/net.svelte.js new file mode 100644 index 0000000..2c14a87 --- /dev/null +++ b/src/mobile/net.svelte.js @@ -0,0 +1,148 @@ +/* The network half of Search: one Soulseek search at a time, its answers + grouped by PEER AND FOLDER — on this network the choice is whose copy to + take, not which file — and ranked the way the desktop's source rules + rank them: lossless first, then a free slot, then speed. The file lists + stay out of the reactive state until a batch is folded in; the screen + sees a sorted snapshot every quarter second. */ +import * as api from "../lib/api.js"; +import { listen } from "../lib/events.js"; + +export const AUDIO = new Set(["flac", "mp3", "mp2", "m4a", "m4b", "aac", "ogg", "oga", "wav", "aiff", "aif", "opus", "wma", "ape", "wv", "mpc", "tta", "dsf", "dff", "alac"]); +export const LOSSLESS = new Set(["flac", "wav", "aiff", "aif", "ape", "wv", "alac", "dsf", "dff", "tta"]); + +export const net = $state({ + query: "", + token: null, + searching: false, + responses: 0, + files: 0, + /** [{ key, username, folder, folderName, slot, speed, queue, files, size, lossless, topExt, maxBitrate, maxDepth }] */ + groups: [], + error: "", +}); + +const groups = new Map(); // key -> group (plain; published as a snapshot) +let publishTimer = null; +let stopTimer = null; +let bound = false; + +const GROUPS_MAX = 150; +const FILES_PER_GROUP_MAX = 200; +const SEARCH_MS = 15_000; + +function fold(batch) { + let changed = false; + for (const r of batch || []) { + if (r.token !== net.token) continue; + net.responses += 1; + for (const f of r.files || []) { + if (!AUDIO.has(f.extension)) continue; + const key = r.username + "|" + f.folder; + let g = groups.get(key); + if (!g) { + if (groups.size >= GROUPS_MAX) continue; + g = { + key, + username: r.username, + folder: f.folder, + folderName: f.folder.split("\\").filter(Boolean).pop() || r.username, + slot: !!r.has_free_upload_slot, + speed: r.upload_speed || 0, + queue: r.queue_length || 0, + files: [], + exts: new Map(), + size: 0, + lossless: 0, + topExt: "", + maxBitrate: 0, + maxDepth: 0, + }; + groups.set(key, g); + } + if (g.files.length >= FILES_PER_GROUP_MAX) continue; + g.files.push(f); + g.size += f.size || 0; + if (LOSSLESS.has(f.extension)) g.lossless += 1; + if ((f.bitrate || 0) > g.maxBitrate) g.maxBitrate = f.bitrate; + if ((f.bit_depth || 0) > g.maxDepth) g.maxDepth = f.bit_depth; + const n = (g.exts.get(f.extension) || 0) + 1; + g.exts.set(f.extension, n); + if (n >= (g.exts.get(g.topExt) || 0)) g.topExt = f.extension; + net.files += 1; + changed = true; + } + } + if (changed) schedulePublish(); +} + +function schedulePublish() { + if (publishTimer) return; + publishTimer = setTimeout(publish, 250); +} + +/** The screen's copy: sorted, files in name order (which is track order). */ +function publish() { + publishTimer = null; + const list = [...groups.values()].map((g) => ({ + ...g, + exts: undefined, + losslessShare: g.files.length ? g.lossless / g.files.length : 0, + files: g.files.slice().sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })), + })); + list.sort((a, b) => + b.losslessShare - a.losslessShare + || (b.slot ? 1 : 0) - (a.slot ? 1 : 0) + || b.speed - a.speed + || b.files.length - a.files.length); + net.groups = list; +} + +export async function bindNet() { + if (bound) return; + bound = true; + try { await listen("soulseek:search-results", (ev) => fold(ev.payload)); } catch { /* not a phone */ } +} + +/** Ask the network. One live search at a time; the last one is stopped. */ +export async function search(query) { + const q = String(query || "").trim(); + if (!q) return; + await stop(); + net.query = q; + net.responses = 0; + net.files = 0; + net.error = ""; + groups.clear(); + net.groups = []; + net.searching = true; + try { + net.token = await api.startSearch(q); + } catch (e) { + net.searching = false; + net.error = String(e); + return; + } + clearTimeout(stopTimer); + stopTimer = setTimeout(() => stop(true), SEARCH_MS); +} + +/** Stop listening; keep what came in unless told otherwise. */ +export async function stop(keep = false) { + clearTimeout(stopTimer); + const t = net.token; + net.token = null; + net.searching = false; + if (t != null) api.stopSearch(t).catch(() => {}); + if (!keep) { groups.clear(); net.groups = []; } +} + +export function speedLabel(kbps) { + if (!kbps) return "speed unknown"; + return kbps >= 1000 ? (kbps / 1000).toFixed(1) + " MB/s" : Math.round(kbps) + " kB/s"; +} +export function groupFormat(g) { + const ext = (g.topExt || "").toUpperCase(); + if (!ext) return ""; + if (LOSSLESS.has(g.topExt)) return ext + (g.maxDepth ? " " + g.maxDepth + "-bit" : ""); + return ext + (g.maxBitrate ? " " + g.maxBitrate : ""); +} diff --git a/src/mobile/player.svelte.js b/src/mobile/player.svelte.js new file mode 100644 index 0000000..593032f --- /dev/null +++ b/src/mobile/player.svelte.js @@ -0,0 +1,695 @@ +/* The player, on the phone: one queue, the audio thread behind the same + IPC the desktop uses, and the lane's story. + + THE QUEUE IS A LIST. Entries are { t: Track, album: Album } — the album + is carried because the mini bar, the ground and the accent all read the + artwork off it, and a queue can leave the record it started from. + + GAPLESS, the desktop's way: when a track starts, the next one is decoded + now and appended behind it in the same sink (`preload_local`); the engine + then reports the boundary as `track-ended { gapless: true }` and the + phone only moves its index. If the queue changed under an armed preload, + the boundary is re-sourced properly instead — a gap, never a wrong track. + + SHUFFLE is a predetermined order: the rest of the queue is shuffled once, + so the track the sheet promises next is the one the engine has armed. + The unshuffled queue is kept so shuffle off restores the order. + + THE SESSION survives a relaunch (localStorage): the queue, the position, + the modes. A reload finds the audio thread still playing and follows it; + a restart restores the track paused with `needsResource`, and play + re-sources it from where it was — the desktop's rule, kept. + + THE LANE'S STORY (src/lib/waveshape.js's constants): on a track change the + outgoing shape is HELD for ARRIVE_HOLD_MS; a decode that lands inside the + hold morphs shape → shape (ARRIVING, one motion over ARRIVE_MS); one that + takes longer drops to the LOADING swell first. */ +import * as api from "../lib/api.js"; +import { listen } from "../lib/events.js"; +import { ARRIVE_HOLD_MS, ARRIVE_MS } from "../lib/waveshape.js"; +import { loadArt, art } from "./art.svelte.js"; +import { lib, tracksOf } from "./lib.svelte.js"; +import { shuffled } from "./util.js"; + +export const player = $state({ + status: "idle", // idle | buffering | playing | paused + position: 0, + duration: null, + queue: [], + qi: -1, + from: "", // what the queue was built from, for the sheet's kicker + track: null, + album: null, + peaks: [], + heldPeaks: [], + phase: "ready", // hold | loading | rising | arriving | ready + /** a stream: how much of the track the peaks describe */ + coverage: 1, + /** the transfer behind a streamed track */ + stream: { received: 0, total: 0, done: false, failed: null }, + /** how the last boundary was crossed: "gapless" (the engine had the next + * appended) or "sourced" (the next was started from scratch) */ + advance: "", + shuffle: false, + repeat: 0, // 0 off · 1 all · 2 one + /* restored from the last session with nothing loaded behind it: play + has to re-source the track rather than resume */ + needsResource: false, + /* the sleep timer: when it pauses, as a timestamp; 0 = off */ + sleepAt: 0, + error: "", +}); + +/** Things the shell plugs in: `recentsDirty` runs when a play has been + * written and the recents rail should re-read; `mediaSync` mirrors the + * bar to the phone's media session (media.svelte.js). */ +export const hooks = { recentsDirty: null, mediaSync: null, resolveEntry: null }; +function mediaSync() { + if (typeof hooks.mediaSync === "function") hooks.mediaSync(); +} + +let gen = 0; +let holdTimer = null; +let arriveTimer = null; +let original = null; // the unshuffled queue while shuffle is on +let armedPath = null; // the file the engine has appended for the next boundary + +export function current() { + return player.queue[player.qi] ?? null; +} + +function setErr(e) { + player.error = String(e); +} + +/* ── the lane's story ────────────────────────────────────────────────── */ +function beginStory() { + player.heldPeaks = player.peaks; + player.peaks = []; + player.phase = "hold"; + clearTimeout(holdTimer); + clearTimeout(arriveTimer); + const g = gen; + holdTimer = setTimeout(() => { + if (g === gen && player.phase === "hold") player.phase = "loading"; + }, ARRIVE_HOLD_MS); +} +function landPeaks(g, peaks) { + if (g !== gen) return; + clearTimeout(holdTimer); + player.peaks = peaks; + player.phase = "arriving"; + arriveTimer = setTimeout(() => { if (g === gen) player.phase = "ready"; }, ARRIVE_MS + 80); +} +async function loadWave(g, path) { + try { + const w = await api.waveform(path, 900); + landPeaks(g, Array.isArray(w) ? w : []); + } catch { + if (g === gen) player.phase = "ready"; + } +} + +/* ── starting a track ────────────────────────────────────────────────── */ +async function startEntry(e, startSecs = null) { + if (!e) return; + const g = ++gen; + clearTimeout(watchdog); + ++preloadGen; + clearTimeout(preloadPoll); + listenFlush(); + armedPath = null; + player.track = e.t; + player.album = e.album; + player.position = startSecs || 0; + player.duration = e.t.duration_secs ?? null; + player.status = "buffering"; + player.needsResource = false; + player.error = ""; + beginStory(); + player.coverage = 1; + player.stream = { received: 0, total: 0, done: false, failed: null }; + void loadArt(e.album?.art_path, true); + writeSession(); + mediaSync(); + if (e.t.catalog && !e.t.path) { + /* a record's later track: it finds its own copy now (resolve.svelte.js) */ + let ok = false; + try { ok = await hooks.resolveEntry?.(e); } catch { ok = false; } + if (g !== gen) return; + if (!ok) { + setErr("No source for “" + e.t.title + "”"); + ended(false); + return; + } + player.duration = e.t.duration_secs ?? null; + writeSession(); + mediaSync(); + } + if (e.t.stream) { + /* a peer's copy: the engine plays it as it arrives; the lane tells + the story from the transfer's own bytes (streamStory) */ + try { + await api.playStream(e.t.stream.username, e.t.path, e.t.stream.size, e.t.duration_secs ?? null); + } catch (err) { + if (g === gen) { setErr(err); player.status = "idle"; player.phase = "ready"; } + return; + } + if (g !== gen) return; + streamStory(g, e); + armWatchdog(g, e); + armPreload(); + return; + } + try { + await api.playLocal(e.t.path, e.t.duration_secs ?? null, startSecs); + } catch (err) { + if (g === gen) { setErr(err); player.status = "idle"; player.phase = "ready"; } + return; + } + if (g !== gen) return; + armPreload(); + loadWave(g, e.t.path); +} + +/* ── a stream's lane ───────────────────────────────────────────────────── + The partial waveform is asked for every few seconds while the bytes + arrive: the backend folds what it has and says how much of the track that + covers, and the lane RISES to that frontier. The last request, once the + transfer is done, is the whole shape — the arrival. The cover comes out + of the file's own tags once enough of it is here. */ +/* A peer that has not sent a byte in time is not about to: the entry's + runner-ups (`t.alts`, the resolver's next candidates) take over one by + one — 12s for the first peer (an indirect handshake honestly takes ~10), + 8s for the rest — and when they are spent the queue moves on. */ +let watchdog = null; +function armWatchdog(g, e) { + clearTimeout(watchdog); + const first = !e.t.tried; + watchdog = setTimeout(() => { + if (g !== gen) return; + if (player.status === "playing" || player.stream.received > 0) return; + const n = e.t.alts?.shift(); + if (n) { + e.t.tried = (e.t.tried || 0) + 1; + e.t.path = n.path; + e.t.stream = { username: n.username, size: n.size }; + if (n.duration) e.t.duration_secs = n.duration; + startEntry(e); + return; + } + setErr("Sources for “" + e.t.title + "” aren't responding"); + ended(false); + }, first ? 12000 : 8000); +} + +let streamTimer = null; +function streamStory(g, e) { + clearTimeout(streamTimer); + const { username, size } = e.t.stream; + const path = e.t.path; + let artTried = false; + const tick = async () => { + if (g !== gen) return; + const done = player.stream.done; + try { + const slice = await api.streamWaveform(username, path, size, 900); + if (g !== gen) return; + const peaks = slice?.peaks || []; + const coverage = Math.max(0, Math.min(1, slice?.coverage ?? 0)); + if (peaks.length) { + clearTimeout(holdTimer); + player.peaks = peaks; + player.coverage = coverage; + if (done || coverage >= 0.999) { + player.phase = "arriving"; + clearTimeout(arriveTimer); + arriveTimer = setTimeout(() => { if (g === gen) player.phase = "ready"; }, ARRIVE_MS + 80); + } else if (player.phase !== "rising") { + player.phase = "rising"; + } + } + } catch { /* nothing to fold yet */ } + if (!artTried && (player.stream.received > 200_000 || done)) { + artTried = true; + const key = e.album?.art_path; + if (key && art[key] === undefined) { + api.streamEmbeddedArt(username, path).then((u) => { art[key] = u || ""; }, () => { art[key] = ""; }); + } + } + if (g === gen && !done) streamTimer = setTimeout(tick, 2500); + }; + streamTimer = setTimeout(tick, 900); +} + +/** The index the engine will play next, in this order, with these modes. */ +function nextIndex() { + if (!player.queue.length) return -1; + if (player.repeat === 2) return player.qi; + if (player.qi + 1 < player.queue.length) return player.qi + 1; + return player.repeat === 1 ? 0 : -1; +} + +/** Ready the next track now, whatever it is — the desktop's album fill and + * stream preload, folded into one place: + * - a library file: decoded and appended behind the current one; + * - a peer's copy: fetched into the cache ahead of time, and appended the + * moment it is byte-complete, so the boundary is gapless; + * - a record's later track that has no copy yet: found first, QUIETLY (the + * resolver's line never shows for a background search), then the above. + * Streams wait ALBUM_FILL_DELAY_MS after a start, so the search and the + * fetch never compete with the track that is starting. */ +const ALBUM_FILL_DELAY_MS = 4000; +/** THREE ahead — the desktop's STREAM_PRELOAD_DEPTH. One track of headroom + * is not enough when a peer is slow or a search misses. */ +const PRELOAD_DEPTH = 3; +let preloadGen = 0; +let preloadPoll = null; +/** The next `count` queue indices in play order: none past the end unless + * repeat-all wraps them; repeat-one has no next. The queue IS the play + * order on the phone (shuffle reorders it). */ +function peekNextIndices(count = PRELOAD_DEPTH) { + const n = player.queue.length; + if (!n || player.repeat === 2) return []; + const out = []; + let i = player.qi; + for (let k = 0; k < count; k++) { + i += 1; + if (i >= n) { if (player.repeat !== 1) break; i = 0; } + if (i === player.qi) break; + out.push(i); + } + return out; +} +function armPreload() { + ++preloadGen; + clearTimeout(preloadPoll); + armedPath = null; + const ahead = player.status === "idle" ? [] : peekNextIndices(); + if (!ahead.length) return; + const first = player.queue[ahead[0]]; + if (first?.t?.path && !first.t.stream && !first.t.catalog) { + /* a library file: decoded and appended now; the rest are files too or + get their fetch below */ + const path = first.t.path; + armedPath = path; + api.preloadLocal(path, first.t.duration_secs ?? null).catch(() => { + if (armedPath === path) armedPath = null; + }); + } + if (ahead.some((i) => player.queue[i]?.t?.stream || player.queue[i]?.t?.catalog)) armStreamPreload(ahead); +} +/** Ready the peers' copies among the next three: found QUIETLY where a + * record's later track has none yet (a background search never shows the + * resolver's line), fetched into the cache ahead of time — one after the + * other, so the search pacing holds — and the FIRST appended the moment it + * is byte-complete, so the boundary is gapless. Waits after a start so + * nothing competes with the track that is starting. */ +function armStreamPreload(indices) { + const g = preloadGen; + preloadPoll = setTimeout(async () => { + for (const i of indices) { + if (g !== preloadGen) return; + const e = player.queue[i]; + if (!e?.t) continue; + if (e.t.catalog && !e.t.path) { + let ok = false; + try { ok = await hooks.resolveEntry?.(e, true); } catch { ok = false; } + if (g !== preloadGen) return; + if (!ok) continue; + } + if (!e.t.stream) continue; // the library's, or nothing to fetch + try { await api.preloadStream(e.t.stream.username, e.t.path, e.t.stream.size); } catch { /* next one */ } + } + /* the immediate next, once it is whole */ + const e = player.queue[indices[0]]; + if (!e?.t?.stream || g !== preloadGen) return; + const { username, size } = e.t.stream; + const path = e.t.path; + const poll = async () => { + if (g !== preloadGen) return; + let ready = false; + try { ready = await api.streamCacheReady(username, path, size); } catch { ready = false; } + if (g !== preloadGen) return; + if (!ready) { preloadPoll = setTimeout(poll, 3000); return; } + try { + const ok = await api.preloadCachedStream(username, path, size, e.t.duration_secs ?? null); + if (ok !== false && g === preloadGen) armedPath = path; + } catch { /* a gap at the boundary, never the wrong track */ } + }; + poll(); + }, ALBUM_FILL_DELAY_MS); +} + +/** The engine crossed into the appended track on its own. */ +function advanceGaplessly(i) { + const e = player.queue[i]; + const g = ++gen; + player.advance = "gapless"; + listenFlush(); + player.qi = i; + player.track = e.t; + player.album = e.album; + player.position = 0; + player.duration = e.t.duration_secs ?? null; + player.status = "playing"; + player.needsResource = false; + beginStory(); + void loadArt(e.album?.art_path, true); + armedPath = null; + armPreload(); + if (e.t.stream) { + /* the appended copy was byte-complete: the lane gets the whole shape */ + player.coverage = 1; + player.stream = { received: e.t.stream.size, total: e.t.stream.size, done: true, failed: null }; + streamStory(g, e); + } else { + loadWave(g, e.t.path); + } + writeSession(); + mediaSync(); +} + +/* ── the queue ───────────────────────────────────────────────────────── */ +export function playEntries(entries, at = 0, from = "") { + original = null; + if (player.shuffle && entries.length > 1) { + /* a predetermined order: the chosen track first, the rest shuffled */ + original = entries; + const first = entries[at]; + entries = [first, ...shuffled(entries.filter((_, i) => i !== at))]; + at = 0; + } + player.queue = entries; + player.qi = at; + player.from = from; + return startEntry(entries[at]); +} +export function playAlbum(album, tracks, at = 0) { + return playEntries(tracks.map((t) => ({ t, album })), at, album.title); +} +export function playAlbumShuffled(album, tracks) { + if (!tracks.length) return; + player.shuffle = true; + return playEntries(tracks.map((t) => ({ t, album })), Math.floor(Math.random() * tracks.length), album.title); +} +export function playQueueAt(i) { + if (i < 0 || i >= player.queue.length) return; + player.qi = i; + return startEntry(player.queue[i]); +} +/** After the current track. */ +export function playNext(entries) { + if (player.qi < 0) return playEntries(entries, 0, player.from); + player.queue.splice(player.qi + 1, 0, ...entries); + if (original) { + const cur = player.queue[player.qi]; + const k = original.findIndex((x) => x.t.path === cur?.t?.path); + original.splice(k >= 0 ? k + 1 : original.length, 0, ...entries); + } + armPreload(); + writeSession(); +} +/** At the end of the queue. */ +export function enqueue(entries) { + if (player.qi < 0) return playEntries(entries, 0, player.from); + player.queue.push(...entries); + if (original) original.push(...entries); + if (player.qi + 1 === player.queue.length - entries.length) armPreload(); + writeSession(); +} + +/* ── a track from the index (Search, the Tracks tab) ─────────────────── */ +/** The library record a TrackIndexRow belongs to, when the library has it. */ +export function albumOfRow(row) { + const k = String(row.artist || "").toLowerCase(); + return lib.albums.find((a) => a.title === row.album && a.artist.toLowerCase() === k) + || (row.art_path ? lib.albums.find((a) => a.art_path === row.art_path) : null) + || null; +} +export function entryFromRow(row, album = albumOfRow(row)) { + return { + t: { id: row.path, title: row.title, artist: row.artist, path: row.path, duration_secs: row.duration_secs ?? null }, + album: album || { id: null, title: row.album, artist: row.artist, art_path: row.art_path ?? null }, + }; +} +/** The track, then the rest of its record. */ +export async function playIndexRow(row) { + try { + const album = albumOfRow(row); + if (album) { + const tr = await tracksOf(album.id); + const i = tr.findIndex((t) => t.path === row.path); + if (i >= 0) return await playAlbum(album, tr, i); + } + await playEntries([entryFromRow(row, album)], 0, row.album); + } catch (e) { setErr(e); } +} + +/* ── the sleep timer ─────────────────────────────────────────────────── */ +let sleepTimer = null; +export function setSleep(minutes) { + clearTimeout(sleepTimer); + sleepTimer = null; + player.sleepAt = minutes > 0 ? Date.now() + minutes * 60_000 : 0; + if (minutes > 0) { + sleepTimer = setTimeout(() => { + player.sleepAt = 0; + sleepTimer = null; + if (player.status === "playing" || player.status === "buffering") api.playerControl("pause").catch(() => {}); + }, minutes * 60_000); + } +} +export function sleepLabel() { + if (!player.sleepAt) return "off"; + const m = Math.max(1, Math.round((player.sleepAt - Date.now()) / 60_000)); + return m + " min left"; +} + +/* ── the transport ───────────────────────────────────────────────────── */ +export function next() { + if (!player.queue.length) return; + if (player.repeat === 2) { seekSecs(0); return; } + const i = player.qi + 1 < player.queue.length ? player.qi + 1 : (player.repeat === 1 ? 0 : -1); + if (i < 0) { + /* the end of the queue: stop, keep the record on the bar */ + api.playerControl("stop").catch(() => {}); + player.status = "idle"; + armedPath = null; + mediaSync(); + return; + } + playQueueAt(i); +} +export function prev() { + /* the universal transport rule: restart first, go back on a second press */ + if (player.position > 5 || player.qi <= 0) { seekSecs(0); return; } + playQueueAt(player.qi - 1); +} +export function toggle() { + if (player.status === "playing" || player.status === "buffering") { + api.playerControl("pause").catch(setErr); + } else if (player.status === "paused" && !player.needsResource) { + api.playerControl("resume").catch(setErr); + } else if (current()) { + /* nothing loaded behind the bar — a restored session, or a queue + that ran out: source it again, from where it was if it was somewhere */ + const at = player.needsResource && player.position > 1 && player.duration + && player.position < player.duration - 1 ? player.position : null; + startEntry(current(), at); + } +} +export function seekSecs(s) { + if (player.duration == null) return; + s = Math.max(0, Math.min(player.duration, s)); + player.position = s; + if (player.needsResource) { startEntry(current(), s > 1 ? s : null); return; } + api.playerControl("seek", s).catch(setErr); + mediaSync(); +} +export function seekFrac(f) { + if (player.duration) seekSecs(f * player.duration); +} +export function cycleRepeat() { + player.repeat = (player.repeat + 1) % 3; + armPreload(); + writeSession(); +} +export function toggleShuffle() { + if (!player.shuffle) { + original = player.queue.slice(); + const rest = shuffled(player.queue.slice(player.qi + 1)); + player.queue = player.queue.slice(0, player.qi + 1).concat(rest); + player.shuffle = true; + } else { + if (original) { + const cur = player.queue[player.qi]; + const k = original.findIndex((x) => x.t.path === cur?.t?.path); + if (k >= 0) { player.queue = original; player.qi = k; } + original = null; + } + player.shuffle = false; + } + armPreload(); + writeSession(); +} + +function ended(gapless) { + listenFlush(); + const i = nextIndex(); + const e = i >= 0 ? player.queue[i] : null; + if (gapless && e && armedPath && e.t.path === armedPath) { advanceGaplessly(i); return; } + if (!e) { + player.status = "idle"; + if (player.duration != null) player.position = player.duration; + armedPath = null; + writeSession(); + mediaSync(); + return; + } + /* not armed, or armed with a track the queue no longer has next: source + the right one (a gap at worst, never the wrong track) */ + player.advance = "sourced"; + playQueueAt(i); +} + +/** Subscribe to the audio thread. Returns the unlisten. */ +export async function bindEvents() { + document.addEventListener("visibilitychange", () => { if (document.hidden) writeSession(); }); + window.addEventListener("pagehide", writeSession); + setInterval(() => { if (player.status === "playing") writeSession(); }, 10_000); + try { + await listen("soulseek:stream", (ev) => { + const p = ev.payload || {}; + if (!current()?.t?.stream) return; + player.stream = { received: p.received || 0, total: p.total || 0, done: !!p.done, failed: p.failed || null }; + if (p.failed) setErr("The stream from " + current().t.stream.username + " failed: " + p.failed); + }); + } catch { /* not a phone */ } + return await listen("soulseek:player", (ev) => { + const p = ev.payload || {}; + switch (p.kind) { + case "playing": player.status = "playing"; player.needsResource = false; mediaSync(); break; + case "paused": player.status = "paused"; mediaSync(); break; + case "buffering": player.status = "buffering"; break; + case "stopped": player.status = "idle"; mediaSync(); break; + case "position": + if (typeof p.secs === "number") { + player.position = p.secs; + listenTick(p.secs); + } + if (p.duration != null) player.duration = p.duration; + break; + case "track-ended": ended(!!p.gapless); break; + case "error": + player.error = p.message || "playback error"; + player.status = "idle"; + break; + } + }); +} + +/* ── the session ─────────────────────────────────────────────────────── */ +const SESSION_KEY = "ss.mobile.session.v1"; + +function packEntry(e) { + const a = e.album; + return { + t: e.t, + album: a ? { id: a.id ?? null, title: a.title, artist: a.artist, year: a.year ?? null, art_path: a.art_path ?? null, artist_art_path: a.artist_art_path ?? null } : null, + }; +} +export function writeSession() { + try { + if (!player.track) { localStorage.removeItem(SESSION_KEY); return; } + localStorage.setItem(SESSION_KEY, JSON.stringify({ + v: 1, + queue: player.queue.map(packEntry), + qi: player.qi, + from: player.from, + position: player.position, + duration: player.duration, + shuffle: player.shuffle, + repeat: player.repeat, + original: original ? original.map(packEntry) : null, + })); + } catch { /* quota, or storage off: the session is a convenience */ } +} + +/** Put back what the last launch left behind. The audio thread is the + * authority: a reload finds it playing and follows it; a restart finds it + * idle and the track comes back paused, to be re-sourced by play. */ +export async function restoreSession() { + let saved = null; + try { saved = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); } catch { saved = null; } + if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false; + let transport = null; + try { transport = await api.playerState(); } catch { transport = null; } + const g = ++gen; + player.queue = saved.queue; + player.qi = saved.qi; + player.from = saved.from || ""; + player.shuffle = !!saved.shuffle; + player.repeat = saved.repeat || 0; + original = Array.isArray(saved.original) ? saved.original : null; + const e = player.queue[player.qi]; + player.track = e.t; + player.album = e.album; + const live = !!transport && transport.status !== "idle"; + player.status = live ? transport.status : "paused"; + player.position = live ? (transport.position ?? 0) : (saved.position ?? 0); + player.duration = live && transport.duration != null ? transport.duration : (saved.duration ?? e.t.duration_secs ?? null); + player.needsResource = !live; + player.phase = "ready"; + void loadArt(e.album?.art_path, true); + loadWave(g, e.t.path); + if (live) armPreload(); + mediaSync(); + return true; +} + +/* ── play history ──────────────────────────────────────────────────────── + The desktop's rule (state.svelte.js): listened time is accumulated from + the position ticks, a row opens once a track has really accumulated a + second, top-ups land every 15s and at the track change, seeks are ignored + by the delta guard. The phone writes to the same table, so Jump back in + is one history across both. */ +const LISTEN_FLUSH_SECS = 15; +const RECENTS_FLOOR_SECS = 45; +let session = null; +let lastPos = null; + +function listenFlush(s = session) { + if (!s?.playIdP) return; + const secs = Math.floor(s.pending); + if (secs < 1) return; + s.pending -= secs; + s.playIdP.then((id) => api.addPlaySeconds(id, secs)).catch(() => {}); +} +function scheduleRecents(ms) { + setTimeout(() => { if (typeof hooks.recentsDirty === "function") hooks.recentsDirty(); }, ms); +} +function listenTick(pos) { + const t = player.track; + if (!t) return; + const title = t.title; + const artist = (t.artist || player.album?.artist || "").trim(); + if (!title || !artist) return; + const key = artist + "" + title; + if (session?.key !== key) { + listenFlush(); + scheduleRecents(1500); + session = { key, pending: 0, playIdP: null }; + lastPos = null; + } + const d = lastPos == null ? 0 : pos - lastPos; + lastPos = pos; + if (d <= 0 || d > 3) return; + session.pending += d; + if (!session.playIdP && session.pending >= 1) { + session.playIdP = api.recordPlay(artist, title, t.stream ? null : (t.path ?? null)); + session.playIdP.catch(() => {}); + session.playIdP.then(() => scheduleRecents(RECENTS_FLOOR_SECS * 1000 + 2000)).catch(() => {}); + } + if (session.pending >= LISTEN_FLUSH_SECS) listenFlush(); +} diff --git a/src/mobile/prefs.svelte.js b/src/mobile/prefs.svelte.js new file mode 100644 index 0000000..2910e8e --- /dev/null +++ b/src/mobile/prefs.svelte.js @@ -0,0 +1,31 @@ +/* Appearance, kept on this device: the three switches the mockup's + Appearance page had. Each one is a data attribute on that the + foundations (mobile.css) read — no component has to know. */ +/* `glass` is the desktop's frosted chrome (a backdrop blur behind the bars). + Off by default on the phone: tests/mobile/perf.mjs measured it at half the + frames on a Home scroll and on the player's opening. */ +export const settings = $state({ ambience: true, accent: true, motion: true, glass: false }); + +const KEY = "ss.mobile.prefs"; + +function apply() { + const d = document.documentElement.dataset; + d.material = settings.ambience ? "ambience" : "solid"; + d.accent = settings.accent ? "art" : "brand"; + d.motion = settings.motion ? "on" : "off"; + d.glass = settings.glass ? "blur" : "solid"; +} + +export function loadPrefs() { + try { + const saved = JSON.parse(localStorage.getItem(KEY) || "{}"); + for (const k of Object.keys(settings)) if (typeof saved[k] === "boolean") settings[k] = saved[k]; + } catch { /* a fresh phone */ } + apply(); +} + +export function setSetting(k, v) { + settings[k] = v; + try { localStorage.setItem(KEY, JSON.stringify({ ...settings })); } catch { /* fine */ } + apply(); +} diff --git a/src/mobile/resolve.svelte.js b/src/mobile/resolve.svelte.js new file mode 100644 index 0000000..4f09546 --- /dev/null +++ b/src/mobile/resolve.svelte.js @@ -0,0 +1,279 @@ +/* The phone's resolver: a catalog track or record → a peer's copy, through + the desktop's own source rules (src/lib/sources.js — one copy, shared). + The order is the desktop's: the library first, then the remembered source + if its bytes are still complete in the cache, then the network — the + query ladder, the candidates, and up to four peers tried in turn. + + A record queues every track from the ONE folder that wins. A song played + off a record queues the rest of the record behind it as UNRESOLVED + entries, each finding its own copy when its turn comes (the player calls + hooks.resolveEntry). Failover lives in the player: an entry carries its + runner-up peers in `t.alts`, and a stream that has not sent a byte in + time moves to the next one. */ +import * as api from "../lib/api.js"; +import { + resolveSources, songCandidates, albumCandidates, simplifyTitle, albumQueryTitle, + cachedStreamSourceFor, rememberStreamSource, catalogTrackFor, cleanTrackName, + hqCover, qualityOf, refreshSourcePrefs, libraryAlbumIn, libraryTrackIn, +} from "../lib/sources.js"; +import { player, hooks, playEntries, playAlbum } from "./player.svelte.js"; +import { lib, tracksOf } from "./lib.svelte.js"; +import { slsk } from "./slsk.svelte.js"; +import { yearOf } from "./catalog.svelte.js"; + +export const resolver = $state({ active: false, label: "", error: "" }); + +void refreshSourcePrefs(); + +const online = () => slsk.connection === "online"; +const OFFLINE = "Sign in on Community to play from the network."; + +/* ── shapes ────────────────────────────────────────────────────────── */ +/** The record a streamed entry belongs to, as the player's `album`. No + * library id: the catalog id rides in `catalogId`, and the cover URL is the + * art key (art.svelte.js routes https through the catalog art proxy). */ +function albumMeta(o, artist) { + return { + id: null, + catalogId: o.albumId ?? null, + title: o.album ?? null, + artist: o.albumArtist ?? artist, + year: o.year ?? null, + art_path: hqCover(o.cover) ?? null, + }; +} +function streamEntry(title, artist, src, o, album) { + return { + t: { + id: o.trackId != null ? "cat:" + o.trackId : "stream:" + src.username + "|" + src.path, + title, + artist, + path: src.path, + duration_secs: src.duration ?? o.expectSecs ?? null, + stream: { username: src.username, size: src.size }, + catalog: { trackId: o.trackId ?? null, albumId: o.albumId ?? null, expectSecs: o.expectSecs ?? null }, + alts: [], + }, + album, + }; +} +/** The rest of a record, unresolved: a path-less entry the player resolves + * on arrival. */ +function lazyEntries(rest, artist, o, album) { + return (rest || []).map((ct) => ({ + t: { + id: "cat:" + ct.id, + title: ct.title, + artist: ct.artist || artist, + path: null, + duration_secs: ct.duration_secs ?? null, + stream: null, + catalog: { trackId: ct.id ?? null, albumId: o.albumId ?? null, expectSecs: ct.duration_secs ?? null }, + alts: [], + }, + album, + })); +} +const alt = (c) => ({ username: c.username, path: c.file.path, size: c.file.size, duration: c.file.duration ?? null }); +function remember(e, quality) { + rememberStreamSource({ stream: e.t.stream, path: e.t.path, title: e.t.title, artist: e.t.artist, trackId: e.t.catalog?.trackId ?? null, duration: e.t.duration_secs, quality }); +} + +/* the bar says what is being looked for while the ladder runs */ +function placeholder(title, artist, album) { + player.track = { id: "resolving", title, artist, path: null, stream: null }; + player.album = album; + player.position = 0; + player.duration = null; + player.status = "buffering"; + player.phase = "loading"; + player.error = ""; +} +function fail(msg) { + resolver.error = msg; + player.error = msg; + if (player.track?.id === "resolving") { player.track = null; player.status = "idle"; player.phase = "ready"; } +} + +/* ── a song ────────────────────────────────────────────────────────── */ +/** Play a catalog track: `o` carries what the row knows — trackId, albumId, + * album (title), cover, expectSecs, year, and `rest` (the record's later + * tracks, queued behind it). */ +export async function streamSong(artist, title, o = {}) { + if (resolver.active) return; + const local = await libraryTrackIn(lib.albums, (id) => tracksOf(id), artist, title); + if (local) { + await playAlbum(local.album, local.tracks, local.index); + return; + } + const album = albumMeta(o, artist); + const from = album.title || artist; + const cached = await cachedStreamSourceFor(o.trackId ?? null, artist, title); + if (cached) { + await playEntries([streamEntry(title, artist, cached, o, album), ...lazyEntries(o.rest, artist, o, album)], 0, from); + return; + } + if (!online()) { fail(OFFLINE); return; } + placeholder(title, artist, album); + const { responses, artistIncluded } = await resolveSources( + `${artist} — ${title}`, + [`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)], + (rs) => songCandidates(rs, title, true, o.album, o.expectSecs, artist).length, + artist, + resolver, + ); + const cands = songCandidates(responses, title, true, o.album, o.expectSecs, artist, !artistIncluded); + if (!cands.length) { fail(`No streamable sources found for “${title}”`); return; } + const [best, ...alts] = cands.slice(0, 4); + const e = streamEntry(title, artist, alt(best), o, album); + e.t.alts = alts.map(alt); + remember(e, qualityOf(best.file)); + await playEntries([e, ...lazyEntries(o.rest, artist, o, album)], 0, from); +} + +/* ── a record ──────────────────────────────────────────────────────── */ +/** Play a catalog record from track `at`: the library's copy if it holds + * one, else the best folder on the network, every track from that one + * peer, the runner-up folders as the first track's failover. */ +export async function streamCatalogAlbum(album, at = 0) { + if (resolver.active) return; + const local = libraryAlbumIn(lib.albums, album.artist, album.title, album.release_date ?? null); + if (local) { + const tracks = await tracksOf(local.id).catch(() => []); + if (tracks.length) { await playAlbum(local, tracks, Math.min(at, tracks.length - 1)); return; } + } + if (!online()) { fail(OFFLINE); return; } + const meta = albumMeta({ albumId: album.id ?? null, album: album.title, cover: album.cover, year: yearOf(album.release_date) }, album.artist); + placeholder(album.title, album.artist, meta); + const catalogTracks = album.id + ? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null) + : null; + const trackCount = catalogTracks?.length || album.track_count || null; + if (trackCount === 1) { + const t = catalogTracks?.[0]; + player.track = null; + await streamSong(album.artist, t?.title ?? album.title, { + cover: t?.cover ?? album.cover, trackId: t?.id ?? null, albumId: album.id ?? null, + album: album.title || null, expectSecs: t?.duration_secs ?? null, year: meta.year, + }); + return; + } + const { responses, artistIncluded } = await resolveSources( + `${album.artist} — ${album.title}`, + [`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)], + (rs) => albumCandidates(rs, album, true, trackCount).length, + album.artist, + resolver, + ); + const folders = albumCandidates(responses, album, true, trackCount, !artistIncluded); + if (!folders.length) { fail(`No streamable sources found for “${album.title}”`); return; } + const c = folders[0]; + const entries = c.audio.map((f, i) => { + const ct = catalogTrackFor(f, i, c.audio, catalogTracks); + const o = { trackId: ct?.id ?? null, albumId: album.id ?? null, expectSecs: ct?.duration_secs ?? null }; + const e = streamEntry(ct?.title ?? cleanTrackName(f.name), album.artist, { username: c.username, path: f.path, size: f.size, duration: f.duration ?? null }, o, meta); + remember(e, qualityOf(f)); + return e; + }); + const start = Math.min(Math.max(0, at), entries.length - 1); + entries[start].t.alts = folders.slice(1, 4).map((g) => { + const f = g.audio[start] || g.audio[0]; + return { username: g.username, path: f.path, size: f.size, duration: f.duration ?? null }; + }); + await playEntries(entries, start, album.title); +} + +/* ── saving ────────────────────────────────────────────────────────── */ +/** Fetch a record into the library: the best folder by the DOWNLOAD rules + * (quality first, a queue is fine), every file in it — the pipeline files + * art and logs beside the finished album and says soulseek:library-changed. + * Returns { username, files } or false. */ +export async function saveCatalogAlbum(album) { + if (resolver.active) return false; + if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; } + const catalogTracks = album.id + ? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null) + : null; + const trackCount = catalogTracks?.length || album.track_count || null; + if (trackCount === 1) { + const t = catalogTracks?.[0]; + return saveSong(album.artist, t?.title ?? album.title); + } + const { responses, artistIncluded } = await resolveSources( + `${album.artist} — ${album.title}`, + [`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)], + (rs) => albumCandidates(rs, album, false, trackCount).length, + album.artist, + resolver, + ); + const best = albumCandidates(responses, album, false, trackCount, !artistIncluded)[0]; + if (!best) { + resolver.error = `No album sources found for “${album.title}”`; + player.error = resolver.error; + return false; + } + for (const f of best.files) await api.downloadFile(best.username, f.path, f.size); + return { username: best.username, files: best.files.length }; +} +/** One song into the library, by the download rules. */ +export async function saveSong(artist, title) { + if (resolver.active) return false; + if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; } + const { responses, artistIncluded } = await resolveSources( + `${artist} — ${title}`, + [`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)], + (rs) => songCandidates(rs, title, false, null, null, artist).length, + artist, + resolver, + ); + const best = songCandidates(responses, title, false, null, null, artist, !artistIncluded)[0]; + if (!best) { + resolver.error = `No sources found for “${title}”`; + player.error = resolver.error; + return false; + } + await api.downloadFile(best.username, best.file.path, best.file.size); + return { username: best.username, files: 1 }; +} + +/* ── an entry's turn ───────────────────────────────────────────────── */ +/** Fill in a path-less catalog entry when the queue reaches it: the + * library, the cache, then a search of its own. Returns whether it can + * play. Mutates the entry (a queue proxy) in place. */ +async function resolveEntry(e, quiet = false) { + const { title, artist, catalog } = e.t; + const local = await libraryTrackIn(lib.albums, (id) => tracksOf(id), artist, title); + if (local) { + const t = local.tracks[local.index]; + e.t.path = t.path; + e.t.duration_secs = t.duration_secs ?? e.t.duration_secs; + e.t.stream = null; + return true; + } + const cached = await cachedStreamSourceFor(catalog?.trackId ?? null, artist, title); + if (cached) { + e.t.path = cached.path; + e.t.stream = { username: cached.username, size: cached.size }; + e.t.duration_secs = cached.duration ?? e.t.duration_secs; + return true; + } + if (!online()) return false; + const { responses, artistIncluded } = await resolveSources( + `${artist} — ${title}`, + [`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)], + (rs) => songCandidates(rs, title, true, e.album?.title, catalog?.expectSecs, artist).length, + artist, + quiet ? null : resolver, + ); + const cands = songCandidates(responses, title, true, e.album?.title, catalog?.expectSecs, artist, !artistIncluded); + if (!cands.length) return false; + const [best, ...alts] = cands.slice(0, 4); + const src = alt(best); + e.t.path = src.path; + e.t.stream = { username: src.username, size: src.size }; + e.t.duration_secs = src.duration ?? e.t.duration_secs; + e.t.alts = alts.map(alt); + remember(e, qualityOf(best.file)); + return true; +} +hooks.resolveEntry = resolveEntry; diff --git a/src/mobile/screens/Album.svelte b/src/mobile/screens/Album.svelte new file mode 100644 index 0000000..1fb4dc4 --- /dev/null +++ b/src/mobile/screens/Album.svelte @@ -0,0 +1,97 @@ + + +{#if album} +
+
+
+
{album.title}
+ +
{album.year ?? "—"} · {tracks.length || album.track_count} tracks{total ? ` · ${longDur(total)}` : ""}
+ {#if fmt} +
+ {fmt} + in your library +
+ {/if} +
+ + + +
+
+
+ + {#each discs as [d, list] (d)} + {#if discs.length > 1}
Disc {d}
{/if} +
+ {#each list as t, i (t.id)} + + {/each} +
+ {/each} + + {#if more.length} +
+

More by {album.artist}

+ +
+
+ {#each more as x (x.id)} + push("album", { id: x.id }, screenEl)} /> + {/each} +
+ {/if} +{:else} +
That record is gone.It is no longer in the library.
+{/if} + +
diff --git a/src/mobile/screens/Artist.svelte b/src/mobile/screens/Artist.svelte new file mode 100644 index 0000000..f8dd574 --- /dev/null +++ b/src/mobile/screens/Artist.svelte @@ -0,0 +1,334 @@ + + +
+
+ {#if heroSrc} +
+ {/if} +
+
+ {#if logoUrl} + + {:else} +
{shownName}
+ {/if} +
+
+ +
+ +
+ + {#if releasedCount}{releasedCount} releases{/if} + {#if page?.artist?.fans || seed?.fans}{fans(page?.artist?.fans || seed?.fans)}{/if} + {#if years}{years}{/if} + {#if page?.top_genre}{page.top_genre}{/if} + {#if albums.length}{/if} + {#each socials as link (link.kind + link.url)} + + {/each} +
+
+ +{#if entry?.status === "loading"} +
Looking up the rest on the catalog…
+{:else if entry?.status === "error"} +
The catalog did not answer — only what this phone holds is here.
+{/if} + +{#if popular.length} +

Popular

+ {#each popular as p, i (p.t.id)} + + {/each} + {#if popularAll.length > 5} + + {/if} +{/if} + +{#if latest} +

Latest release

+
+
+
+ +
+
Latest · {(latest.record_type || "album").toUpperCase()}
+
{latest.title}
+
{yearOf(latest.release_date)}{latest.track_count ? ` · ${latest.track_count} tracks` : ""}{inLib(latest) ? " · in your library" : ""}
+
+ + +
+
+
+
+{/if} + +{#if tabs.length} +

Discography · {releasedCount} on the catalog

+ {#if tabs.length > 1} +
+ {#each tabs as t (t.key)} + + {/each} +
+ {/if} +
+ {#each families as f (familyKeyOf(f))} + {@const l = familyLib(f)} + 1 ? `${f.editions.length} editions` : (l ? "in your library" : null)} + onclick={() => openFamily(f)} + /> + {/each} +
+{/if} + +{#if timeline} +

Releases by year · {timeline.last - timeline.first + 1} years · {timeline.total} releases

+
+ {#each timeline.cols as c (c.year)} +
+ + {String(c.year).slice(2)} +
+ {/each} +
+{/if} + +{#if extras?.related?.length} +

Fans also like

+
+ {#each extras.related.slice(0, 12) as a (a.id)} + + {/each} +
+{/if} + +{#if albums.length} +

On this phone · {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}

+
+ {#each albums as a (a.id)} + push("album", { id: a.id }, screenEl)} /> + {/each} +
+{/if} + +{#if extras?.bio || extras?.profile?.listeners || extras?.profile?.tags?.length} +

About

+
+ {#if extras.profile?.listeners || extras.profile?.plays} +
+ {#if extras.profile.listeners}{fmtCount(extras.profile.listeners)} listeners{/if} + {#if extras.profile.plays}{fmtCount(extras.profile.plays)} plays{/if} +
+ {/if} + {#if extras.bio} +

(bioOpen = !bioOpen)} role="button" tabindex="0" onkeydown={() => {}}>{extras.bio}

+ {/if} + {#if extras.profile?.tags?.length} +
{#each extras.profile.tags.slice(0, 6) as t (t)}{t}{/each}
+ {/if} +
+{/if} + +{#if !albums.length && !popular.length && !tabs.length && entry?.status !== "loading"} +
Nothing here yet.No records by {shownName} on this phone, and the catalog had nothing to add.
+{/if} + +
diff --git a/src/mobile/screens/CatalogAlbum.svelte b/src/mobile/screens/CatalogAlbum.svelte new file mode 100644 index 0000000..9cab117 --- /dev/null +++ b/src/mobile/screens/CatalogAlbum.svelte @@ -0,0 +1,221 @@ + + +
+
{#if album?.cover}{:else}{/if}
+
+
{(album?.record_type || "album").toUpperCase()}
+
{album?.title || title}
+ +
{[yearOf(album?.release_date), rows.length ? `${rows.length} tracks` : "", runtime, libAlbum ? "in your library" : ""].filter(Boolean).join(" · ") || "—"}
+ {#if page?.album_plays || page?.album_listeners} +
+ {#if page.album_plays}{fmtCount(page.album_plays)} plays{/if} + {#if page.album_listeners}{fmtCount(page.album_listeners)} listeners{/if} + {#if page.totals_summed}summed{/if} +
+ {/if} + {#if libAlbum || rows.length} +
+ + {#if !libAlbum || missing > 0} + + {/if} + +
+ {/if} +
+
+ +{#if editions.length} +

Editions

+
+ {#each editions as ed (ed.id)} + + {/each} +
+{/if} + +{#if entry?.status === "loading"} +
Asking the catalog…
+{:else if entry?.status === "error"} +
The catalog did not answer.{entry.error}
+{/if} + +
+ {#each rows as t, i (t.id)} + {@const hit = hits.get(looseTitle(t.title))} + {#if multiDisc && (t.disk_number ?? 1) !== (rows[i - 1]?.disk_number ?? (i === 0 ? null : 1))} +
Disc {t.disk_number ?? 1}
+ {/if} + + {/each} +
+ +{#if info?.description} +

About this record

+
+ {#if desc.lede}

{desc.lede}

{/if} + {#if desc.rest} +

(descOpen = !descOpen)} role="button" tabindex="0" onkeydown={() => {}}>{desc.rest}

+ {/if} +
+{/if} + +{#if album && (album.release_date || info?.label || info?.genre)} +

Release

+
+
+ {#if info?.genre}{info.genre}{/if} + {#if info?.label}{info.label}{/if} + {#if album.release_date}{fullDate(album.release_date)}{/if} + {#if rows.length}{rows.length} tracks{/if} + {#if runtime}{runtime}{/if} +
+

{#if info?.description}Words Wikipedia · {/if}{#if page?.album_plays || page?.album_listeners}Counts Last.fm · {/if}Catalogue {libAlbum ? "your library + " : ""}Deezer + MusicBrainz

+
+{/if} + +
diff --git a/src/mobile/screens/Community.svelte b/src/mobile/screens/Community.svelte new file mode 100644 index 0000000..1711f34 --- /dev/null +++ b/src/mobile/screens/Community.svelte @@ -0,0 +1,160 @@ + + +
+
+

Community

+ {#if online} +
+ + +
+ {/if} +
+ {#if online} +
online as {slsk.username} · Soulseek
+ {:else if slsk.connection === "connecting"} +
signing in…
+ {/if} +
+ +{#if !online} + +
+ Spaces, holdings and the sealed inbox come later on the phone. + They live on the private layer; the network side is here first. +
+{:else} +
+ {#each [["rooms", "Rooms"], ["chat", "Chat"], ["buddies", "Buddies"]] as [k, l] (k)} + + {/each} +
+ + {#if tab === "rooms"} + {#if joinedNames.length} +

Joined · {joinedNames.length}

+ {#each joinedNames as name (name)} + {@const r = slsk.joined[name]} + {@const last = r.messages[r.messages.length - 1]} + + {/each} + {/if} +

Rooms · {slsk.rooms.length} on the server

+ {#if !slsk.rooms.length} +
Asking the server for its rooms…
+ {/if} + {#each otherRooms as r (r.name)} + + {/each} + {:else if tab === "chat"} + {#if !threads.length} +
No conversations yet.A name in a room, or a buddy, opens one.
+ {/if} + {#each threads as t (t.u)} + + {/each} + {:else if tab === "buddies"} + {#if !buddies.length} +
No buddies on this account yet.Hold a name in a room to add one.
+ {/if} + {#each buddies as b (b.u)} + + {/each} + {/if} +
+{/if} + +
diff --git a/src/mobile/screens/Home.svelte b/src/mobile/screens/Home.svelte new file mode 100644 index 0000000..df9356d --- /dev/null +++ b/src/mobile/screens/Home.svelte @@ -0,0 +1,320 @@ + + +
+
+
+
{dateKick()}
+

{greeting()}

+
+ +
+
+ +{#if lead} + +
+
+
+
+
+
+ +
+
{lead.upcoming ? "Coming" : "New"} · {kindLabel(lead.record_type).toUpperCase()}
+
{lead.title}
+
{lead.artistName} · {lead.upcoming ? upcomingLabel(lead) : age(lead.release_date)}{lead.track_count ? ` · ${lead.track_count} tracks` : ""}
+
+
+
+ {lib.follows.length} followed + {lib.albums.length} albums +
+
+ {#if lead.upcoming} + + {:else if !lead.mbOnly} + + {/if} + +
+
+
+{:else if newestHere} +
+
+
+
+
+
+ +
+
Newest in your library
+
{newestHere.title}
+
{newestHere.artist} · {newestHere.year ?? "—"} · {newestHere.track_count} tracks
+
+
+
+ {fromThisYear} from this year + {lib.follows.length} followed + {lib.albums.length} albums +
+
+ + +
+
+
+{:else if lib.loaded} +
+ Nothing in the library on this phone yet. + Music the phone can play lives in the app's own Library folder for now; follow artists and Save records from the catalog, and this page fills up. +
+{/if} + +{#if feedRest.length} +

New from your artists · {lib.follows.length} followed

+
+ {#each feedRest as r (r.artistName + "|" + r.title)} + openRelease(r)} /> + {/each} +
+{/if} + +{#if newsItems.length} +

News · from your artists

+ {#each newsItems as n (n.url)} + + {/each} +{/if} + +{#if recents.length} +
+

Jump back in · picks up where you left off

+ +
+
+ {#each recents as r (r.album_id)} + open(r.album_id)} /> + {/each} +
+{/if} + +{#if backCatalogue.length} +

From the back catalogue · albums you may have missed

+
+ {#each backCatalogue as r (r.artistName + "|" + r.title)} + openRelease(r)} /> + {/each} +
+{/if} + +{#if because.length} +

Recommended for you · from everyone you follow

+
+ {#each because as r (r.id)} + + {/each} +
+{/if} + +{#if sorted.length} +
+

Recently added · {lib.albums.length} albums

+ +
+
+ {#each sorted.slice(0, 6) as a (a.id)} + open(a.id)} onmore={() => openHalf("album", { album: a })} /> + {/each} +
+{/if} + +{#if fresh.length} +
+

Out now · released this year

+ +
+
+ {#each fresh as a (a.id)} + = thisYear ? String(a.year) : null} onclick={() => open(a.id)} onmore={() => openHalf("album", { album: a })} /> + {/each} +
+{/if} + +{#if completeSet.length} +

Complete the set · gaps on your shelf

+ {#each completeSet as row (row.slot.id)} + + {/each} +{/if} + +{#if following.length} +

Following · {lib.follows.length} artists

+
+ {#each following as row (row.slot.id)} + + {/each} +
+{/if} + +
diff --git a/src/mobile/screens/Library.svelte b/src/mobile/screens/Library.svelte new file mode 100644 index 0000000..f695624 --- /dev/null +++ b/src/mobile/screens/Library.svelte @@ -0,0 +1,187 @@ + + +
+
+

Your library

+
+ + + +
+
+
+ +
+ {#each TABS as [k, l] (k)} + + {/each} +
+ +{#if tab === "albums"} +
+ {#each SORTS as [k, l] (k)} + + {/each} +
+
+ {list.length} album{list.length === 1 ? "" : "s"} · {totalTracks} tracks + +
+ {#if !list.length} +
Nothing here yetThe library on this phone is empty.
+ {:else if view === "grid"} +
+ {#each list as a (a.id)} + push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} /> + {/each} +
+ {:else} + {#each list as a (a.id)} + + {/each} + {/if} +{:else if tab === "artists"} +
{arts.length} artists · {lib.follows.length} followed
+ {#each arts as ar (ar.name)} + + {/each} +{:else if tab === "tracks"} + +{:else if tab === "playlists"} + {#if !lib.playlists.length} +
No playlists on this phone yet.Playlists are part of the library folder, so the ones the desktop keeps come over with it.
+ {/if} + {#each lib.playlists as p (p.id)} + + {/each} +{:else if tab === "gaps"} + {#if !shelves.length} +
Follow the artists you collect.The records they released that this library does not have show up here, shelf by shelf — the desktop's discography lookup, only ever for an artist you followed, so a name is never guessed onto a namesake.
+ {:else} +
{shelves.length} followed {shelves.length === 1 ? "artist" : "artists"} on this phone
+ {#each shelves as ar (ar.name)} + {@const key = foldKey(ar.name)} + {@const count = gapCount(ar)} + + {#if count !== 0} + + {:else} +
Complete — every released record is here
+ {/if} + {#if gapOpen[key] && gapRows[key]?.length} +
+ {#each gapRows[key] as r (r.id)} + push("catalogAlbum", { id: r.id, artist: ar.name, title: r.title }, screenEl)} /> + {/each} +
+ {/if} + {/each} + {/if} +{/if} + +
diff --git a/src/mobile/screens/Playlist.svelte b/src/mobile/screens/Playlist.svelte new file mode 100644 index 0000000..2fea1fa --- /dev/null +++ b/src/mobile/screens/Playlist.svelte @@ -0,0 +1,70 @@ + + +{#if pl} +
+
{#if cover}{:else}{/if}
+
+
{pl.name}
+
{items.length} track{items.length === 1 ? "" : "s"}{total ? ` · ${longDur(total)}` : ""}{playable < items.length ? ` · ${playable} on this phone` : ""}
+ {#if pl.description}
{pl.description}
{/if} +
+ + +
+
+
+
+ {#each items as x, i (x.id)} + + {/each} +
+{:else} +
That playlist is gone.
+{/if} + +
diff --git a/src/mobile/screens/Room.svelte b/src/mobile/screens/Room.svelte new file mode 100644 index 0000000..e453369 --- /dev/null +++ b/src/mobile/screens/Room.svelte @@ -0,0 +1,69 @@ + + +{#if room} +
+ {room.users.length} here now + +
+
+ {#if !lines.length} +
Nothing said since you joined.
+ {/if} + {#each lines as l, i (i)} + {@const me = l.username === slsk.username} +
+ {#if !me}{/if} +
{l.message}
+
{clock(l.ts)}
+
+ {/each} +
+
+ { if (e.key === "Enter") { e.preventDefault(); send(); } }} /> + +
+{:else} +
Not in this room.Join it from Community.
+{/if} + +
diff --git a/src/mobile/screens/Search.svelte b/src/mobile/screens/Search.svelte new file mode 100644 index 0000000..3fc1dde --- /dev/null +++ b/src/mobile/screens/Search.svelte @@ -0,0 +1,383 @@ + + +

Search

+ +
+ + setQ(e.currentTarget.value)} type="search" placeholder="Artists, records, songs" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search" aria-label="Search" onkeydown={enter} /> + {#if q} + + {/if} +
+ +{#if !asking} +
+ The catalog, your library{online ? " and the network" : ""}. + Artists, records and songs from the catalog; a song plays from the best copy the network has, a record opens its page. + {#if !online}Sign in on Community to play what the library does not hold.{/if} +
+{:else} + {#if catErr}
{catErr}
{/if} + {#if catBusy && !cat}
Asking the catalog…
{/if} + {#if catEmpty && !catBusy}
Nothing on the catalog for “{query}”.
{/if} + + {#if top} +
+
+
+ +
+
Top result · {top.kind === "artist" ? "Artist" : top.kind === "album" ? "Record" : "Song"}
+
{top.kind === "artist" ? top.data.name : top.data.title}
+ {#if top.kind === "artist"} +
{[fans(top.data.fans), topReleases.length ? topReleases.length + " release" + (topReleases.length === 1 ? "" : "s") : "", profile?.listeners ? fans(profile.listeners).replace("fans", "listeners") : ""].filter(Boolean).join(" · ")}
+ {#if heroTags.length}
{#each heroTags as t (t)}{t}{/each}
{/if} + {:else if top.kind === "album"} +
+ + {[yearOf(top.data.release_date), top.data.track_count ? `${top.data.track_count} tracks` : ""].filter(Boolean).map((x) => " · " + x).join("")} +
+ {:else} +
+ + {top.data.album ? " · " + top.data.album : ""}{top.data.duration_secs ? " · " + mmss(top.data.duration_secs) : ""} +
+ {/if} +
+ + +
+
+
+
+ {#if latestRelease} + + {/if} + {/if} + + {#if catArtists.length} +

Artists

+
+ {#each catArtists as ar (ar.id)} + + {/each} +
+ {/if} + + {#if catAlbums.length} +

Records

+
+ {#each catAlbums as a (a.id)} + openAlbum(a)} /> + {/each} +
+ {/if} + + {#if catTracks.length} +

Songs

+ {#each catTracks as t (t.id)} + + {/each} + {/if} + + {#if libAny} +

In your library{#if searching} · searching…{/if}

+ {#each artistHits as ar (ar.name)} + + {/each} + {#if albumHits.length} +
+ {#each albumHits as a (a.id)} + push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} /> + {/each} +
+ {/if} + {#each tracks as row (row.path)} + + {/each} + {/if} + + {#if online} + {#if !netHere} +
+ +
+ {:else} +
+

The network · {net.searching ? "searching…" : net.responses ? net.responses + " people · " + net.files + " files" : "nothing yet"}

+ {#if !net.searching}{/if} +
+ {#if net.error}
{net.error}
{/if} + {#if net.searching && !net.groups.length} +
Asking the network as {slsk.username}…
+ {/if} + {#each net.groups.slice(0, 40) as g (g.key)} +
+
+ + + {g.username}{g.slot ? "slot free" : "queue " + g.queue} · {speedLabel(g.speed)} + + +
+ +
+ {#each g.files.slice(0, expanded.has(g.key) ? 200 : 3) as f, i (f.path)} + + {/each} + {#if g.files.length > 3 && !expanded.has(g.key)} + + {/if} +
+
+ {/each} + {/if} + {/if} +{/if} + +
diff --git a/src/mobile/screens/Stub.svelte b/src/mobile/screens/Stub.svelte new file mode 100644 index 0000000..0fd3834 --- /dev/null +++ b/src/mobile/screens/Stub.svelte @@ -0,0 +1,13 @@ + + +

{title}

+
{title} is next.{text}
+
diff --git a/src/mobile/screens/Thread.svelte b/src/mobile/screens/Thread.svelte new file mode 100644 index 0000000..b145feb --- /dev/null +++ b/src/mobile/screens/Thread.svelte @@ -0,0 +1,57 @@ + + +
+ + {st.status === 2 || st.status === "online" ? "online" : st.status === 1 || st.status === "away" ? "away" : "on Soulseek"}{st.file_count ? ` · ${st.file_count.toLocaleString()} files` : ""} +
+
+ {#if !lines.length} +
Say something to {username}.
+ {/if} + {#each lines as l, i (i)} +
+
{l.text}
+
{clock(l.ts)}
+
+ {/each} +
+
+ { if (e.key === "Enter") { e.preventDefault(); send(); } }} /> + +
+ +
diff --git a/src/mobile/screens/Tracks.svelte b/src/mobile/screens/Tracks.svelte new file mode 100644 index 0000000..5d76952 --- /dev/null +++ b/src/mobile/screens/Tracks.svelte @@ -0,0 +1,69 @@ + + +
+ + setQ(e.currentTarget.value)} type="search" placeholder="Filter tracks" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Filter tracks" /> + {#if q}{/if} +
+
+ {#if index === null}Reading the index…{:else}{index.length} tracks · {longDur(total)}{/if} + {#if list.length > CAP} · showing {CAP} of {list.length}{/if} + {#if failed} · {failed}{/if} +
+ +{#each list.slice(0, CAP) as r, i (r.path)} + +{/each} +{#if index && !list.length} +
No tracks match.
+{/if} diff --git a/src/mobile/screens/Transfers.svelte b/src/mobile/screens/Transfers.svelte new file mode 100644 index 0000000..3fb9462 --- /dev/null +++ b/src/mobile/screens/Transfers.svelte @@ -0,0 +1,46 @@ + + +
+
+

Transfers

+ {#if rows.some((d) => DONE.has(d.state))}{/if} +
+
+ +{#if !rows.length} +
+ Nothing in flight. + Save a record or a song from the catalog and it arrives here — the best copy by the download rules, filed into your library when it lands. +
+{:else} +

{live ? `${live} in flight` : "Done"} · {rows.length} total

+ {#each rows as d (d.id)} + + {/each} +{/if} + +
diff --git a/src/mobile/screens/You.svelte b/src/mobile/screens/You.svelte new file mode 100644 index 0000000..4fc169d --- /dev/null +++ b/src/mobile/screens/You.svelte @@ -0,0 +1,124 @@ + + +

You

+ +
Appearance
+
+ {#each ROWS as [k, icon, t, m] (k)} + + {/each} +
+ +
Playback
+
+ +
+ +
Library
+
+ + {#if !privateFolder && access === false} + + {/if} +
Records{lib.albums.length}
+
Playlists{lib.playlists.length}
+ + +
+ +
This phone
+
+
SoulStream{lib.version || "—"}
+
Soulseek{lib.username || "not signed in"}
+
+ +
+ Playback, storage, the network and the account come next. + They are the desktop's settings pages, drawn with the phone's components; the library folder you pick and Import land with them. +
+ +
diff --git a/src/mobile/shell.css b/src/mobile/shell.css new file mode 100644 index 0000000..a687373 --- /dev/null +++ b/src/mobile/shell.css @@ -0,0 +1,1260 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + SoulStream on Android — the shell and the shared vocabulary. + + research/mobile-mockup's app.css / player.css / layouts.css, the phone + layout, with the workbench and the other layouts left out. Every name has + a desktop ancestor, noted where it matters. Global on purpose: this is + the phone's own document, and a vocabulary shared across screens is not + a component's private style. + ═══════════════════════════════════════════════════════════════════════════ */ + +.app { + position: relative; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--bg-base); +} + +/* ── the page ground ────────────────────────────────────────────────────── + The desktop's dark-glass canvas, restated for a device with no compositor + blur to borrow: the artwork in play, blown up and blurred, is the light + source. --ambience is how much of it the ground admits. Two layers, so a + change of record is a crossfade rather than a cut (background-image does + not transition). */ +.app-bg { + position: absolute; + inset: 0; + z-index: 0; + background: var(--bg-base); + overflow: hidden; + pointer-events: none; +} +.amb { + position: absolute; + inset: -22%; + background-size: cover; + background-position: center; + filter: blur(56px) saturate(1.5); + opacity: 0; + transform: scale(1.25); + transition: opacity 620ms ease; + will-change: opacity; +} +.amb.on { opacity: var(--ambience); } +.amb-tint { + position: absolute; + inset: 0; + background: linear-gradient(180deg, + rgba(10, 10, 12, 0.34) 0%, + rgba(10, 10, 12, 0.72) 46%, + rgba(10, 10, 12, 0.93) 100%); +} +/* the desktop's .feat-grain: a fixed noise tile at low alpha so a huge flat + gradient does not band on an OLED */ +.grain { + position: absolute; + inset: 0; + pointer-events: none; + opacity: var(--grain); + background-image: url("data:image/svg+xml;utf8,"); + mix-blend-mode: overlay; +} + +/* ── the scroll pane ───────────────────────────────────────────────────── */ +.pane { + position: relative; + z-index: 1; + flex: 1; + min-height: 0; +} +.viewport { + position: absolute; + inset: 0; + overflow: hidden; +} +.screen { + position: absolute; + inset: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + padding-top: var(--inset-top); +} +.screen::-webkit-scrollbar { width: 0; display: none; } +/* a pushed screen whose content starts at the very top must clear the bar; + heroes bleed behind it and the record head has its own clearance */ +.screen.under-bar { padding-top: var(--top-h); } +/* the pad that clears the mini player + nav bar */ +.endpad { height: calc(var(--nav-h) + var(--inset-bottom) + 24px); } +.has-mini .endpad { height: calc(var(--mini-h) + var(--nav-h) + var(--inset-bottom) + 16px); } + +/* ── the glass bar ──────────────────────────────────────────────────────── + Direct descendant of GlassBar.svelte: same tint, same 12px blur at + saturate(1.7), same top-light hairline. A phone has a system back gesture + and no room for a hierarchy trail, so the trail collapses to a KICKER + under the title, and the arrows to one chevron that only appears when + there is somewhere to go back to. It runs up under the status bar. */ +.gbar { + position: absolute; + top: 0; left: 0; right: 0; + z-index: 40; + height: var(--top-h); + padding: var(--inset-top) 8px 0 6px; + display: flex; + align-items: center; + gap: 10px; + /* SOLID by default on the phone. The desktop's glass — the 28% tint over + a 12px backdrop blur — was measured on the Pixel 6a (tests/mobile/ + perf.mjs): every backdrop blur re-blurs its band on every frame that + scrolls or animates under it, and the three of them (this bar, the mini + bar, the nav) turned a 60 Hz scroll into 30 Hz and dropped every other + frame of the player's opening. The blur is a switch (You → Appearance → + Glass) so the two can be compared; the design was built to stand on + the solid floor first. */ + background: rgba(18, 18, 22, 0.92); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05); + opacity: 0; + transition: opacity 180ms ease; + pointer-events: none; +} +.gbar.on { opacity: 1; pointer-events: auto; } +html[data-glass="blur"] .gbar { background: rgba(20, 20, 24, 0.28); backdrop-filter: blur(12px) saturate(1.7); -webkit-backdrop-filter: blur(12px) saturate(1.7); } +html[data-material="solid"] .gbar { background: rgba(24, 24, 24, 0.94); backdrop-filter: none; -webkit-backdrop-filter: none; } +.gbar .gb-t { + font-size: 15px; + font-weight: 800; + letter-spacing: -0.2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + /* the bar arrives before its title does */ + opacity: 0; + transform: translateY(5px); + transition: opacity 200ms ease, transform 240ms cubic-bezier(0.22, 1, 0.36, 1); +} +.gbar.titled .gb-t { opacity: 1; transform: none; } +.gbar .gb-t small { + display: block; + font-size: 10.5px; + font-weight: 700; + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.2px; + margin-top: -1px; +} + +/* the round chrome button — the desktop bar's .arrow at touch size */ +.iconbtn { + display: grid; + place-items: center; + width: var(--tap); + height: var(--tap); + border-radius: 50%; + color: #fff; + flex: none; + transition: background var(--m-fast), transform var(--m-fast); +} +.iconbtn:active { background: rgba(255, 255, 255, 0.12); transform: scale(0.92); } +.iconbtn.sm { width: 38px; height: 38px; } +.iconbtn.muted { color: var(--text-secondary); } +.iconbtn.on { color: var(--accent-hover); } +.iconbtn.liked { color: var(--accent); } + +/* ── large page header (the un-scrolled state) ────────────────────────── */ +.phead { padding: 14px var(--pad) 6px; position: relative; z-index: 2; } +.phead .kick { + font-size: 11.5px; + font-weight: 800; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--accent-hover); + margin-bottom: 3px; +} +.phead h1 { margin: 0; font-size: 27px; font-weight: 800; letter-spacing: -0.7px; line-height: 1.1; } +.phead .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 0; padding: 0; } +.phead .row:active { background: none; } +.phead .acts { display: flex; } + +/* ── section heads — the desktop's ax-head / ax-title / ax-note ────────── */ +.sec { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 22px var(--pad) 11px; +} +.sec h2 { margin: 0; font-size: 17px; font-weight: 800; letter-spacing: -0.25px; min-width: 0; } +.sec h2 em { font-style: normal; font-size: 12.5px; font-weight: 650; color: var(--text-muted); letter-spacing: 0; } +.sec .all { + font-size: 12px; + font-weight: 750; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 1px; + flex: none; + padding: 6px 2px 6px 8px; +} +.sec .all:active { color: #fff; } +.sec-note { padding: 0 var(--pad) 4px; font-size: 12.5px; color: var(--text-muted); line-height: 1.5; } + +/* ── horizontal rails ─────────────────────────────────────────────────── */ +.rail { + display: flex; + gap: 13px; + overflow-x: auto; + scroll-snap-type: x proximity; + padding: 0 var(--pad) 2px; + scrollbar-width: none; +} +.rail::-webkit-scrollbar { display: none; } +.rail > * { scroll-snap-align: start; flex: none; } + +/* ── the release card — Home.svelte's .release, at phone scale ──────────── */ +.release { width: 138px; text-align: left; display: block; } +.release.w2 { width: 100%; } +.cover-wrap { + position: relative; + display: block; + aspect-ratio: 1; + border-radius: var(--r-art); + overflow: hidden; + background: var(--surface); + box-shadow: var(--ring), 0 6px 18px -8px rgba(0, 0, 0, 0.7); + transition: transform var(--m-base); +} +html[data-material="solid"] .cover-wrap { background: var(--bg-elevated); } +.cover-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; animation: img-in 300ms ease both; } +@keyframes img-in { + from { opacity: 0; transform: scale(1.04); } + to { opacity: 1; transform: none; } +} +.cover-wrap .ph { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted); } +.release .title { + display: -webkit-box; + font-size: 14px; + font-weight: 700; + margin-top: 8px; + line-height: 1.28; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.release .sub { + display: block; + font-size: 12.5px; + color: var(--text-muted); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.release:active .cover-wrap { transform: scale(0.965); transition: transform 90ms ease; } + +/* the edition badge, straight from Home.svelte */ +.ed-badge { + position: absolute; + top: 6px; right: 6px; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 4px; + line-height: 1; + padding: 4px 7px; + border-radius: 6px; + font-size: 10px; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: #fff; + background: color-mix(in srgb, var(--accent) 40%, rgba(10, 10, 12, 0.7)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent); +} + +/* artist card — the desktop's ax-card, round on mobile */ +.acard { width: 116px; text-align: center; display: block; } +.acard .por { + display: block; + width: 116px; height: 116px; + border-radius: 50%; + overflow: hidden; + background: var(--surface); + box-shadow: var(--ring); + margin: 0 auto; +} +.acard .por img { width: 100%; height: 100%; object-fit: cover; animation: img-in 300ms ease both; } +/* no picture: the app's own placeholder, centred, muted */ +.acard .por { display: flex; align-items: center; justify-content: center; color: var(--text-muted); } +.acard .nm { display: block; font-size: 13.5px; font-weight: 700; margin-top: 9px; line-height: 1.25; } +.acard .kd { display: block; font-size: 12px; color: var(--text-muted); margin-top: 1px; } + +/* ── grids: auto-fill, so one rule covers every width ─────────────────── */ +.grid2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(148px, 46%), 1fr)); gap: 20px 14px; padding: 0 var(--pad); } +.grid2 > .release, .grid2 > .acard { width: 100%; } +.grid2 > .acard .por { width: 100%; height: auto; aspect-ratio: 1; } + +/* ── list rows ────────────────────────────────────────────────────────── */ +.row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: var(--tap); + padding: 8px var(--pad); + text-align: left; + transition: background var(--m-fast); +} +.row:active { background: var(--hover); } +.row .art { + width: 46px; height: 46px; + border-radius: 7px; + overflow: hidden; + flex: none; + background: var(--surface); + box-shadow: var(--ring); + position: relative; + display: grid; + place-items: center; + color: var(--text-muted); +} +.row .art.round { border-radius: 50%; } +.row .art img { width: 100%; height: 100%; object-fit: cover; animation: img-in 260ms ease both; } +.row .b { min-width: 0; flex: 1; } +.row .t { display: block; font-size: 14.5px; font-weight: 650; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.row .m { display: block; font-size: 12.5px; color: var(--text-muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.row .r { flex: none; color: var(--text-muted); display: flex; align-items: center; gap: 4px; } +.row.playing .t { color: var(--accent-hover); } +.row .lead { width: 30px; display: grid; place-items: center; color: var(--text-secondary); flex: none; } + +/* track rows carry a number instead of art */ +.trk { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 46px; padding: 5px var(--pad); text-align: left; transition: background var(--m-fast); } +.trk:active { background: var(--hover); } +.trk .n { width: 22px; flex: none; text-align: right; font-size: 13px; font-variant-numeric: tabular-nums; color: var(--text-muted); font-weight: 650; } +.trk .b { min-width: 0; flex: 1; } +.trk .t { display: block; font-size: 14.5px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.trk .m { display: block; font-size: 12px; color: var(--text-muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.trk .d { font-size: 12.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: none; } +.trk .r { flex: none; color: var(--text-muted); display: grid; place-items: center; width: 34px; height: 34px; margin-right: -8px; border-radius: 50%; } +.trk .r:active { background: var(--hover); } +.trk.playing .n { color: var(--accent); } +.trk.playing .t { color: var(--accent-hover); } +/* the playing marker: the desktop's little bars, at row size */ +.trk.playing .n { font-size: 0; } +.trk.playing .n::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); } + +/* ── buttons: the desktop's ax-primary / ax-ghost, at 48dp ─────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 40px; + padding: 0 17px; + border-radius: var(--r-pill); + font-size: 13.5px; + font-weight: 750; + white-space: nowrap; + transition: background var(--m-fast), transform var(--m-fast), color var(--m-fast); +} +.btn:active { transform: scale(0.955); } +.btn-fill { background: var(--accent); color: #04121a; } +.btn-fill:active { background: var(--accent-hover); } +.btn-ghost { background: rgba(255, 255, 255, 0.09); color: #fff; } +.btn-ghost:active { background: rgba(255, 255, 255, 0.16); } +.btn.wide { width: 100%; } + +/* the play disc — the app's one play vocabulary: filled accent circle + play */ +.disc { + display: grid; + place-items: center; + width: 46px; height: 46px; + border-radius: 50%; + background: var(--accent); + color: #04121a; + flex: none; + box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent) 70%, transparent); + transition: transform var(--m-fast), background var(--m-fast); +} +.disc:active { transform: scale(0.92); background: var(--accent-hover); } +.disc.lg { width: 66px; height: 66px; } +.disc.sm { width: 36px; height: 36px; } + +/* ── chips ────────────────────────────────────────────────────────────── */ +.chips { display: flex; gap: 7px; overflow-x: auto; padding: 0 var(--pad); scrollbar-width: none; } +.chips::-webkit-scrollbar { display: none; } +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 13px; + border-radius: var(--r-pill); + background: rgba(255, 255, 255, 0.08); + font-size: 12.5px; + font-weight: 700; + color: #fff; + white-space: nowrap; + flex: none; + transition: background var(--m-fast), color var(--m-fast), transform var(--spd-spatial-fast) var(--sp-spatial-fast); +} +.chip:active { transform: scale(0.94); } +.chip.on { background: var(--accent); color: #04121a; } +.chip.quiet { background: rgba(255, 255, 255, 0.055); color: var(--text-secondary); font-weight: 650; } + +/* stat chips — Home's .stat-chips, glassier on mobile */ +.stats { display: flex; flex-wrap: wrap; gap: 6px; } +.stats .s { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 9px; + border-radius: var(--r-pill); + background: rgba(0, 0, 0, 0.42); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1); + font-size: 11.5px; + font-weight: 750; + color: rgba(255, 255, 255, 0.92); +} + +/* ── segmented tabs (Library's second tier) ───────────────────────────── */ +.segtabs { display: flex; gap: 6px; overflow-x: auto; padding: 4px var(--pad) 10px; scrollbar-width: none; } +.segtabs::-webkit-scrollbar { display: none; } +.segtabs button { + padding: 7px 14px; + border-radius: var(--r-pill); + font-size: 13px; + font-weight: 750; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.055); + white-space: nowrap; + flex: none; + transition: background var(--m-fast), color var(--m-fast), transform var(--spd-spatial-fast) var(--sp-spatial-fast); +} +.segtabs button:active { transform: scale(0.94); } +.segtabs button.on { background: #fff; color: #101014; } + +/* ── cards / groups ───────────────────────────────────────────────────── */ +.card { background: var(--surface); box-shadow: var(--ring); border-radius: var(--r-card); overflow: hidden; } +.cardpad { padding: 14px; } +.group { margin: 0 var(--pad) 14px; border-radius: var(--r-card); overflow: hidden; background: var(--surface); box-shadow: var(--ring); } +html[data-material="solid"] .card, html[data-material="solid"] .group { background: var(--bg-elevated); box-shadow: none; } +.group .row { padding: 11px 14px; } +.group .row + .row { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); } +.group .lead { width: 32px; display: grid; place-items: center; color: var(--accent-hover); flex: none; } + +/* key / value rows — the shape a page wants when it is REPORTING */ +.kv { display: flex; align-items: baseline; gap: 12px; padding: 9px 14px; min-height: 40px; } +.kv + .kv { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); } +.kv .k { font-size: 13px; color: var(--text-muted); font-weight: 600; flex: none; } +.kv .v { font-size: 13.5px; font-weight: 700; margin-left: auto; text-align: right; min-width: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.kv .v.mono { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 12px; font-weight: 600; letter-spacing: -0.2px; } +.kv .v.dim { color: var(--text-secondary); font-weight: 600; } + +/* the switch — M3 Expressive's, at the size the rest of this app sits at */ +.sw { + width: 44px; height: 26px; + border-radius: var(--r-pill); + flex: none; + position: relative; + background: rgba(255, 255, 255, 0.16); + box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.14); + transition: background var(--m-base), box-shadow var(--m-base); +} +.sw > span { + position: absolute; + top: 4px; left: 4px; + width: 18px; height: 18px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.62); + transition: left var(--m-spring), width var(--m-spring), height var(--m-spring), top var(--m-spring), background var(--m-base); +} +.sw.on { background: var(--accent); box-shadow: none; } +.sw.on > span { left: 20px; top: 3px; width: 20px; height: 20px; background: #fff; } + +/* tags — the format badge and its verdict colours */ +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 19px; + padding: 2px 7px; + line-height: 1.25; + border-radius: 5px; + font-size: 10.5px; + font-weight: 800; + letter-spacing: 0.3px; + background: rgba(255, 255, 255, 0.09); + color: var(--text-secondary); +} +.tag.hi { background: color-mix(in srgb, var(--accent) 26%, transparent); color: var(--accent-hover); } +.tag.good { background: rgba(61, 220, 132, 0.16); color: #3ddc84; } + +/* ── the hero card — Home.svelte's .feat, restacked vertically ──────────── */ +.feat { + position: relative; + margin: 6px var(--pad) 0; + border-radius: var(--r-card); + overflow: hidden; + min-height: 152px; + display: flex; + align-items: flex-end; + box-shadow: 0 12px 34px -14px rgba(0, 0, 0, 0.85); +} +/* the blow-up is BLURRED here, unlike the desktop's sharp banner: at 380px + wide a sharp cover behind text is noise; blurred it is the record's own + colour field, the trick the page ground uses */ +.feat .bg { position: absolute; inset: -18%; background-size: cover; background-position: center; filter: blur(30px) saturate(1.25); transform: scale(1.2); } +.feat .tint { + position: absolute; inset: 0; + background: + linear-gradient(180deg, rgba(8, 8, 10, 0.42) 0%, rgba(8, 8, 10, 0.66) 55%, rgba(8, 8, 10, 0.84) 100%), + linear-gradient(90deg, rgba(8, 8, 10, 0.34) 0%, transparent 70%); +} +.feat .in { position: relative; z-index: 2; padding: 15px; width: 100%; } +.feat-top { display: flex; gap: 13px; align-items: flex-start; } +.feat-art { + display: block; + width: 78px; height: 78px; + flex: none; + border-radius: 9px; + overflow: hidden; + background: var(--bg-highlight); + box-shadow: 0 10px 22px -8px rgba(0, 0, 0, 0.85), inset 0 0 0 1px rgba(255, 255, 255, 0.14); + transition: transform var(--spd-spatial-fast) var(--sp-spatial-fast); +} +.feat-art img { width: 100%; height: 100%; object-fit: cover; display: block; } +.feat-art:active { transform: scale(var(--art-grow)); } +.feat .kick { font-size: 10.5px; font-weight: 800; letter-spacing: 0.9px; text-transform: uppercase; color: rgba(255, 255, 255, 0.7); } +.feat .nm { font-size: 21px; font-weight: 800; letter-spacing: -0.5px; margin: 2px 0 3px; line-height: 1.12; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.feat-sub { font-size: 12.5px; font-weight: 600; color: rgba(255, 255, 255, 0.68); } +.feat .stats { margin-top: 12px; } +.feat .acts { display: flex; gap: 8px; margin-top: 13px; } + +/* ── the empty / stub markers ─────────────────────────────────────────── */ +.stub { + margin: 10px var(--pad); + padding: 16px; + border-radius: var(--r-card); + background: rgba(255, 255, 255, 0.035); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07); + font-size: 12.5px; + line-height: 1.55; + color: var(--text-secondary); +} +.stub b { color: #fff; font-weight: 750; display: block; margin-bottom: 3px; font-size: 13px; } + +/* ── the record head — album and playlist share it ──────────────────────── + Stacked and centred, the cover as the page's own hero. The glass bar is + opaque from the first pixel on a pushed screen, so the cover starts below + it. */ +.rec-head { padding: calc(var(--bar-h) + 14px) var(--pad) 0; display: flex; flex-direction: column; align-items: center; } +.rec-cover { + width: 66%; + max-width: 300px; + aspect-ratio: 1; + flex: none; + border-radius: var(--r-card); + overflow: hidden; + background: var(--surface); + box-shadow: 0 26px 54px -18px rgba(0, 0, 0, 0.9), inset 0 0 0 1px rgba(255, 255, 255, 0.08); + display: grid; + place-items: center; + color: var(--text-muted); +} +.rec-cover img { width: 100%; height: 100%; object-fit: cover; display: block; animation: img-in 300ms ease both; } +.rec-meta { text-align: center; min-width: 0; width: 100%; } +.rec-title { font-size: 22px; font-weight: 800; letter-spacing: -0.4px; line-height: 1.2; margin-top: 18px; } +.rec-artist { font-size: 14.5px; font-weight: 700; color: var(--accent-hover); margin-top: 6px; display: inline-block; padding: 2px 6px; border-radius: 6px; } +.rec-artist:active { background: var(--hover); } +.rec-line { margin-top: 5px; font-size: 13px; font-weight: 500; color: var(--text-muted); } +.rec-chips { justify-content: center; margin-top: 9px; } +.rec-acts { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 18px 0 6px; } +.disc-head { padding: 14px var(--pad) 4px; font-size: 13px; font-weight: 800; color: var(--text-muted); letter-spacing: 0.6px; text-transform: uppercase; } + +/* ── the artist hero ────────────────────────────────────────────────────── + The desktop hero is a wide banner beside the identity block. A phone has + no beside, so the portrait goes full-bleed and the name sits ON it. */ +.hero { position: relative; height: 300px; display: flex; align-items: flex-end; overflow: hidden; margin-bottom: -6px; } +.hero-img { position: absolute; inset: 0; background-size: cover; background-position: center 22%; transform: scale(1.04); } +/* the blurred SEED: the picture the library already has, soft and + oversized, until a sharp one exists */ +.hero-img.seed { filter: blur(16px) saturate(1.15); transform: scale(1.12); } +/* the sharp one dissolves in over the seed when the catalog lookup lands */ +.hero-img.dissolve { animation: hero-dissolve 720ms var(--sp-effects-default) both; } +@keyframes hero-dissolve { + from { opacity: 0; filter: blur(16px) saturate(1.15); transform: scale(1.12); } + to { opacity: 1; filter: blur(0) saturate(1); transform: scale(1.04); } +} +.hero-scrim { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(10, 10, 12, 0.3) 0%, rgba(10, 10, 12, 0.1) 34%, rgba(10, 10, 12, 0.82) 82%, var(--bg-base) 100%), + linear-gradient(90deg, rgba(10, 10, 12, 0.42) 0%, transparent 58%); +} +.hero-in { position: relative; z-index: 2; padding: 0 var(--pad) 16px; width: 100%; } +.hero-name { font-size: 32px; font-weight: 800; letter-spacing: -0.9px; line-height: 1.04; text-shadow: 0 2px 14px rgba(0, 0, 0, 0.55); } + +/* ── mini player ────────────────────────────────────────────────────────── + The desktop PlayerBar is one 62px row; the phone keeps the height and the + single-row rule, floats it as a CARD above the nav, and gives the lane its + own strip along the bottom, inset past the corners. */ +.mini { + position: absolute; + left: 8px; right: 8px; + bottom: calc(var(--nav-h) + var(--inset-bottom) + 6px); + z-index: 50; + height: var(--mini-h); + border-radius: 16px; + display: flex; + align-items: center; + gap: 11px; + padding: 0 10px 26px 8px; /* 4 above the lane, 16 of lane, 6 below */ + background: rgba(28, 28, 33, 0.94); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09), 0 10px 28px -10px rgba(0, 0, 0, 0.9); + overflow: hidden; + transition: transform var(--m-sheet), opacity 200ms ease; + touch-action: none; +} +html[data-glass="blur"] .mini { background: rgba(28, 28, 33, 0.72); backdrop-filter: blur(22px) saturate(1.6); -webkit-backdrop-filter: blur(22px) saturate(1.6); } +html[data-material="solid"] .mini { background: #1c1c20; backdrop-filter: none; -webkit-backdrop-filter: none; } +.mini .art { width: 40px; height: 40px; border-radius: 9px; overflow: hidden; flex: none; background: var(--bg-highlight); display: grid; place-items: center; color: var(--text-muted); } +.mini .art img { width: 100%; height: 100%; object-fit: cover; } +.mini .b { min-width: 0; flex: 1; } +/* chrome, like the notification shade: the two lines scale to 150% and stop */ +.mini .t { display: block; font-size: min(13.5px, 18px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.mini .m { display: block; font-size: min(12px, 15px); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; } +.mini .m.live { color: var(--accent-hover); } +.mini .mini-lane { position: absolute; left: 16px; right: 16px; bottom: 6px; opacity: 0.95; pointer-events: none; } +.mini.hidden { transform: translateY(140%); opacity: 0; pointer-events: none; } + +/* ── bottom nav ───────────────────────────────────────────────────────── */ +.nav { + position: absolute; + left: 0; right: 0; bottom: 0; + z-index: 55; + height: calc(var(--nav-h) + var(--inset-bottom)); + padding-bottom: var(--inset-bottom); + display: flex; + background: rgba(16, 16, 19, 0.92); + box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.06); + transition: transform var(--m-sheet); +} +html[data-glass="blur"] .nav { background: rgba(16, 16, 19, 0.82); backdrop-filter: blur(26px) saturate(1.5); -webkit-backdrop-filter: blur(26px) saturate(1.5); } +html[data-material="solid"] .nav { background: #101013; backdrop-filter: none; -webkit-backdrop-filter: none; } +.nav.gone { transform: translateY(100%); } +.nav button { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--text-muted); + transition: color var(--m-fast); + position: relative; + padding-top: 2px; +} +.nav button .lb { font-size: 10.5px; font-weight: 750; letter-spacing: 0.1px; } +.nav button.on { color: #fff; } +/* the pill is the one element that should visibly overshoot: it is the app + telling you where you just landed */ +.nav button .pill { + position: absolute; + top: 4px; + width: 46px; height: 26px; + border-radius: var(--r-pill); + background: var(--accent); + opacity: 0; + transform: scaleX(0.5); + transition: opacity var(--m-fast), transform var(--spd-spatial-fast) var(--sp-spatial-fast); +} +.nav button.on .pill { opacity: 0.22; transform: none; } +.nav button .gl { position: relative; z-index: 1; height: 26px; display: grid; place-items: center; transition: transform var(--spd-spatial-fast) var(--sp-spatial-fast); } +.nav button:active .gl { transform: scale(0.86); } +.nav button.on .gl { color: var(--accent-hover); } + +/* ── the player sheet ───────────────────────────────────────────────────── + Two arrivals. Tapping the mini bar is a CONTAINER TRANSFORM: the artwork + flies from the bar to the sheet and the sheet fades in around it, which is + why .morphing suppresses the slide. Anything else slides up on the + spatial spring. */ +.sheet { + position: absolute; + inset: 0; + z-index: 90; + display: flex; + flex-direction: column; + transform: translateY(100%); + transition: transform var(--spd-spatial-default) var(--sp-spatial-default); + overflow: hidden; +} +.sheet.up { transform: none; } +/* the drag handles own their touches; the faces underneath still scroll */ +.np-head, .np-art { touch-action: none; } +.sheet.morphing { + transition: opacity var(--spd-effects-default) var(--sp-effects-default); + transform: none; + opacity: 0; +} +.sheet.morphing.up { opacity: 1; } +.sheet .sheet-bg { position: absolute; inset: 0; z-index: 0; background: var(--bg-base); overflow: hidden; } +/* the one screen entirely about the record gets the most of the artwork: + the blow-up runs brighter and the veil is lighter than any browsing page's; + the radial keeps the middle darker so the cover reads as lit, not fogged */ +.sheet .sheet-bg .amb { opacity: calc(var(--ambience) * 1.05); filter: blur(64px) saturate(1.45); transition: none; } +.sheet .sheet-bg .amb-tint { + background: + radial-gradient(125% 72% at 50% 28%, rgba(10, 10, 12, 0.34) 0%, rgba(10, 10, 12, 0.64) 58%, rgba(10, 10, 12, 0.82) 100%), + linear-gradient(180deg, rgba(10, 10, 12, 0.3) 0%, rgba(10, 10, 12, 0.42) 48%, rgba(10, 10, 12, 0.72) 100%); +} + +.np { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + height: 100%; + padding-top: var(--inset-top); /* the OS status bar stays on top of us */ + padding-bottom: calc(var(--inset-bottom) + 6px); +} +.np-head { display: flex; align-items: center; gap: 8px; padding: 4px 6px; flex: none; } +.np-ctx { flex: 1; min-width: 0; text-align: center; border-radius: 10px; padding: 2px 6px; transition: background var(--m-fast); } +.np-ctx:active { background: rgba(255, 255, 255, 0.08); } +.np-ctx .k { font-size: 10.5px; font-weight: 800; letter-spacing: 0.9px; text-transform: uppercase; color: rgba(255, 255, 255, 0.55); } +.np-ctx .v { font-size: 13px; font-weight: 750; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.np-art { flex: none; display: grid; place-items: center; padding: 12px var(--pad) 0; transition: padding var(--m-sheet); } +.np-frame { + width: 100%; + aspect-ratio: 1; + border-radius: 16px; + overflow: hidden; + background: var(--bg-highlight); + box-shadow: 0 34px 60px -22px rgba(0, 0, 0, 0.92), inset 0 0 0 1px rgba(255, 255, 255, 0.08); + transition: width var(--m-sheet), border-radius var(--m-sheet); + display: grid; + place-items: center; + color: var(--text-muted); +} +.np-frame img { width: 100%; height: 100%; object-fit: cover; display: block; } +/* the artwork demotes to a thumbnail when a face takes the room */ +.np-art.small { padding: 6px var(--pad) 2px; justify-items: start; } +.np-art.small .np-frame { width: 62px; border-radius: 10px; } + +/* all the vertical slack goes BELOW the artwork, never above it: the record + hangs from the top and the controls land about a third up, where a thumb is */ +.np-spacer { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: flex-end; gap: 12px; padding: 14px var(--pad) 4px; } +.np-next { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 42px; + padding: 0 12px; + border-radius: var(--r-pill); + background: rgba(255, 255, 255, 0.07); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07); + font-size: 13px; + color: rgba(255, 255, 255, 0.72); + transition: background var(--m-fast); +} +.np-next:active { background: rgba(255, 255, 255, 0.13); } +.np-next span { flex: 1; min-width: 0; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.np-next b { font-weight: 800; color: #fff; margin-right: 4px; } +.np-next i { font-style: normal; color: var(--accent-hover); font-weight: 700; } + +.np-face { + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: none; + -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 22px, #000 calc(100% - 26px), transparent 100%); + mask-image: linear-gradient(180deg, transparent 0, #000 22px, #000 calc(100% - 26px), transparent 100%); +} +.np-face::-webkit-scrollbar { display: none; } + +.np-meta { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 0; flex: none; } +.np-meta .b { min-width: 0; flex: 1; } +.np-t { font-size: 21px; font-weight: 800; letter-spacing: -0.4px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.np-a { font-size: 14.5px; font-weight: 650; color: rgba(255, 255, 255, 0.66); margin-top: 2px; display: block; text-align: left; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.np-a:active { color: var(--accent-hover); } + +/* ── the scrubber: the lane, at player size ─────────────────────────────── + There is no track and no knob: SoulStream's position control has always + been the waveform itself — played bars in the record's accent, the rest in + --text-muted. Geometry and shaping come from src/lib/waveshape.js. */ +.np-scrub { padding: 14px var(--pad) 0; flex: none; } +.np-scrub .times { display: flex; justify-content: space-between; margin-top: 7px; font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums; color: rgba(255, 255, 255, 0.6); } + +/* one per bar, the way the desktop rail draws it (NowPlaying.svelte's + .wave): 2px on a 6px pitch, an inline height, --fill mixing the accent in + for the played part. The bars are updated in place, never rebuilt while a + track plays — a rebuilt node cannot transition. */ +.lane { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; /* BAR_PITCH - BAR_W */ + width: 100%; + overflow: hidden; + touch-action: none; +} +.lane i { + flex: 0 0 2px; + width: 2px; + height: 0; + min-height: 2px; /* the resting dot */ + border-radius: 1px; + background: color-mix(in srgb, var(--accent) calc(var(--fill, 0) * 100%), #7a7a7a); +} +/* LOADING SWELL — NowPlaying.svelte's rail-swell, verbatim: phase-staggered + per bar so one crest travels the lane left→right. 2000ms sweep + 500ms + rest is the 2500ms period in waveshape.js. */ +.lane.loading i { + animation: lane-swell 2500ms linear infinite; + animation-delay: calc(-2500ms + 2000ms * var(--i) / var(--bars, 60)); +} +@keyframes lane-swell { + 0%, 26%, 100% { height: 2px; opacity: 0.45; } + 13% { height: 30%; opacity: 0.85; } +} +/* STREAMING RISE — a released bar stands up over RISE_MS (2000ms) */ +.lane.rising i { transition: height 2000ms cubic-bezier(0.22, 1, 0.36, 1), opacity 400ms linear; } +/* ARRIVAL MORPH — a complete waveform eases in as one motion, ARRIVE_MS */ +.lane.arriving i { transition: height 1000ms cubic-bezier(0.22, 1, 0.36, 1), opacity 400ms linear; } + +/* the transport lands about a third up the screen — reachable one-handed */ +.np-transport { display: flex; align-items: center; justify-content: space-between; padding: 20px calc(var(--pad) + 2px) 0; flex: none; } +.np-transport .disc.lg { width: 68px; height: 68px; background: #fff; color: #101014; box-shadow: 0 10px 26px -8px rgba(0, 0, 0, 0.7); } +.np-transport .disc.lg:active { background: #e6e6e6; } + +.np-foot { display: flex; align-items: center; justify-content: center; padding: 14px var(--pad) 0; flex: none; } +.np-faces { display: flex; gap: 3px; padding: 3px; border-radius: var(--r-pill); background: rgba(0, 0, 0, 0.28); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09); } +.fbtn { display: flex; align-items: center; gap: 5px; height: 32px; padding: 0 12px; border-radius: var(--r-pill); font-size: 12px; font-weight: 750; color: rgba(255, 255, 255, 0.6); transition: background var(--m-fast), color var(--m-fast); } +.fbtn.on { background: rgba(255, 255, 255, 0.15); color: #fff; } +.fbtn span { display: none; } +.fbtn.on span { display: inline; } + +/* ── lyrics ─────────────────────────────────────────────────────────────── + Line highlighting from the app's own lyrics pipeline; a word sweep only + when the source really carries word times — carried, never invented. */ +.lyr { padding: 16px var(--pad) 20px; } +.lyr .ln { display: block; width: 100%; text-align: left; font-size: 22px; font-weight: 800; letter-spacing: -0.4px; line-height: 1.34; padding: 5px 0; transition: color 300ms ease, opacity 300ms ease; color: rgba(255, 255, 255, 0.46); } +.lyr .ln.past { color: rgba(255, 255, 255, 0.34); } +.lyr .ln.now { color: #fff; text-shadow: 0 0 22px color-mix(in srgb, var(--accent) 55%, transparent); } +.lyr .ln.now.words { color: rgba(255, 255, 255, 0.34); text-shadow: none; } +.lyr .ln.now.words .fill { color: #fff; text-shadow: 0 0 22px color-mix(in srgb, var(--accent) 55%, transparent); } +.lyr .ln:active { color: var(--accent-hover); } +.lyr-gap { height: 14px; } +.lyr-plain { white-space: pre-wrap; font-size: 17px; font-weight: 650; line-height: 1.55; color: rgba(255, 255, 255, 0.82); } +.lyr-note { padding: 10px 0 0; font-size: 12px; color: var(--text-muted); font-weight: 650; } + +/* the queue face */ +.q-face { padding-top: 4px; } +.q-break { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--text-muted); + padding: 14px var(--pad) 6px; + display: flex; + align-items: center; + gap: 8px; +} +.q-break::after { content: ""; flex: 1; height: 1px; background: rgba(255, 255, 255, 0.1); } + +/* ── half sheets ──────────────────────────────────────────────────────── */ +.halfsheet { + position: absolute; + left: 0; right: 0; bottom: 0; + z-index: 92; + max-height: 76%; + display: flex; + flex-direction: column; + border-radius: var(--r-sheet) var(--r-sheet) 0 0; + background: rgba(26, 26, 30, 0.97); + box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.1); + transform: translateY(102%); + transition: transform var(--spd-spatial-default) var(--sp-spatial-default); + overflow: hidden; + padding-bottom: var(--inset-bottom); +} +.halfsheet.up { transform: none; } +html[data-glass="blur"] .halfsheet { background: rgba(26, 26, 30, 0.94); backdrop-filter: blur(30px) saturate(1.5); -webkit-backdrop-filter: blur(30px) saturate(1.5); } +html[data-material="solid"] .halfsheet { background: #1c1c20; backdrop-filter: none; -webkit-backdrop-filter: none; } +.halfsheet .grab { width: 38px; height: 4px; border-radius: 3px; background: rgba(255, 255, 255, 0.26); margin: 10px auto 4px; flex: none; } +.halfsheet .hs-body { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none; display: flex; flex-direction: column; } +.halfsheet .hs-body::-webkit-scrollbar { display: none; } +.halfsheet .hs-body > * { flex: none; } +.sheet-title { font-size: 17px; font-weight: 800; letter-spacing: -0.25px; padding: 10px var(--pad) 4px; } +.sheet-sec { font-size: 11px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); padding: 12px var(--pad) 8px; } +.sheet-head { display: flex; align-items: center; gap: 12px; padding: 8px var(--pad) 14px; margin-bottom: 4px; box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08); } +.sheet-head .art { width: 52px; height: 52px; border-radius: 8px; overflow: hidden; flex: none; background: var(--bg-highlight); display: grid; place-items: center; color: var(--text-muted); } +.sheet-head .art img { width: 100%; height: 100%; object-fit: cover; } +.sheet-head .b { min-width: 0; } +.sheet-head .t { display: block; font-size: 15px; font-weight: 750; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sheet-head .m { display: block; font-size: 12.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; } +.sheet-rows { padding-bottom: 6px; } +.sheet-hair { height: 1px; background: rgba(255, 255, 255, 0.08); margin: 6px var(--pad); } +.sheet-info { margin: 4px var(--pad) 12px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); } +.scrim { + position: absolute; + inset: 0; + z-index: 91; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + pointer-events: none; + transition: opacity var(--spd-effects-default) var(--sp-effects-default); +} +.scrim.on { opacity: 1; pointer-events: auto; } + +/* ── the toast ─────────────────────────────────────────────────────────── */ +.toast { + position: absolute; left: 12px; right: 12px; + bottom: calc(var(--nav-h) + var(--mini-h) + var(--inset-bottom) + 20px); + z-index: 95; + padding: 10px 14px; border-radius: 12px; + background: #3a0f14; color: #ffb4b4; + font-size: 12.5px; line-height: 1.4; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); +} + +/* ── the network: sign-in, rooms, chat ─────────────────────────────────── */ +.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #3ddc84; flex: none; } +.dot.off { background: rgba(255, 255, 255, 0.25); } +.dot.warn { background: #f5a524; } +.online-line { display: flex; align-items: center; gap: 7px; margin-top: 6px; } +.online-line b { color: var(--text-secondary); } +.signin { margin: 8px var(--pad) 0; padding: 16px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); display: flex; flex-direction: column; gap: 10px; } +.signin-t { font-size: 17px; font-weight: 800; letter-spacing: -0.25px; } +.signin-m { font-size: 12.5px; line-height: 1.5; color: var(--text-secondary); margin-top: -4px; } +.signin-err { font-size: 12.5px; color: #ffb4b4; line-height: 1.4; } +.field-row { display: flex; align-items: center; gap: 6px; } +.field-row .field { flex: 1; min-width: 0; } +/* the desktop's text field, at thumb size: a pill of the surface ladder */ +.field { + width: 100%; + height: 44px; + padding: 0 14px; + border-radius: var(--r-pill); + background: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); + border: 0; + outline: 0; + font-size: 15px; + font-weight: 600; + color: #fff; + -webkit-appearance: none; + appearance: none; +} +.field::placeholder { color: var(--text-muted); font-weight: 600; } +.field:focus { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent) 70%, transparent); } +.btn:disabled { opacity: 0.45; } +.disc:disabled { opacity: 0.4; } +.segtabs .count, .unread { + display: inline-block; + min-width: 18px; + padding: 1px 6px; + border-radius: 999px; + background: var(--accent); + color: #04121a; + font-size: 11px; + font-weight: 800; + text-align: center; + vertical-align: 1px; +} +.row .art.hash { color: var(--accent-hover); } +.row .art.idc { background: none; box-shadow: none; } +.row .r .n { font-size: 12.5px; font-weight: 700; color: var(--text-secondary); } +.room-head { display: flex; align-items: center; gap: 10px; padding: 6px var(--pad) 8px; } +.room-head .b { min-width: 0; flex: 1; } +.room-head .art { width: 40px; height: 40px; border-radius: 50%; overflow: hidden; flex: none; } + +/* the conversation shape: theirs left on the surface, ours right in the + accent; a room and a thread draw with the same classes */ +.chat { display: flex; flex-direction: column; gap: 3px; padding: 6px var(--pad) 0; } +.msg { max-width: 84%; align-self: flex-start; } +.msg.me { align-self: flex-end; } +.msg .who { display: block; font-size: 11.5px; font-weight: 750; color: var(--text-muted); padding: 0 4px 3px; text-align: left; } +.msg .who:active { color: var(--accent-hover); } +.msg .bub { + padding: 9px 13px; + border-radius: 16px 16px 16px 5px; + background: var(--surface); + box-shadow: var(--ring); + font-size: 14px; + line-height: 1.45; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.msg.me .bub { + border-radius: 16px 16px 5px 16px; + background: color-mix(in srgb, var(--accent) 24%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 32%, transparent); +} +.msg + .msg { margin-top: 9px; } +.msg .at { font-size: 10.5px; color: var(--text-muted); padding: 3px 5px 0; font-weight: 650; } +.msg.me .at { text-align: right; } + +/* the composer, pinned above the mini bar and the nav — a chat input that + scrolls away with the messages is not a chat input */ +.composer { + display: flex; + align-items: center; + gap: 9px; + padding: 8px var(--pad) 10px; + position: sticky; + bottom: calc(var(--nav-h) + var(--inset-bottom) + 6px); + background: linear-gradient(180deg, transparent, rgba(12, 12, 14, 0.78) 34%); + z-index: 3; +} +.has-mini .composer { bottom: calc(var(--mini-h) + var(--nav-h) + var(--inset-bottom) + 14px); } +.composer .field { height: 42px; } + +/* ── the artist page: the desktop's identity row and extras ─────────────── + The wordmark (or the name) on the banner; under it Play popular beside the + stat chips — Follow leads the row; Popular's listener bars; the latest + release on the app's featured surface (the artwork blurred behind itself); + releases by year; About. */ +.hero-logo { display: block; max-width: 78%; max-height: 96px; object-fit: contain; object-position: left bottom; filter: drop-shadow(0 2px 12px rgba(0, 0, 0, 0.6)); } +.hero-acts { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 0; } +.hero-acts .stats { flex: 1; min-width: 0; } +.stats button.s { cursor: pointer; } +.stats .s.fb { background: rgba(255, 255, 255, 0.11); color: var(--text); } +.stats .s.fb.on { background: var(--accent); color: #04121a; } +.stats .s.fb:disabled { opacity: 0.5; } +.stats .s.quiet { color: var(--text-secondary); } +.row.pop .rank { width: 16px; text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: none; } +.lbar { display: flex; align-items: center; gap: 8px; margin-top: 5px; height: 10px; } +.lbar i { display: block; flex: 1; height: 3px; border-radius: 2px; background: var(--accent); transform-origin: left center; opacity: 0.85; } +.lbar em { font-style: normal; font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; } +.more-link { display: block; padding: 8px var(--pad) 2px; font-size: 12.5px; font-weight: 750; color: var(--accent-hover); } +.feat { position: relative; border-radius: var(--r-card); overflow: hidden; background: var(--surface); box-shadow: var(--ring); } +.feat-bg { position: absolute; inset: -30px; background-size: cover; background-position: center; filter: blur(28px) saturate(1.2); opacity: 0.55; } +.feat::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(10, 10, 12, 0.55), rgba(10, 10, 12, 0.25)); } +.feat-in { position: relative; z-index: 1; display: flex; gap: 14px; padding: 14px; align-items: center; } +.feat-art { width: 96px; height: 96px; border-radius: 10px; overflow: hidden; flex: none; background: rgba(255, 255, 255, 0.06); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); } +.feat-art img { width: 100%; height: 100%; object-fit: cover; display: block; } +.feat-art .ph { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; color: var(--text-muted); } +.feat-copy { min-width: 0; flex: 1; } +.feat-copy .k, .top-text .k { font-size: 10.5px; letter-spacing: 0.8px; text-transform: uppercase; font-weight: 800; color: var(--text-muted); } +.feat-name { font-size: 17px; font-weight: 800; letter-spacing: -0.3px; line-height: 1.15; margin: 3px 0 2px; } +.feat-copy .m, .top-text .m { font-size: 12px; color: var(--text-secondary); font-weight: 600; } +.feat-acts, .top-acts { display: flex; gap: 8px; margin-top: 10px; } +.about { padding: 0 var(--pad); } +.about .stats { margin-bottom: 10px; } +.bio { font-size: 13.5px; line-height: 1.5; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; margin: 0; } +.bio.open { display: block; } +.about .tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.tl { display: flex; align-items: flex-end; gap: 3px; padding: 6px var(--pad) 0; height: 66px; overflow-x: auto; scrollbar-width: none; } +.tl::-webkit-scrollbar { display: none; } +.tl-col { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1 0 14px; min-width: 14px; } +.tl-bar { display: block; width: 100%; border-radius: 2px 2px 0 0; background: var(--accent); } +.tl-col.empty .tl-bar { background: rgba(255, 255, 255, 0.12); } +.tl-yr { font-size: 9px; color: var(--text-muted); font-variant-numeric: tabular-nums; } + +/* ── the catalog record page: the desktop's album page ──────────────────── + The kind above the title, a portrait in the byline, Last.fm's totals as + chips, the other editions in a rail, listener bars in the rows, the words + below the tracks with the lede promoted, and who said so. */ +.rec-meta .kind { font-size: 10.5px; letter-spacing: 0.9px; text-transform: uppercase; font-weight: 800; color: var(--text-muted); margin-bottom: 2px; } +.rec-artist.who { display: inline-flex; align-items: center; gap: 7px; } +.rec-artist.who img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; } +.rec-acts .btn.on { background: var(--accent); color: #04121a; } +.eds { padding-bottom: 4px; } +.ed { display: flex; align-items: center; gap: 10px; width: 210px; padding: 8px 10px 8px 8px; border-radius: 12px; background: var(--surface); box-shadow: var(--ring); text-align: left; } +.ed.on { box-shadow: inset 0 0 0 1.5px var(--accent); } +.ed-art { width: 40px; height: 40px; border-radius: 6px; overflow: hidden; flex: none; background: rgba(255, 255, 255, 0.06); display: flex; align-items: center; justify-content: center; color: var(--text-muted); } +.ed-art img { width: 100%; height: 100%; object-fit: cover; display: block; } +.ed-copy { min-width: 0; } +.ed-copy .n { display: block; font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ed-copy .y { display: block; font-size: 11.5px; color: var(--text-muted); font-weight: 600; margin-top: 1px; } +.trk.locked { opacity: 0.5; } +.trk .tick { display: inline-flex; margin-left: 6px; color: var(--accent-hover); vertical-align: -1px; } +.trk .lbar { max-width: 220px; } +.lede { font-size: 14.5px; line-height: 1.5; font-weight: 650; color: var(--text); margin: 0 0 8px; } +.srcs { font-size: 11.5px; color: var(--text-muted); line-height: 1.5; margin: 10px 0 0; } +.srcs b { color: var(--text-secondary); font-weight: 750; } + +/* ── Gaps: the desktop's gap line under a followed shelf, its missing + records dimmed until touched ─────────────────────────────────────────── */ +.gapline { display: flex; align-items: center; gap: 6px; width: 100%; padding: 2px var(--pad) 10px calc(var(--pad) + 56px); font-size: 12.5px; color: var(--text-muted); font-weight: 600; text-align: left; } +.gapline b { color: var(--text-secondary); font-weight: 800; } +.gapline .cta { margin-left: auto; display: inline-flex; align-items: center; gap: 3px; color: var(--accent-hover); font-weight: 750; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.5px; } +.gapline.done { color: var(--text-muted); } +.rail.gaps { padding-bottom: 6px; } +.rail.gaps .release { opacity: 0.44; transition: opacity 150ms ease; } +.rail.gaps .release:active { opacity: 1; } + +/* ── the search hero: the desktop's top result ──────────────────────────── + The most popular exact match of its kind — an artist, a record or a song + — on the featured surface, its artwork blurred behind itself. */ +.top { position: relative; margin: 4px var(--pad) 10px; border-radius: var(--r-card); overflow: hidden; background: var(--surface); box-shadow: var(--ring); } +.top-bg { position: absolute; inset: -30px; background-size: cover; background-position: center; filter: blur(30px) saturate(1.2); opacity: 0.5; } +.top::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(10, 10, 12, 0.5), rgba(10, 10, 12, 0.2)); } +.top-in { position: relative; z-index: 1; display: flex; gap: 14px; padding: 14px; align-items: center; } +.top-art { width: 104px; height: 104px; border-radius: 12px; overflow: hidden; flex: none; background: rgba(255, 255, 255, 0.06); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); } +.top.round .top-art { border-radius: 50%; } +.top-art img { width: 100%; height: 100%; object-fit: cover; display: block; } +.top-art .ph { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; color: var(--text-muted); } +.top-text { min-width: 0; flex: 1; } +.top-name { font-size: 20px; font-weight: 800; letter-spacing: -0.4px; line-height: 1.1; margin: 3px 0 3px; } +.top-who { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 700; color: var(--text); vertical-align: middle; } +.top-who img { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; } +.top-text .tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; } + +/* ── catalog results ────────────────────────────────────────────────────── + The desktop's top bar: rows for artists and songs, a rail for records. A + song row is a play button — the resolver's line replaces its duration + while the ladder runs. */ +.row.cat-song .r, .row.cat-artist .r { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; } +.row.cat-song.playing .t { color: var(--accent-hover); } +.net-ask { gap: 8px; } + +/* ── Soulseek results ───────────────────────────────────────────────────── + Grouped by PEER, because on this network the choice is whose copy to take, + not which file. The folder is the unit you act on; the tracks under it + confirm it is the right rip — and a file row plays, streamed from that + peer, the lane telling the transfer's story. */ +.peer { margin: 0 var(--pad) 12px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); overflow: hidden; } +html[data-material="solid"] .peer { background: var(--bg-elevated); box-shadow: none; } +.peer-head { display: flex; align-items: center; gap: 10px; padding: 10px 12px 8px; } +.peer-who { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1; } +.peer-who > span { min-width: 0; } +.peer-who b { display: block; font-size: 13.5px; font-weight: 750; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.peer-who i { display: block; font-style: normal; font-size: 11.5px; color: var(--text-muted); font-weight: 600; margin-top: 1px; } +.peer-folder { + display: grid; + grid-template-columns: 1fr auto; + gap: 2px 10px; + align-items: center; + width: 100%; + padding: 8px 12px 10px; + text-align: left; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + transition: background var(--m-fast); +} +.peer-folder:active { background: var(--hover); } +.peer-path { font-size: 13px; font-weight: 650; line-height: 1.35; word-break: break-word; } +.peer-meta { font-size: 11.5px; color: var(--text-muted); font-weight: 600; grid-column: 1; } +.peer-get { + grid-row: 1 / span 2; + grid-column: 2; + display: inline-flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 12px; + border-radius: var(--r-pill); + background: var(--accent); + color: #04121a; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} +.peer-files { padding: 2px 12px 8px; } +.peer-file { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 34px; + padding: 4px 6px; + margin: 0 -6px; + border-radius: 8px; + font-size: 12.5px; + color: var(--text-secondary); + text-align: left; + transition: background var(--m-fast); +} +.peer-file:active { background: var(--press); } +.peer-file.playing .pf-t { color: var(--accent-hover); } +.peer-file > span:first-child { width: 16px; text-align: right; color: var(--text-muted); font-variant-numeric: tabular-nums; font-size: 11.5px; flex: none; } +.pf-t { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pf-m { color: var(--text-muted); font-size: 11.5px; font-variant-numeric: tabular-nums; flex: none; } +.peer-more { display: block; font-size: 11.5px; color: var(--text-muted); font-weight: 650; padding: 6px 0 2px 26px; text-align: left; } +.peer-more:active { color: var(--accent-hover); } + +/* inline icons inside a line of text (Icon.svelte is display:block) */ +.inl { display: inline-flex; vertical-align: -3px; } +.t-sub { font-size: 13px; font-weight: 500; color: var(--text-muted); } + +/* the search field — a pill, the desktop's search box at thumb size */ +.sfield { + display: flex; + align-items: center; + gap: 10px; + margin: 4px var(--pad) 8px; + height: 46px; + padding: 0 6px 0 14px; + border-radius: var(--r-pill); + background: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); + color: var(--text-secondary); +} +.sfield input { + flex: 1; + min-width: 0; + height: 100%; + background: none; + border: 0; + outline: 0; + font-size: 15px; + font-weight: 600; + color: #fff; + -webkit-appearance: none; + appearance: none; +} +.sfield input::placeholder { color: var(--text-muted); font-weight: 600; } +.sfield input::-webkit-search-cancel-button { display: none; } +.trk .n .icon { display: inline-block; color: var(--text-muted); } +.countline { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--pad) 10px; } +/* a hero bleeds up under the status bar; the scrim keeps the chrome legible */ +.screen.has-hero { padding-top: 0; } +.mini .mini-lane { width: auto; } + +/* ── screen transitions ─────────────────────────────────────────────────── + Material's shared-axis-X for a push and its reverse for a pop, so the + direction of travel is legible; a fade-through for a tab change, because + tabs are siblings. The outgoing screen is replaced outright, so the + incoming one carries the whole gesture. */ +@keyframes scr-push { from { opacity: 0; transform: translate3d(26px, 0, 0); } to { opacity: 1; transform: none; } } +@keyframes scr-pop { from { opacity: 0; transform: translate3d(-26px, 0, 0); } to { opacity: 1; transform: none; } } +@keyframes scr-fade { from { opacity: 0; transform: scale(0.972); } to { opacity: 1; transform: none; } } +.screen.tr-push { animation: scr-push var(--spd-spatial-default) var(--sp-spatial-default) both; } +.screen.tr-pop { animation: scr-pop var(--spd-spatial-default) var(--sp-spatial-default) both; } +.screen.tr-tab { animation: scr-fade var(--spd-effects-default) var(--sp-effects-default) both; } + +/* a page does not arrive all at once; its first eight blocks land in order */ +@keyframes blk-rise { from { opacity: 0; transform: translate3d(0, 10px, 0); } to { opacity: 1; transform: none; } } +.screen.stagger > * { animation: blk-rise var(--spd-spatial-default) var(--sp-spatial-default) both; } +.screen.stagger > *:nth-child(1) { animation-delay: 0ms; } +.screen.stagger > *:nth-child(2) { animation-delay: 26ms; } +.screen.stagger > *:nth-child(3) { animation-delay: 52ms; } +.screen.stagger > *:nth-child(4) { animation-delay: 76ms; } +.screen.stagger > *:nth-child(5) { animation-delay: 98ms; } +.screen.stagger > *:nth-child(6) { animation-delay: 116ms; } +.screen.stagger > *:nth-child(7) { animation-delay: 130ms; } +.screen.stagger > *:nth-child(8) { animation-delay: 140ms; } +.screen.stagger > *:nth-child(n + 9) { animation: none; } + +/* ── REDUCE MOTION: not faster — off ──────────────────────────────────── */ +html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="off"] *::after { + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + transition-duration: 0.01ms !important; +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/mobile/slsk.svelte.js b/src/mobile/slsk.svelte.js new file mode 100644 index 0000000..9c78542 --- /dev/null +++ b/src/mobile/slsk.svelte.js @@ -0,0 +1,177 @@ +/* The Soulseek hub on the phone: the connection, the rooms, the chats, the + buddies. The desktop's `slsk` store (state.svelte.js), the same events, + the same rules — the server echoes a room line back so it is not appended + locally; a direct message is not echoed so our half is. The private layer + (Freenet: Spaces, holdings, the sealed inbox) is not on the phone yet. */ +import * as api from "../lib/api.js"; +import { listen } from "../lib/events.js"; + +export const slsk = $state({ + connection: "offline", // offline | connecting | online + username: "", + loginError: "", + /** the saved username, for the sign-in card */ + savedUser: "", + hasPassword: false, + /** [{ name, users }] — the server's public list */ + rooms: [], + /** room → { users: [{ username, … }], messages: [{ username, message, ts }] } */ + joined: {}, + /** username → { messages: [{ me, text, ts }], unread } */ + chats: {}, + buddies: [], + buddyState: {}, + /** the room or the person whose screen is open, so its unread stays 0 */ + openChat: null, +}); + +const FEED_MAX = 300; +function trim(arr) { + if (arr.length > FEED_MAX) arr.splice(0, arr.length - FEED_MAX); +} + +export async function loadCredentials() { + try { + const c = await api.getCredentials(); + slsk.savedUser = c?.username || ""; + slsk.hasPassword = !!c?.has_password; + } catch { /* fine */ } +} + +/** Sign in. The phone always remembers: there is no credential manager to + * ask, only the app's own sealed data directory. */ +export async function connect(username, password) { + slsk.loginError = ""; + slsk.connection = "connecting"; + try { + await api.connectAndLogin(username.trim(), password, true); + slsk.savedUser = username.trim(); + slsk.hasPassword = true; + } catch (e) { + slsk.connection = "offline"; + slsk.loginError = String(e).replace(/^Error:\s*/, ""); + } +} + +/** At launch: the saved account signs in on its own, quietly. */ +export async function connectStored() { + await loadCredentials(); + if (!slsk.savedUser || !slsk.hasPassword) return; + slsk.connection = "connecting"; + try { + await api.connectStored(); + } catch (e) { + slsk.connection = "offline"; + slsk.loginError = String(e).replace(/^Error:\s*/, ""); + } +} + +export async function disconnect() { + try { await api.disconnect(); } catch { /* fine */ } + slsk.connection = "offline"; + slsk.joined = {}; +} + +export async function forgetAccount() { + try { await api.forgetCredentials(); } catch { /* fine */ } + slsk.savedUser = ""; + slsk.hasPassword = false; +} + +export function refreshRooms() { + api.slskRoomList().catch(() => {}); +} +export async function joinRoom(name) { + await api.slskJoinRoom(name); + /* the roster arrives as soulseek:room-joined; nothing optimistic here */ +} +export async function leaveRoom(name) { + await api.slskLeaveRoom(name); + delete slsk.joined[name]; +} +/** The server echoes our line back to everyone, us included — send only. */ +export async function sayRoom(room, text) { + const t = text.trim(); + if (t) await api.slskSayRoom(room, t); +} +export function openThread(username) { + slsk.chats[username] ??= { messages: [], unread: 0 }; + slsk.chats[username].unread = 0; +} +/** A direct message is not echoed back; our half is appended here. */ +export async function sendDm(username, text) { + const t = text.trim(); + if (!t) return; + await api.slskMessageUser(username, t); + openThread(username); + slsk.chats[username].messages.push({ me: true, text: t, ts: Date.now() }); + trim(slsk.chats[username].messages); +} +export async function loadBuddies() { + try { slsk.buddies = (await api.buddyList()) || []; } catch { /* offline */ } +} +export async function addBuddy(username) { + slsk.buddies = (await api.buddyAdd(username)) || slsk.buddies; +} +export async function removeBuddy(username) { + slsk.buddies = (await api.buddyRemove(username)) || slsk.buddies; + delete slsk.buddyState[username]; +} + +export function unreadTotal() { + return Object.values(slsk.chats).reduce((n, c) => n + (c.unread || 0), 0); +} + +let bound = false; +/** Subscribe to the hub's events. Returns the unlisteners. */ +export async function bindSlsk() { + if (bound) return []; + bound = true; + const subs = []; + const on = async (name, fn) => { try { subs.push(await listen(name, (ev) => fn(ev.payload))); } catch { /* not a phone */ } }; + await on("soulseek:state", (ev) => { + if (ev?.kind === "connected") { + slsk.connection = "online"; + slsk.username = ev.username || slsk.savedUser; + slsk.loginError = ""; + void loadBuddies(); + refreshRooms(); + } else if (ev?.kind === "disconnected") { + slsk.connection = "offline"; + } + }); + await on("soulseek:room-list", (rooms) => { + slsk.rooms = (rooms || []).slice().sort((a, b) => (b.users || 0) - (a.users || 0)); + }); + await on("soulseek:room-joined", (p) => { + const prev = slsk.joined[p.room]; + slsk.joined[p.room] = { users: p.users || [], messages: prev?.messages ?? [] }; + }); + await on("soulseek:room-left", (p) => { delete slsk.joined[p.room]; }); + await on("soulseek:room-user", (p) => { + const room = slsk.joined[p.room]; + if (!room) return; + if (p.joined) { + if (!room.users.some((u) => u.username === p.user?.username)) room.users.push(p.user); + } else { + room.users = room.users.filter((u) => u.username !== p.username); + } + }); + await on("soulseek:room-message", (p) => { + const room = slsk.joined[p.room]; + if (!room) return; + room.messages.push({ username: p.username, message: p.message, ts: p.ts ? p.ts * 1000 : Date.now() }); + trim(room.messages); + }); + await on("soulseek:private-message", (p) => { + const chat = (slsk.chats[p.username] ??= { messages: [], unread: 0 }); + chat.messages.push({ me: false, text: p.message, ts: (p.timestamp || 0) * 1000 || Date.now() }); + trim(chat.messages); + if (slsk.openChat !== p.username) chat.unread += 1; + }); + await on("soulseek:user-state", (p) => { + const s = (slsk.buddyState[p.username] ??= {}); + Object.assign(s, p); + }); + return subs; +} diff --git a/src/mobile/transfers.svelte.js b/src/mobile/transfers.svelte.js new file mode 100644 index 0000000..6165c32 --- /dev/null +++ b/src/mobile/transfers.svelte.js @@ -0,0 +1,61 @@ +/* Transfers on the phone: the downloads the backend is running — every + Save lands here — fed by soulseek:download exactly as the desktop's + `downloads` store is (latest update wins, a terminal update without + numbers keeps the last known progress), pruned once a burst drains. */ +import * as api from "../lib/api.js"; +import { listen } from "../lib/events.js"; + +export const transfers = $state({ + /** id → payload { id, username, path, name, state, queue_place, local_path, error, received, total } */ + items: {}, +}); + +export const DONE = new Set(["complete", "failed", "cancelled"]); +const HISTORY_MAX = 60; + +export function list() { + return Object.values(transfers.items).sort((a, b) => b.id - a.id); +} +export function active() { + return Object.values(transfers.items).filter((d) => !DONE.has(d.state)); +} +export function clearFinished() { + for (const d of Object.values(transfers.items)) if (DONE.has(d.state)) delete transfers.items[d.id]; +} +export async function cancel(id) { + try { await api.cancelDownload(id); } catch { /* already gone */ } +} + +/** "queued · 3rd in line", "transferring · 42%", "saved", "failed · reason" */ +export function stateLine(d) { + switch (d.state) { + case "connecting": return "connecting…"; + case "requested": return "asked…"; + case "queued": return d.queue_place ? `queued · ${d.queue_place} ahead` : "queued"; + case "transferring": return d.total ? `${Math.round((d.received / d.total) * 100)}%` : "transferring…"; + case "complete": return "saved"; + case "failed": return d.error ? `failed · ${d.error}` : "failed"; + case "cancelled": return "cancelled"; + default: return d.state || ""; + } +} + +let bound = false; +export async function bindTransfers() { + if (bound) return; + bound = true; + try { + await listen("soulseek:download", (ev) => { + const d = ev.payload; + if (!d || d.id == null) return; + const prev = transfers.items[d.id]; + if (prev && !d.total) { d.received = prev.received; d.total = prev.total; } + transfers.items[d.id] = d; + if (DONE.has(d.state)) { + /* keep an active burst whole; prune the oldest finished past the cap */ + const done = Object.values(transfers.items).filter((x) => DONE.has(x.state)).sort((a, b) => a.id - b.id); + for (const x of done.slice(0, Math.max(0, done.length - HISTORY_MAX))) delete transfers.items[x.id]; + } + }); + } catch { /* not a phone */ } +} diff --git a/src/mobile/util.js b/src/mobile/util.js new file mode 100644 index 0000000..4834233 --- /dev/null +++ b/src/mobile/util.js @@ -0,0 +1,177 @@ +/* Small helpers the phone's screens share. Nothing here touches the IPC. */ + +export function mmss(secs) { + secs = Math.max(0, Math.round(secs || 0)); + const m = Math.floor(secs / 60); + const s = secs % 60; + return m + ":" + (s < 10 ? "0" : "") + s; +} + +export function longDur(secs) { + secs = Math.max(0, Math.round(secs || 0)); + const h = Math.floor(secs / 3600); + const m = Math.round((secs % 3600) / 60); + if (h) return h + " hr " + m + " min"; + return m + " min"; +} + +/** A relative day, against the wall clock. `ts` is unix seconds. */ +export function ago(ts) { + const d = Math.floor((Date.now() / 1000 - ts) / 86400); + if (d <= 0) return "today"; + if (d === 1) return "yesterday"; + if (d < 7) return d + " days ago"; + if (d < 14) return "last week"; + if (d < 60) return Math.round(d / 7) + " weeks ago"; + if (d < 400) return Math.round(d / 30) + " months ago"; + return Math.round(d / 365) + " years ago"; +} + +/** "3:07 PM" for a timestamp in milliseconds. */ +export function clock(ms) { + const d = new Date(ms || Date.now()); + let h = d.getHours(); + const m = d.getMinutes(); + const ap = h >= 12 ? "PM" : "AM"; + h = h % 12 || 12; + return h + ":" + (m < 10 ? "0" : "") + m + " " + ap; +} + +/** "03 - Wham City.flac" → "Wham City": the track number and the extension + * go, the rest is the title as the peer named it. */ +export function titleFromName(name) { + let t = String(name || "").replace(/\.[^.]+$/, ""); + t = t.replace(/^\s*\d{1,3}\s*[-._)\]]\s*/, "").replace(/^\s*\d{1,3}\s+/, ""); + return t.trim() || String(name || ""); +} +/** What a shared folder's name says about the record, by the common + * shapes: "Artist - 2007 - Album", "2007 - Album" under "Artist", "Artist - Album". */ +export function folderMeta(folder) { + const parts = String(folder || "").split(/[\\/]/).filter(Boolean); + const last = (parts[parts.length - 1] || "").replace(/\s*[[(](flac|mp3|320|v0|web|cd|vinyl|lossless|24[^\])]*|16[^\])]*)[\])]\s*/gi, " ").trim(); + const prev = parts[parts.length - 2] || ""; + let m = last.match(/^(.+?)\s+-\s+\(?(\d{4})\)?\s+-\s+(.+)$/); + if (m) return { artist: m[1].trim(), year: m[2], album: m[3].trim() }; + m = last.match(/^\(?(\d{4})\)?\s+-\s+(.+)$/); + if (m) return { artist: prev, year: m[1], album: m[2].trim() }; + m = last.match(/^(.+?)\s+-\s+(.+?)(?:\s+\((\d{4})\))?$/); + if (m) return { artist: m[1].trim(), year: m[3] || "", album: m[2].trim() }; + return { artist: prev, year: "", album: last }; +} + +export function fmtBytes(n) { + if (!n) return ""; + if (n >= 1073741824) return (n / 1073741824).toFixed(1) + " GB"; + if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB"; + return Math.round(n / 1024) + " kB"; +} + +export function basename(p) { + if (!p) return ""; + const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + return i >= 0 ? p.slice(i + 1) : p; +} +export function dirname(p) { + if (!p) return ""; + const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + return i >= 0 ? p.slice(0, i) : ""; +} + +const LOSSLESS = new Set(["flac", "wav", "aif", "aiff", "ape", "wv", "alac", "dsf", "dff"]); + +/** "FLAC 16/44" for a lossless file, "MP3 320" for a lossy one — the + * desktop's format tag, read off the library's own track row. */ +export function fmtOf(t) { + if (!t) return ""; + const ext = String(t.path || "").split(".").pop().toLowerCase(); + const name = ext ? ext.toUpperCase() : ""; + if (LOSSLESS.has(ext)) { + const bits = t.bit_depth || 16; + const khz = t.sample_rate ? Math.round(t.sample_rate / 1000) : 44; + return name + " " + bits + "/" + khz; + } + if (t.bitrate_kbps) return name + " " + t.bitrate_kbps; + return name; +} +export function isLossless(fmt) { + return /^(FLAC|WAV|AIFF?|APE|WV|ALAC|DSF|DFF)\b/.test(fmt || ""); +} +/** The format most of an album's tracks share. */ +export function albumFmt(tracks) { + const count = new Map(); + for (const t of tracks || []) { + const f = fmtOf(t); + if (f) count.set(f, (count.get(f) || 0) + 1); + } + let best = "", n = 0; + for (const [f, c] of count) if (c > n) { best = f; n = c; } + return best; +} + +export function greeting() { + const h = new Date().getHours(); + return h < 5 ? "Still up" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening"; +} +export function dateKick() { + return new Date().toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); +} + +export function shuffled(list) { + const a = list.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +/** A pointer drag with thresholds, for the mini bar and the player sheet. + * `from(target, event, box)` says whether a press starts a drag at all; + * `move(dx, dy, el, e)` and `end(dx, dy, el, e)` see the deltas. A Svelte + * action: use:drag={opts}. */ +export function drag(el, opts) { + /* armed on pointerdown, DRAGGING only once the pointer has really moved: + capturing the pointer at once would retarget the pointerup, and a tap + on a button inside the element would never become that button's click */ + const SLOP = 6; + let armed = false, on = false, x0 = 0, y0 = 0, o = opts; + const down = (e) => { + if (!o || e.button > 0) return; + const box = el.getBoundingClientRect(); + if (o.from && !o.from(e.target, e, box)) return; + armed = true; on = false; x0 = e.clientX; y0 = e.clientY; + }; + const move = (e) => { + if (!armed) return; + const dx = e.clientX - x0, dy = e.clientY - y0; + if (!on) { + if (Math.abs(dx) < SLOP && Math.abs(dy) < SLOP) return; + on = true; + try { el.setPointerCapture(e.pointerId); } catch { /* not capturable */ } + el.style.transition = "none"; + if (o.start) o.start(el, e); + } + if (o.move) o.move(dx, dy, el, e); + }; + const finish = (e) => { + if (!armed) return; + armed = false; + if (!on) return; + on = false; + el.style.transition = ""; + if (o.end) o.end(e.clientX - x0, e.clientY - y0, el, e); + }; + el.addEventListener("pointerdown", down); + el.addEventListener("pointermove", move); + el.addEventListener("pointerup", finish); + el.addEventListener("pointercancel", finish); + return { + update(next) { o = next; }, + destroy() { + el.removeEventListener("pointerdown", down); + el.removeEventListener("pointermove", move); + el.removeEventListener("pointerup", finish); + el.removeEventListener("pointercancel", finish); + }, + }; +} diff --git a/tests/mobile/cdp.mjs b/tests/mobile/cdp.mjs new file mode 100644 index 0000000..36962c2 --- /dev/null +++ b/tests/mobile/cdp.mjs @@ -0,0 +1,101 @@ +/* The DevTools protocol, for the phone walk and the perf probe. + + Two things live here: a client small enough to read (a WebSocket, ids, + a map of waits), and the phone attach — a debug build's WebView exposes + DevTools on `localabstract:webview_devtools_remote_`, which + `adb forward` puts on a local port. The attach refuses to run when + another app has the phone's screen: the harness never takes the phone + from whoever is holding it. */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { MiniWS } from "./miniws.mjs"; + +/* Node 22 has a global WebSocket; Node 19 does not */ +const WS = typeof WebSocket !== "undefined" ? WebSocket : MiniWS; + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export const ADB = [ + path.join(process.env.LOCALAPPDATA || "", "Android/Sdk/platform-tools/adb.exe"), + path.join(process.env.ANDROID_HOME || "", "platform-tools/adb.exe"), + "adb", +].find((p) => p === "adb" || fs.existsSync(p)); + +export function adb(...args) { + const r = spawnSync(ADB, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0) throw new Error("adb " + args.join(" ") + ": " + (r.stderr || "").toString()); + return r.stdout; +} + +export class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.on = {}; } + static async attach(port, { phone = false } = {}) { + for (let i = 0; i < 200; i++) { + try { + /* a backgrounded WebView accepts the TCP connection and never + answers — without a timeout this loop would hang for good */ + const list = await (await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2500) })).json(); + const page = list.find((t) => t.type === "page"); + if (page) { + const ws = new WS(page.webSocketDebuggerUrl); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new CDP(ws); + ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); } + else if (m.method && c.on[m.method]) c.on[m.method](m.params); + }; + return c; + } + } catch { /* not up yet */ } + await sleep(200); + } + throw new Error("could not attach to the page on port " + port + + (phone ? " (a WebView in the background does not answer; is SoulStream on screen?)" : "")); + } + send(method, params = {}) { + const id = ++this.id; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((res) => this.waits.set(id, res)); + } + async evaluate(expr) { + const r = await this.send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.result && r.result.exceptionDetails) { + throw new Error("page threw: " + (r.result.exceptionDetails.exception?.description || JSON.stringify(r.result.exceptionDetails))); + } + return r.result?.result?.value; + } + close() { try { this.ws.close(); } catch { /* fine */ } } +} + +/** What is on the phone's screen, from the window manager. */ +export function phoneFocus() { + return (adb("shell", "dumpsys", "window").toString().match(/mCurrentFocus=[^\n]*/) || [""])[0]; +} + +/** Attach to SoulStream's WebView on the connected phone. Launches the app + * only from the launcher; refuses when another app is in front. */ +export async function attachPhone(port) { + const focus = phoneFocus(); + if (!/com\.soulstream\.app/.test(focus)) { + if (/launcher/i.test(focus)) { + adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity"); + await sleep(2500); + } else { + const who = (focus.match(/\s([\w.]+)\/[^}]*\}/) || [])[1] || "another app"; + throw new Error("the phone is in use (" + who + " is on screen); not touching it"); + } + } + const pid = adb("shell", "pidof", "com.soulstream.app").toString().trim(); + if (!pid) throw new Error("SoulStream is not running on the phone (adb shell pidof found nothing)"); + adb("forward", "--remove-all"); + adb("forward", `tcp:${port}`, `localabstract:webview_devtools_remote_${pid}`); + const cdp = await CDP.attach(port, { phone: true }); + return { cdp, pid }; +} + +/** The phone's own frame, status bar included. */ +export function phoneStill(file) { + fs.writeFileSync(file, adb("exec-out", "screencap", "-p")); +} diff --git a/tests/mobile/miniws.mjs b/tests/mobile/miniws.mjs new file mode 100644 index 0000000..a528cc6 --- /dev/null +++ b/tests/mobile/miniws.mjs @@ -0,0 +1,81 @@ +/* A WebSocket client small enough to live here. Node 19 has no global + WebSocket (it arrived in 22), and the recorder must not depend on an npm + install — a mockup that needs one is a mockup nobody records. Text frames + only, which is all the DevTools protocol uses; handles the 16- and 64-bit + lengths a 200 KB screencast frame needs, continuation frames, and pings. + Same surface as the browser's: onopen / onerror / onmessage({data}) / send. */ +import http from 'node:http'; +import crypto from 'node:crypto'; + +export class MiniWS { + constructor(url) { + this.onopen = null; this.onerror = null; this.onmessage = null; this.onclose = null; + this.buf = Buffer.alloc(0); + this.frag = []; + this.sock = null; + const u = new URL(url); + const req = http.request({ + host: u.hostname, port: u.port, path: u.pathname + u.search, method: 'GET', + headers: { + Connection: 'Upgrade', Upgrade: 'websocket', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': crypto.randomBytes(16).toString('base64'), + }, + }); + req.on('upgrade', (res, sock) => { + this.sock = sock; + sock.on('data', d => this.feed(d)); + sock.on('error', e => this.onerror && this.onerror(e)); + sock.on('close', () => this.onclose && this.onclose()); + if (this.onopen) this.onopen(); + }); + req.on('response', res => { if (this.onerror) this.onerror(new Error('upgrade refused: HTTP ' + res.statusCode)); }); + req.on('error', e => { if (this.onerror) this.onerror(e); }); + req.end(); + } + + feed(d) { + this.buf = this.buf.length ? Buffer.concat([this.buf, d]) : d; + for (;;) { + const b = this.buf; + if (b.length < 2) return; + const fin = (b[0] & 0x80) !== 0, op = b[0] & 0x0f, masked = (b[1] & 0x80) !== 0; + let len = b[1] & 0x7f, off = 2; + if (len === 126) { if (b.length < 4) return; len = b.readUInt16BE(2); off = 4; } + else if (len === 127) { if (b.length < 10) return; len = Number(b.readBigUInt64BE(2)); off = 10; } + if (masked) off += 4; /* a server must not mask; tolerate it */ + if (b.length < off + len) return; /* wait for the rest of the frame */ + let payload = b.subarray(off, off + len); + if (masked) { + const m = b.subarray(off - 4, off); + payload = Buffer.from(payload.map((v, i) => v ^ m[i & 3])); + } + this.buf = b.subarray(off + len); + if (op === 0x8) { this.close(); return; } + if (op === 0x9) { this.frame(0xA, payload); continue; } + if (op === 0xA) continue; + if (op === 0x1 || op === 0x2 || op === 0x0) { + this.frag.push(payload); + if (fin) { + const msg = Buffer.concat(this.frag).toString('utf8'); + this.frag = []; + if (this.onmessage) this.onmessage({ data: msg }); + } + } + } + } + + frame(op, payload) { + const mask = crypto.randomBytes(4); + const n = payload.length; + let head; + if (n < 126) head = Buffer.from([0x80 | op, 0x80 | n]); + else if (n < 65536) { head = Buffer.alloc(4); head[0] = 0x80 | op; head[1] = 0x80 | 126; head.writeUInt16BE(n, 2); } + else { head = Buffer.alloc(10); head[0] = 0x80 | op; head[1] = 0x80 | 127; head.writeBigUInt64BE(BigInt(n), 2); } + const body = Buffer.alloc(n); + for (let i = 0; i < n; i++) body[i] = payload[i] ^ mask[i & 3]; + this.sock.write(Buffer.concat([head, mask, body])); + } + send(s) { this.frame(0x1, Buffer.from(String(s), 'utf8')); } + close() { if (this.sock) { this.sock.end(); this.sock = null; } } +} diff --git a/tests/mobile/perf.mjs b/tests/mobile/perf.mjs new file mode 100644 index 0000000..1ce80da --- /dev/null +++ b/tests/mobile/perf.mjs @@ -0,0 +1,155 @@ +/* Where the phone's frames go. + + Attaches to the app on the connected phone, then times the frames the + WebView actually produced (a requestAnimationFrame log in the page) while + the harness scrolls a long list, pushes a record page, opens the player + and sits idle with a track playing — the moments a person feels as lag. + Each scene runs under several CSS variants injected live, so the cost of + an effect is measured rather than guessed: the backdrop blurs behind the + chrome, the artwork ambience, the entrance animations. + + node tests/mobile/perf.mjs every scene × every variant + node tests/mobile/perf.mjs --quick the base variant only + + Frames over 32 ms are two dropped frames at 60 Hz; the p90 says how the + scene feels, the max how bad the worst hitch was. */ +import { attachPhone, sleep } from "./cdp.mjs"; + +const QUICK = process.argv.includes("--quick"); +const PORT = 9222; + +const VARIANTS = { + base: "", + "no-backdrop": `.nav,.mini,.gbar,.halfsheet{backdrop-filter:none!important;-webkit-backdrop-filter:none!important}`, + "no-ambience": `.amb{filter:none!important}.grain{display:none!important}`, + "no-feat-blur": `.feat .bg{filter:none!important}`, + "no-motion": `.screen,.screen>*{animation:none!important}`, + "no-blur-at-all": `.nav,.mini,.gbar,.halfsheet{backdrop-filter:none!important;-webkit-backdrop-filter:none!important}.amb{filter:none!important}.grain{display:none!important}.feat .bg{filter:none!important}`, +}; + +const FRAMES = `(function () { + if (!window.__fm) { + var f = { t: [], on: false }; + window.__fm = f; + (function loop(ts) { if (f.on) f.t.push(ts); requestAnimationFrame(loop); })(0); + } + return true; +})()`; +const START = `(__fm.t = [], __fm.on = true, true)`; +const STOP = `(function () { + __fm.on = false; + var t = __fm.t, d = []; + for (var i = 1; i < t.length; i++) d.push(t[i] - t[i - 1]); + d.sort(function (a, b) { return a - b; }); + var n = d.length; + if (!n) return { frames: 0 }; + var q = function (p) { return Math.round(d[Math.min(n - 1, Math.floor(n * p))]); }; + return { frames: n, p50: q(0.5), p90: q(0.9), p99: q(0.99), max: Math.round(d[n - 1]), + over32: d.filter(function (x) { return x > 32; }).length, over100: d.filter(function (x) { return x > 100; }).length }; +})()`; +const VARIANT = (css) => `(function () { + var s = document.getElementById('__perfvar'); + if (!s) { s = document.createElement('style'); s.id = '__perfvar'; document.head.appendChild(s); } + s.textContent = ${JSON.stringify(css)}; + return true; +})()`; + +async function scrollGesture(cdp, distance) { + /* the WebView's own scroll, through the input pipeline, not scrollBy */ + const size = await cdp.evaluate(`[innerWidth, innerHeight]`); + await cdp.send("Input.synthesizeScrollGesture", { + x: Math.round(size[0] / 2), y: Math.round(size[1] * 0.6), + yDistance: distance, speed: 1400, gestureSourceType: "touch", + }); +} + +/* the handle on the page has no patch(); tabs are clicked like a thumb would */ +const tab = (i) => `(function(){ var b = document.querySelectorAll('.segtabs button')[${i}]; if (b) b.click(); return true; })()`; +const SCENES = [ + { + name: "scroll tracks", + setup: async (cdp) => { await cdp.evaluate(`__ss.go('library'); true`); await sleep(300); await cdp.evaluate(tab(2)); }, + run: async (cdp) => { await scrollGesture(cdp, -1800); await scrollGesture(cdp, 1800); await scrollGesture(cdp, -1800); }, + }, + { + name: "scroll home", + setup: async (cdp) => { await cdp.evaluate(`__ss.go('home'); true`); }, + run: async (cdp) => { await scrollGesture(cdp, -1200); await scrollGesture(cdp, 1200); }, + }, + { + name: "push record", + setup: async (cdp) => { await cdp.evaluate(`__ss.go('library'); true`); await sleep(300); await cdp.evaluate(tab(0)); }, + run: async (cdp) => { + await cdp.evaluate(`(function(){ var c = document.querySelector('.grid2 .release'); if (c) c.click(); })()`); + await sleep(900); + await cdp.evaluate(`__ss.back(); true`); + await sleep(700); + }, + }, + { + name: "open player", + setup: async (cdp) => { await cdp.evaluate(`__ss.go('home'); true`); }, + run: async (cdp) => { + await cdp.evaluate(`(function(){ var m = document.querySelector('.mini'); if (m) m.click(); })()`); + await sleep(1100); + await cdp.evaluate(`__ss.closeSheet(); true`); + await sleep(700); + }, + }, + { + name: "idle, playing", + setup: async (cdp) => { await cdp.evaluate(`__ss.go('home'); true`); }, + run: async () => { await sleep(3000); }, + }, +]; + +async function main() { + const { cdp } = await attachPhone(PORT); + await cdp.send("Runtime.enable"); + await cdp.send("Performance.enable"); + const hasHandle = await cdp.evaluate(`!!window.__ss`); + if (!hasHandle) throw new Error("no __ss handle on the page — an older build?"); + await cdp.evaluate(FRAMES); + /* something must be playing for the lane and the ambience to cost anything */ + if (!(await cdp.evaluate(`__ss.player.status === 'playing'`))) { + /* something must be playing for the lane and the ambience to cost anything */ + await cdp.evaluate(`__ss.go('library'); true`); await sleep(300); await cdp.evaluate(tab(0)); await sleep(300); + await cdp.evaluate(`(function(){ var c = document.querySelector('.grid2 .release'); if (c) c.click(); })()`); await sleep(900); + await cdp.evaluate(`(function(){ var d = document.querySelector('.rec-acts .disc'); if (d) d.click(); })()`); await sleep(1500); + await cdp.evaluate(`__ss.back(); true`); await sleep(500); + } + + const variants = QUICK ? ["base"] : Object.keys(VARIANTS); + const rows = []; + for (const scene of SCENES) { + for (const v of variants) { + await cdp.evaluate(VARIANT(VARIANTS[v])); + await scene.setup(cdp); + await sleep(900); + const m0 = (await cdp.send("Performance.getMetrics")).result.metrics; + await cdp.evaluate(START); + await scene.run(cdp); + const stats = await cdp.evaluate(STOP); + const m1 = (await cdp.send("Performance.getMetrics")).result.metrics; + const metric = (name) => (m1.find((x) => x.name === name)?.value ?? 0) - (m0.find((x) => x.name === name)?.value ?? 0); + const row = { + scene: scene.name, variant: v, ...stats, + script: Math.round(metric("ScriptDuration") * 1000), + style: Math.round(metric("RecalcStyleDuration") * 1000), + layout: Math.round(metric("LayoutDuration") * 1000), + task: Math.round(metric("TaskDuration") * 1000), + }; + rows.push(row); + console.log( + scene.name.padEnd(14) + v.padEnd(16) + + `frames ${String(row.frames).padStart(4)} p50 ${String(row.p50).padStart(3)} p90 ${String(row.p90).padStart(3)} p99 ${String(row.p99).padStart(4)} max ${String(row.max).padStart(4)} >32ms ${String(row.over32).padStart(3)} >100ms ${String(row.over100).padStart(2)}` + + ` script ${row.script}ms style ${row.style}ms layout ${row.layout}ms`, + ); + } + } + await cdp.evaluate(VARIANT("")); + await cdp.evaluate(`__ss.go('home'); true`); + cdp.close(); + return rows; +} +main().catch((e) => { console.error(e.message || e); process.exit(1); }); diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs new file mode 100644 index 0000000..0ccc1c6 --- /dev/null +++ b/tests/mobile/smoke.mjs @@ -0,0 +1,583 @@ +/* Walk the phone UI and say what it showed. + + Two targets, one driver: + + node tests/mobile/smoke.mjs headless Chrome + the mock library + node tests/mobile/smoke.mjs --phone the app on the connected phone, over + the WebView's own DevTools socket + + MOCK: builds the phone document with relative asset paths into dist-smoke/, + opens it from file:// with src/lib/mockLibrary.js switched on, drives the + screens, screenshots each step, asserts what each should show, and fails + on any exception or console error. No Tauri there, so playback commands + reject — the point is the UI's own logic and its CSS, before the phone. + + PHONE: a debug build's WebView exposes DevTools on an abstract socket; + `adb forward` puts it on a local port and the same protocol drives the + real app on the real library — real playback, real covers, real colour. + Stills are the phone's own frame (adb screencap), status bar included. + `--album ` picks the record to walk by title (default: the newest). + + Stills land in tests/mobile/out/ (or the directory given as --out). */ +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { CDP, adb, sleep, attachPhone, phoneStill } from "./cdp.mjs"; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const ARGS = process.argv.slice(2); +const flag = (name) => { const i = ARGS.indexOf(name); return i >= 0 ? (ARGS[i + 1] ?? true) : null; }; +const PHONE = ARGS.includes("--phone"); +const OUT = path.resolve(flag("--out") || path.join(ROOT, "tests", "mobile", "out")); +const ALBUM = flag("--album"); +const PORT = PHONE ? 9222 : 9421; + +const CHROME = [ + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + path.join(process.env.LOCALAPPDATA || "", "Google/Chrome/Application/chrome.exe"), +].find((p) => fs.existsSync(p)); +/* injected beside the steps: click the first element whose text mentions + something, because the real library is a list of names, not of indices */ +const HELPERS = ` +window.__click = function (sel, text) { + var all = document.querySelectorAll(sel); + for (var i = 0; i < all.length; i++) { + if (!text || (all[i].textContent || '').indexOf(text) >= 0) { all[i].click(); return true; } + } + throw new Error('nothing to click for ' + sel + (text ? ' ~ "' + text + '"' : '')); +}; +window.__settle = function () { + /* put the app back at Home with nothing open, whatever it was doing */ + var s = document.querySelector('.sheet.up'); + if (s) { var c = s.querySelector('.np-head .iconbtn'); if (c) c.click(); } + var h = document.querySelector('.halfsheet.up'); + if (h) { var sc = document.querySelector('.scrim'); if (sc) sc.click(); } + var home = document.querySelectorAll('.nav button')[0]; + if (home) { home.click(); home.click(); } + return true; +}; +`; + +/* what the page shows, read back after every still */ +const PROBE = `(function () { + var e = document.querySelector('.screen'); + return { + screen: e ? e.className : '', + title: (document.querySelector('.gb-t') || {}).textContent || '', + mini: !!document.querySelector('.mini'), + sheet: (document.querySelector('.sheet') || {}).className || '', + half: (document.querySelector('.halfsheet') || {}).className || '', + halfText: (document.querySelector('.halfsheet .hs-body') || {}).textContent || '', + accent: getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(), + amb: [].map.call(document.querySelectorAll('.app-bg .amb'), function (a) { return a.className + ':' + (a.style.backgroundImage ? 'img' : '-'); }).join(' '), + playing: (document.querySelector('.mini .t') || {}).textContent || '', + toast: (document.querySelector('.toast') || {}).textContent || '', + live: (document.querySelector('.mini .m.live') || {}).textContent || '', + peers: document.querySelectorAll('.peer').length, + songs: document.querySelectorAll('.cat-song').length, + msgs: document.querySelectorAll('.msg').length, + lane: (document.querySelector('.sheet .lane') || {}).className || '', + top: (document.querySelector('.top .top-name') || {}).textContent || '', + tabs: document.querySelectorAll('.disco-tabs button').length, + pop: document.querySelectorAll('.row.pop').length, + fb: (document.querySelector('.stats .fb') || {}).textContent || '', + heroImg: !!document.querySelector('.top-art img'), + socs: document.querySelectorAll('.stats .soc').length, + eds: document.querySelectorAll('.eds .ed').length, + lede: !!document.querySelector('.lede'), + bars: document.querySelectorAll('.trk .lbar').length, + save: (document.querySelector('.rec-acts .save') || {}).textContent || '', + xfers: document.querySelectorAll('.row.xfer').length, + feat: (document.querySelector('.feat .kick') || {}).textContent || '', + gaplines: document.querySelectorAll('.gapline').length, + folderRow: !![].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Library folder/.test(x.textContent); }), + norm: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Volume normalization/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(), + gapCards: document.querySelectorAll('.rail.gaps .release').length, + news: document.querySelectorAll('.row.news').length, + secs: [].map.call(document.querySelectorAll('.screen .sec h2'), function (h) { return h.firstChild && h.firstChild.textContent ? h.firstChild.textContent.trim() : ''; }).join('|'), + xlive: (document.querySelector('.screen .sec h2') || {}).textContent || '', + cardImgs: document.querySelectorAll('.rail .acard img').length, + cardPhs: document.querySelectorAll('.rail .acard svg').length, + }; +})()`; + +/* ── the walks ─────────────────────────────────────────────────────────── */ +const MOCK_STEPS = [ + { name: "home", wait: 1600 }, + /* a long press on a card is its sheet */ + { do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 700 }, + { do: `document.querySelector('.scrim').click()`, wait: 500 }, + { do: `document.querySelectorAll('.nav button')[1].click()`, name: "search-empty", wait: 700 }, + { do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpo'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 900 }, + { do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 }, + { do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 }, + { do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 300 }, + { do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "album", wait: 900 }, + { do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 900 }, + { do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 700 }, + { do: `document.querySelector('.scrim').click()`, wait: 600 }, + { do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1100 }, + { do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 600 }, + { do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 }, + { do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 300 }, + { do: `history.back()`, name: "after-back", wait: 900 }, + { do: `document.querySelector('.rec-artist').click()`, name: "artist", wait: 900 }, + { do: `history.back()`, wait: 700 }, + { do: `history.back()`, name: "library-again", wait: 800 }, + { do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 800 }, + /* the fake network: a room with a few lines in it */ + { do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 }, + { do: `history.back()`, wait: 700 }, + /* a search answered by five peers; the best one's first file streams, + the lane rising as the bytes arrive */ + { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 600 }, + { do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk discovery'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "cat-results", wait: 1400 }, + /* the hero's artist: the desktop's page — tabs, Popular's bars, Follow, + the latest release, fans also like — then a fan-also-likes page */ + { do: `__click('.top button', 'Open')`, name: "cat-artist", wait: 2200 }, + { do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 }, + { do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 }, + { do: `__click('.rail .acard', 'Justice')`, name: "cat-artist-related", wait: 1800 }, + { do: `history.back()`, wait: 600 }, + { do: `history.back()`, wait: 800 }, + { do: `document.querySelector('.cat-song').click()`, name: "net-playing", wait: 5500 }, + { do: `document.querySelector('.mini').click()`, name: "net-sheet", wait: 3000 }, + { name: "net-arrived", wait: 7000 }, + /* a catalog record: a row plays through the resolver with the rest of + the record queued behind it, the next track finds its own copy on + its turn, and the disc plays the whole record from one folder */ + { do: `__settle()`, wait: 800 }, + { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 900 }, + { do: `__click('.rail .release', 'Discovery')`, name: "cat-album", wait: 1500 }, + { do: `document.querySelectorAll('.tracks .trk')[1].click()`, name: "cat-album-row", wait: 5500 }, + { do: `__ss.next()`, name: "cat-album-next", wait: 7000 }, + { do: `document.querySelector('.rec-acts .disc').click()`, name: "cat-album-disc", wait: 6500 }, + /* Save: the best folder by the download rules, every file fetched */ + /* one track: the long press's sheet, Save to library */ + { do: `document.querySelectorAll('.tracks .trk')[3].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "cat-track-sheet", wait: 700 }, + { do: `__click('.halfsheet .row', 'Save to library')`, name: "cat-track-save", wait: 3500 }, + { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); })()`, wait: 500 }, + { do: `document.querySelector('.rec-acts .save').click()`, name: "cat-album-save", wait: 4500 }, + /* the burst on the Transfers screen, reached from You */ + { do: `__settle()`, wait: 700 }, + { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 600 }, + { do: `__click('.screen .row', 'Transfers')`, name: "transfers", wait: 1200 }, + { do: `history.back()`, wait: 600 }, + /* Gaps: the followed shelf, its missing records dimmed */ + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, + { do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 800 }, + { do: `__click('.gapline', null)`, name: "gaps-open", wait: 1600 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 }, + { do: `__settle()`, wait: 800 }, + { do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 700 }, + { do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-again", wait: 700 }, + /* the page overflow → the sleep timer */ + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 700 }, + { do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 700 }, + { do: `__click('.halfsheet .chip', '30 min')`, name: "sleep-set", wait: 700 }, +]; +const MOCK_EXPECT = { + tracks: (i) => i.title.startsWith("Your library"), + community: (i) => i.title.startsWith("Community"), + "home-more": (i) => i.half.includes("up"), + you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow, + "sleep-sheet": (i) => i.half.includes("up"), + "sleep-set": (i) => !i.half.includes("up"), + home: (i) => /^(Good |Still)/.test(i.title), + "hold-sheet": (i) => i.half.includes("up"), + "search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"), + search: (i) => i.title.startsWith("Search"), + library: (i) => i.title.startsWith("Your library"), + album: (i) => i.screen.includes("tr-push") && !i.mini, + "album-playing": (i) => i.mini, + "track-sheet": (i) => i.half.includes("up"), + sheet: (i) => i.sheet.includes("up"), + "after-back": (i) => !i.sheet.includes("up"), + artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img"), + "library-again": (i) => i.screen.includes("tr-pop"), + /* after the follow: the feed's lead ("Coming · ALBUM" — the upcoming + record leads), the news, the back catalogue, recommendations, + complete the set, following — the desktop's page */ + "home-again": (i) => /^(Good |Still)/.test(i.title) && /^Coming/.test(i.feat) && i.news >= 1 && /New from your artists/.test(i.secs) && /From the back catalogue/.test(i.secs) && /Recommended for you/.test(i.secs) && /Following/.test(i.secs), + room: (i) => i.screen.includes("tr-push") && i.msgs >= 2, + /* the artist with a picture shows it on the hero and its card; the + namesake under Deezer's empty-hash placeholder shows the app's own */ + "cat-results": (i) => i.songs >= 4 && i.top.startsWith("Daft Punk") && i.heroImg && i.cardImgs === 1 && i.cardPhs === 1, + "cat-artist": (i) => i.screen.includes("has-hero") && i.title.startsWith("Daft Punk") && i.tabs >= 4 && i.pop >= 5 && i.socs === 2, + "cat-artist-follow": (i) => i.fb.includes("Following"), + "cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2, + "cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero"), + "net-playing": (i) => i.mini && i.playing.includes("One More Time") && /streaming from|copy/.test(i.live), + "net-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"), + "net-arrived": (i) => i.sheet.includes("up") && !i.lane.includes("rising"), + "cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10, + "cat-track-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText), + "cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"), + "cat-album-save": (i) => /^Saving \d+ from /.test(i.save), + transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11, + gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1, + "gaps-open": (i) => i.gapCards >= 3, + "cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live), + "cat-album-next": (i) => i.playing.includes("Digital Love") && /streaming from/.test(i.live), + "cat-album-disc": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live), +}; + +/* the phone walk: a record by name, played for real */ +const albumText = ALBUM ? JSON.stringify(ALBUM) : "null"; +const PHONE_STEPS = [ + { do: `__settle()`, wait: 900 }, + { name: "home", wait: 300 }, + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, + /* the Library root remembers its tab across runs — back to Albums first */ + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 }, + { do: `__click('.grid2 .release', ${albumText})`, name: "album", wait: 1400 }, + { do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 3200 }, + { do: `document.querySelector('.gbar .iconbtn').click()`, name: "library-ambience", wait: 1400 }, + { do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-ambience", wait: 1200 }, + { do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1400 }, + { do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 800 }, + { do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 3500 }, + { do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 400 }, + { do: `history.back()`, name: "after-back", wait: 1000 }, + /* a reload keeps the audio thread: the session comes back LIVE */ + { do: `location.reload()`, name: "after-reload", wait: 4000 }, + { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 }, + { do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'the'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 1200 }, + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, + { do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 900 }, + { do: `document.querySelector('.scrim').click()`, wait: 600 }, + { do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 1200 }, + { do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 }, + /* the artist page: the library's shell at once, the catalog dissolving in */ + /* button.row: the page header is a .row too, and it is a div */ + { do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 }, + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 }, + { do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 }, + { do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 }, + { name: "artist", wait: 4500 }, + /* a discography card the library does NOT hold opens the catalog record */ + { do: `(function () { var c = [].slice.call(document.querySelectorAll('.grid2 .release')); if (!c.length) throw new Error('no discography grid'); var pick = c.find(function (x) { return x.textContent.indexOf('in your library') < 0; }) || c[c.length - 1]; pick.click(); })()`, name: "catalog-album", wait: 3500 }, + { do: `history.back()`, wait: 800 }, + { do: `history.back()`, wait: 800 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 }, + { do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 }, +]; +const PHONE_EXPECT = { + album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img"), + "album-playing": (i) => i.mini && !i.toast, + "home-ambience": (i) => /^(Good |Still)/.test(i.title) && i.amb.includes("on:img"), + sheet: (i) => i.sheet.includes("up"), + "after-back": (i) => !i.sheet.includes("up"), + "after-reload": (i) => i.mini && !!i.playing && i.amb.includes("on:img") && !i.toast, + search: (i) => i.title.startsWith("Search"), + "hold-sheet": (i) => i.half.includes("up"), + tracks: (i) => i.title.startsWith("Your library"), + "artist-cold": (i) => i.screen.includes("has-hero"), + artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1, + "catalog-album": (i) => i.screen.includes("tr-push") && !!i.title, + end: (i) => /^(Good |Still)/.test(i.title), +}; + +/* The system's view of the app's media session: who owns it and what the + * playback state says. From `dumpsys media_session`. */ +function mediaSessionState() { + try { + const dump = adb("shell", "dumpsys", "media_session").toString(); + /* sessions are listed as blocks; find ours by package, then the first + playback state after it */ + const at = dump.search(/com\.soulstream\.app/); + if (at < 0) return { owner: "", state: "" }; + const block = dump.slice(Math.max(0, at - 400), at + 3000); + const state = (block.match(/state=PlaybackState \{state=([A-Z_]+)\(\d+\), position=(\d+)/) || []); + return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null }; + } catch { return { owner: "", state: "" }; } +} + +/* The audio mixer's underrun count for the app's fast track: a dropout the + * ear hears is a number here. Read before and after the walk; a rise during + * it is a finding. */ +function audioUnderruns() { + try { + const dump = adb("shell", "dumpsys", "media.audio_flinger").toString(); + let n = 0; + for (const m of dump.matchAll(/Fifo frame underruns:[^\n]*n:\s*(\d+)/g)) n += +m[1]; + for (const m of dump.matchAll(/numTracks=\d+ writeErrors=\d+ underruns=(\d+)/g)) n += +m[1]; + return n; + } catch { return null; } +} + +async function main() { + fs.mkdirSync(OUT, { recursive: true }); + const problems = []; + let cdp = null; // reassigned by the restart phase + let underrunsBefore = null; + let chrome = null; + let steps, expect, still, prefix; + + if (PHONE && ARGS.includes("--only-lock")) { + /* the lock-screen card alone: whatever is on the bar plays, the phone + sleeps, wakes to its lock screen, and that is the still. Leaves the + phone locked. */ + const attached = await attachPhone(PORT); + cdp = attached.cdp; + await cdp.send("Runtime.enable"); + const st = await cdp.evaluate(`(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, status: p.status } : null; })()`); + if (!st) { console.log("nothing on the bar to show on the lock screen"); process.exit(1); } + if (st.status !== "playing") { await cdp.evaluate(`window.__ss && __ss.toggle()`); await sleep(2500); } + cdp.close(); + cdp = null; + adb("shell", "input", "keyevent", "KEYCODE_SLEEP"); + await sleep(2000); + adb("shell", "input", "keyevent", "KEYCODE_WAKEUP"); + await sleep(2500); + fs.mkdirSync(OUT, { recursive: true }); + await phoneStill(path.join(OUT, "phone-lockscreen.png")); + console.log(" -- lockscreen still taken (" + st.title + "); the phone is locked now"); + process.exit(0); + } + if (PHONE) { + const attached = await attachPhone(PORT); + cdp = attached.cdp; + const pid = attached.pid; + underrunsBefore = audioUnderruns(); + steps = PHONE_STEPS; expect = PHONE_EXPECT; prefix = "phone-"; + still = phoneStill; + console.log("attached to the phone's WebView (pid " + pid + ")"); + } else { + if (!CHROME) throw new Error("Chrome not found"); + console.log("building dist-smoke/ …"); + const b = spawnSync("npx", ["vite", "build", "--outDir", "dist-smoke", "--base", "./"], { cwd: ROOT, shell: true, encoding: "utf8" }); + if (b.status !== 0) { console.error(b.stdout, b.stderr); throw new Error("vite build failed"); } + /* a profile per run: a Chrome left behind by an interrupted run keeps + its crashpad file open, and removing that profile throws EBUSY */ + const profile = path.join(os.tmpdir(), "ss-smoke-profile-" + process.pid); + for (const d of fs.readdirSync(os.tmpdir())) { + if (d.startsWith("ss-smoke-profile")) { + try { fs.rmSync(path.join(os.tmpdir(), d), { recursive: true, force: true }); } catch { /* still held */ } + } + } + chrome = spawn(CHROME, [ + "--headless=new", "--disable-gpu", "--hide-scrollbars", "--mute-audio", + "--allow-file-access-from-files", `--user-data-dir=${profile}`, + `--remote-debugging-port=${PORT}`, "--window-size=411,914", "about:blank", + ], { stdio: "ignore" }); + cdp = await CDP.attach(PORT); + steps = MOCK_STEPS; expect = MOCK_EXPECT; prefix = ""; + still = async (file) => { + const shot = await cdp.send("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(file, Buffer.from(shot.result.data, "base64")); + }; + } + + try { + await cdp.send("Page.enable"); + await cdp.send("Runtime.enable"); + await cdp.send("Log.enable"); + cdp.on["Runtime.exceptionThrown"] = (p) => problems.push("EXC " + (p.exceptionDetails?.exception?.description || p.exceptionDetails?.text)); + cdp.on["Runtime.consoleAPICalled"] = (p) => { + if (p.type === "error" || p.type === "warning") problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" ")); + }; + cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); }; + if (!PHONE) { + await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `try { localStorage.setItem('mockLibrary', '1'); } catch (e) {}` }); + await cdp.send("Emulation.setDeviceMetricsOverride", { width: 411, height: 914, deviceScaleFactor: 2, mobile: true }); + await cdp.send("Page.navigate", { url: pathToFileURL(path.join(ROOT, "dist-smoke", "mobile.html")).href }); + await sleep(600); + } + for (const s of steps) { + if (s.do) { + try { + /* the helpers live in the page; a reload step wipes them */ + if (s.do.includes("__")) await cdp.evaluate(HELPERS); + await cdp.evaluate(s.do); + } catch (e) { problems.push("STEP " + (s.name || s.do) + ": " + e.message); } + } + await sleep(s.wait || 500); + if (!s.name) continue; + await still(path.join(OUT, `${prefix}${s.name}.png`)); + const info = await cdp.evaluate(PROBE); + const ok = expect[s.name] ? expect[s.name](info) : true; + if (!ok) problems.push("EXPECT " + s.name + " " + JSON.stringify(info)); + console.log((ok ? " ok " : " FAIL ") + s.name.padEnd(16) + " " + JSON.stringify(info)); + } + + /* ── the restart half of session restore, on the phone only ────── + A reload kept the audio thread (tested above); a real restart + loses it. The track must come back on the bar, paused, and play + must pick it up where it was rather than at zero. */ + if (PHONE) { + const PLAYER = `(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, pos: Math.round(p.position), status: p.status, needs: !!p.needsResource } : null; })()`; + const before = await cdp.evaluate(PLAYER); + cdp.close(); + adb("shell", "am", "force-stop", "com.soulstream.app"); + await sleep(800); + adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity"); + await sleep(6000); + const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim(); + adb("forward", "--remove-all"); + adb("forward", `tcp:${PORT}`, `localabstract:webview_devtools_remote_${pid2}`); + cdp = await CDP.attach(PORT); + await cdp.send("Runtime.enable"); + await still(path.join(OUT, `${prefix}restart.png`)); + const after = await cdp.evaluate(PLAYER); + const restored = !!before && !!after && after.title === before.title && after.status === "paused" && after.needs && Math.abs(after.pos - before.pos) <= 15; + if (!restored) problems.push("EXPECT restart " + JSON.stringify({ before, after })); + console.log((restored ? " ok " : " FAIL ") + "restart".padEnd(16) + " " + JSON.stringify({ before, after })); + await cdp.evaluate(`window.__ss && __ss.toggle()`); + await sleep(3500); + const resumed = await cdp.evaluate(PLAYER); + await still(path.join(OUT, `${prefix}resumed.png`)); + const okResume = !!resumed && resumed.status === "playing" && !resumed.needs && (!after || resumed.pos >= after.pos - 1); + if (!okResume) problems.push("EXPECT resumed " + JSON.stringify(resumed)); + console.log((okResume ? " ok " : " FAIL ") + "resumed".padEnd(16) + " " + JSON.stringify(resumed)); + + /* ── the media session, end to end ───────────────────────── + The system must hold a session for the app with the track + playing; a media key must reach the player through it; the + audio must keep moving with the app in the background. */ + await sleep(1500); + const sess = mediaSessionState(); + const sessOk = /com\.soulstream\.app/.test(sess.owner) && /PLAYING/.test(sess.state); + if (!sessOk) problems.push("EXPECT media-session " + JSON.stringify(sess)); + console.log((sessOk ? " ok " : " FAIL ") + "media-session".padEnd(16) + " " + JSON.stringify(sess)); + + adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PAUSE"); + await sleep(1500); + const keyPaused = await cdp.evaluate(PLAYER); + adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PLAY"); + await sleep(2000); + const keyPlaying = await cdp.evaluate(PLAYER); + const keysOk = keyPaused?.status === "paused" && keyPlaying?.status === "playing"; + if (!keysOk) problems.push("EXPECT media-keys " + JSON.stringify({ keyPaused, keyPlaying })); + console.log((keysOk ? " ok " : " FAIL ") + "media-keys".padEnd(16) + " " + JSON.stringify({ paused: keyPaused?.status, playing: keyPlaying?.status })); + + const posBefore = (await cdp.evaluate(PLAYER))?.pos ?? 0; + cdp.close(); + adb("shell", "input", "keyevent", "KEYCODE_HOME"); + await sleep(10000); + const stillPlaying = /PLAYING/.test(mediaSessionState().state); + adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity"); + await sleep(2500); + cdp = await CDP.attach(PORT, { phone: true }); + await cdp.send("Runtime.enable"); + const posAfter = (await cdp.evaluate(PLAYER))?.pos ?? 0; + const bgOk = stillPlaying && posAfter >= posBefore + 8; + if (!bgOk) problems.push("EXPECT background " + JSON.stringify({ posBefore, posAfter, stillPlaying })); + console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify({ posBefore, posAfter, stillPlaying })); + await still(path.join(OUT, `${prefix}shade.png`)); + + /* ── the network: a search, then a peer's copy played as it + arrives. Only with the app signed in (the stored account signs + in at boot); skipped, not failed, when it is not. ────────── */ + const online = await cdp.evaluate(`!!(window.__ss && __ss.slsk && __ss.slsk.connection === 'online')`); + if (!online) { + console.log(" -- network skipped: not signed in on the phone"); + } else { + const QUERY = flag("--query") || "daft punk"; + await cdp.evaluate(HELPERS); + await cdp.evaluate(`__settle()`); + await sleep(700); + await cdp.evaluate(`document.querySelectorAll('.nav button')[1].click()`); + await sleep(600); + await cdp.evaluate(`(function () { var i = document.querySelector('.sfield input'); i.value = ${JSON.stringify(QUERY)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`); + const CAT = `(function () { return { songs: document.querySelectorAll('.cat-song').length, records: document.querySelectorAll('.rail .release').length, artists: document.querySelectorAll('.cat-artist').length }; })()`; + let catInfo = null; + for (let i = 0; i < 30; i++) { + await sleep(500); + catInfo = await cdp.evaluate(CAT); + if (catInfo.songs >= 1) break; + } + await sleep(600); + catInfo = await cdp.evaluate(CAT); + await still(path.join(OUT, `${prefix}cat-results.png`)); + const resOk = catInfo.songs >= 1; + if (!resOk) problems.push("EXPECT cat-results " + JSON.stringify(catInfo)); + console.log((resOk ? " ok " : " FAIL ") + "cat-results".padEnd(16) + " " + JSON.stringify(catInfo)); + + if (resOk) { + /* the hero's page: an artist's (tabs, Popular) or a record's */ + const HERO = `(function () { var e = document.querySelector('.screen'); return { screen: e ? e.className : '', title: (document.querySelector('.gb-t') || {}).textContent || '', tabs: document.querySelectorAll('.disco-tabs button').length, pop: document.querySelectorAll('.row.pop').length, socs: document.querySelectorAll('.stats .soc').length, top: (document.querySelector('.top .top-name') || {}).textContent || '' }; })()`; + const topName = (await cdp.evaluate(HERO)).top; + await cdp.evaluate(HELPERS); + await cdp.evaluate(`__click('.top .btn-ghost', null)`); + await sleep(4500); + const hero = await cdp.evaluate(HERO); + await still(path.join(OUT, `${prefix}cat-hero-page.png`)); + const heroOk = hero.screen.includes("tr-push") && (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1); + if (!heroOk) problems.push("EXPECT cat-hero-page " + JSON.stringify(hero)); + console.log((heroOk ? " ok " : " FAIL ") + "cat-hero-page".padEnd(16) + " " + JSON.stringify({ top: topName, title: hero.title, tabs: hero.tabs, pop: hero.pop, socials: hero.socs, hero: hero.screen.includes("has-hero") })); + await cdp.evaluate(`history.back()`); + await sleep(1200); + + /* the first song, through the resolver: the ladder, a + peer's copy, the lane rising before the file is here */ + const STREAM = `(function () { var p = window.__ss && __ss.player; if (!p || !p.track) return null; return { title: p.track.title, from: p.track.stream && p.track.stream.username, status: p.status, phase: p.phase, pos: Math.round(p.position), coverage: +(p.coverage || 0).toFixed(2), received: p.stream && p.stream.received, total: p.stream && p.stream.total, failed: p.stream && p.stream.failed, error: p.error || '', live: (document.querySelector('.mini .m.live') || {}).textContent || '' }; })()`; + await cdp.evaluate(`document.querySelector('.cat-song').click()`); + let st = null; + for (let i = 0; i < 60; i++) { + await sleep(1000); + st = await cdp.evaluate(STREAM); + if (st && st.status === "playing" && st.pos >= 3) break; + if (st && (st.failed || st.error)) break; + } + await still(path.join(OUT, `${prefix}net-playing.png`)); + console.log(" stream " + JSON.stringify(st)); + await cdp.evaluate(`document.querySelector('.mini').click()`); + await sleep(2500); + await still(path.join(OUT, `${prefix}net-sheet.png`)); + const st2 = await cdp.evaluate(STREAM); + const partial = !!st2 && st2.received > 0 && (st2.received < st2.total || st2.coverage < 1 || st2.phase === "rising"); + const streamOk = !!st && !!st2 && st.status === "playing" && st2.status === "playing" && !!st2.from && st2.pos >= st.pos && !st2.failed && !st2.error; + if (!streamOk) problems.push("EXPECT net-stream " + JSON.stringify({ st, st2 })); + console.log((streamOk ? " ok " : " FAIL ") + "net-stream".padEnd(16) + " " + JSON.stringify({ from: st2?.from, status: st2?.status, phase: st2?.phase, pos: st2?.pos, coverage: st2?.coverage, received: st2?.received, total: st2?.total, live: st?.live, partialWhileWatched: partial })); + /* the raw peers, on request */ + await cdp.evaluate(`document.querySelector('.sheet .np-head .iconbtn').click()`); + await sleep(700); + await cdp.evaluate(`document.querySelector('.net-ask').click()`); + const NET = `(function () { return { peers: document.querySelectorAll('.peer').length, files: document.querySelectorAll('.peer-file').length }; })()`; + let netInfo = null; + for (let i = 0; i < 30; i++) { + await sleep(500); + netInfo = await cdp.evaluate(NET); + if (netInfo.peers >= 4) break; + } + await still(path.join(OUT, `${prefix}net-peers.png`)); + const peersOk = !!netInfo && netInfo.peers >= 1; + if (!peersOk) problems.push("EXPECT net-peers " + JSON.stringify(netInfo)); + console.log((peersOk ? " ok " : " FAIL ") + "net-peers".padEnd(16) + " " + JSON.stringify(netInfo)); + await cdp.evaluate(HELPERS); + await cdp.evaluate(`__settle()`); + await sleep(800); + } + } + + /* the lock-screen card: opt in, because it locks the phone and a + fingerprint has to open it again */ + if (ARGS.includes("--lockscreen")) { + adb("shell", "input", "keyevent", "KEYCODE_SLEEP"); + await sleep(2000); + adb("shell", "input", "keyevent", "KEYCODE_WAKEUP"); + await sleep(2500); + await still(path.join(OUT, `${prefix}lockscreen.png`)); + console.log(" -- lockscreen still taken; the phone is locked now"); + } + } + } finally { + if (cdp) cdp.close(); + if (chrome) chrome.kill(); + } + if (PHONE && underrunsBefore != null) { + const after = audioUnderruns(); + const grew = after != null && after > underrunsBefore; + if (grew) problems.push(`AUDIO underruns rose during the walk: ${underrunsBefore} → ${after}`); + console.log((grew ? " FAIL " : " ok ") + "audio".padEnd(16) + ` mixer underruns ${underrunsBefore} → ${after}`); + } + /* the two toasts a Tauri-less page always shows are not findings */ + const real = PHONE ? problems : problems.filter((p) => !/transformCallback|reading 'invoke'/.test(p)); + console.log("\n" + (real.length ? real.length + " problem(s):\n" + real.map((p) => " - " + p.slice(0, 400)).join("\n") : "no exceptions, no console errors, every step as expected")); + process.exit(real.length ? 1 : 0); +} +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/vite.config.js b/vite.config.js index c274860..56fdb9b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,16 +1,23 @@ import { defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; +// `tauri android dev` sets this to the machine's LAN address so the phone's +// webview can reach the dev server; on the desktop it is unset and the +// loopback rule below stands. +const devHost = process.env.TAURI_DEV_HOST; + export default defineConfig({ plugins: [svelte()], clearScreen: false, build: { rollupOptions: { - // Two documents, one app: the full shell and the mini player - // window load the same modules through different mount points. + // Three documents, one app: the full shell, the mini player + // window and the phone load the same modules through different + // mount points. input: { main: "index.html", mini: "mini.html", + mobile: "mobile.html", }, }, }, @@ -21,7 +28,15 @@ export default defineConfig({ // binds [::1] ONLY, while WebView2 resolves the devUrl's "localhost" // to 127.0.0.1 — so the window opens on ERR_CONNECTION_REFUSED while // curl and the browser (which try ::1) both say the server is fine. - host: "127.0.0.1", + host: devHost || "127.0.0.1", + // Named explicitly, not left to the page's host: on the phone the dev + // flavour is proxied under http://tauri.localhost (ANDROID.md), where + // Vite's client would first knock on ws://tauri.localhost and log a + // refused connection before falling back. localhost is right in both + // places — WebView2 here, the adb reverse tunnel there. + hmr: devHost + ? { protocol: "ws", host: devHost, port: 1421 } + : { protocol: "ws", host: "localhost", port: 1421 }, watch: { // Never watch the Rust build dir: cargo locks files mid-compile, // which crashes Vite's watcher (EBUSY) and kills tauri dev.