diff --git a/ANDROID.md b/ANDROID.md index b47f6ed..2005239 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -172,6 +172,89 @@ 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. +## Playlists + +The desktop's two playlist pages (`src/views/Playlists.svelte`, +`Playlist.svelte`), at phone scale, on the same commands +(`list_playlists`, `playlist_items`, `playlist_cover`, create / rename / +describe / pin / duplicate / delete, `playlist_add_items`, +`playlist_remove_items`). + +- **The shelf** is Library → Playlists (`src/mobile/PlaylistShelf.svelte`): + the stat line, Pinned then All, the custom cover or the composed mosaic on + every card (fetched once per revision — `coverFor` in + `playlists.svelte.js`), a Setlist / N unresolved badge, New playlist (a + prompt sheet). A long press on a card is its sheet: Play, Shuffle, Pin, + Rename, Duplicate, Delete (a confirm sheet). +- **The page** (`screens/Playlist.svelte`) is the record page's shape: the + cover or mosaic, the kind and a Pinned chip, the description, the meta + line with its warnings (N unresolved, N missing), Play / Shuffle / More, + a filter, and the rows with the desktop's tags — `unresolved`, + `set audio`, `file missing`. Setlist cue times show only when every row + carries one. The bar's More is the page's sheet: Pin, Rename, Edit + description, Duplicate, Remove unresolved entries, Delete. +- **Playing** follows the desktop's `queueableRows`: Play queues the rows + that can play — present local files as themselves, stream rows as + path-less entries with their catalog identity, which the player resolves + on reach through `hooks.resolveEntry` (the same path a record's later + tracks take, so the three-ahead preload covers them). A tap on a weak row + with a name streams it one-off; a bare placeholder opens its sheet. The + playing row is found by `plItemId` on the queue entry. A set-recording + slice starts at its cue (`t.startSecs`) and the player moves on at the + next cue (`t.endSecs`); the scrubber still shows the whole recording. +- **A row's sheet** (`pltrack`): Play from here, Play next, Add to queue, + Save to library for a stream row, Go to artist, Remove from playlist. + A removal is undoable for seven seconds from the neutral toast + (`notice.svelte.js` — the desktop's undo pill); the stash pins the + playlist it came from. +- **Add to library / Download all tracks** on the page's sheet — the + desktop's bulk actions (`runBulk` in `playlists.svelte.js`, the rules + verbatim): the first files every already-streamed track whose cache is + byte-complete, the second finds a source for every stream row (the + remembered one, else the ladder) and hands it to the download pipeline; + the page shows the run's line with Stop, then its summary. +- **Add to playlist** is on every track and record sheet: a pick-a-playlist + sheet (New playlist at the top). A file on the phone becomes a local row; + a streamed or catalog track keeps its identity (`track_id`) and cover URL + and becomes a stream row. +- Every mutation re-lists through `soulseek:playlists-changed`, which the + desktop's commands already emit; the page re-reads its rows when the + playlist's revision moves. +- **Importing** (`importer.svelte.js`, `screens/Import.svelte`): the + shelf's Import opens the doors that need no file — a link (YouTube · + Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, + a timestamped setlist, or track links), a YouTube DJ set's posted + tracklist. The backend stages the draft over plain HTTP (the same + commands as the desktop, no yt-dlp involved), the review screen shows + every row's verdict, the stream rows are verified against the catalog + while it is up (the desktop's pass, verbatim), and Create swaps the + review for the playlist it made (`nav.replace`). +- **The share sheet** (`crates/tauri-plugin-share`, `share.svelte.js`): + Share → SoulStream from any app. The plugin's `ShareActivity` (an + ACTION_SEND text/plain target, merged into the app's manifest from the + plugin module) keeps the text and brings the app's own task to the front + through its launcher intent; the WebView asks `pending_share` at boot and + whenever it comes back to the front, and a link opens the link door (the + first URL in the text — Spotify shares the link inside a sentence), + anything else the pasted-tracklist door. The phone walk sends the intent + itself (`adb shell am start -a android.intent.action.SEND …`). + +Not on the phone yet: the file and folder import doors (the files are on +the desktop), the listener door, the find-a-local-match repair panel, drag +reorder, the cover picker, export, Share on SoulStream. + +## Settings that reach the config + +`set_settings` takes **camelCase** arguments (Tauri's rule for command +parameters): `{ volumeNormalization, downloadDir, streamSourcePrefs, +downloadSourcePrefs }`. The phone's first You screen sent snake_case keys +and every save was silently ignored (found 2026-09-13 while adding the +Sources group — the walk now flips the switch and a chip and reads each +back from the config). You → Sources is the desktop's Source quality +section: per mode (streaming, downloads) the ceiling (no limit / lossy +only), the floor (any … lossless only) and the priority (best quality / +fastest transfer); the resolver re-reads them (`refreshSourcePrefs`). + ## Background playback and the lock-screen card The service is started with `startForegroundService`, so it promotes itself @@ -342,12 +425,25 @@ waveform growing; backend events go over the mock's own bus, which 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. +a catalog record, its Save and the Transfers burst → Gaps → the playlist +shelf and a page with every row kind (a local row, the stream row through +the resolver, the filter, a removal and its undo, unpin, rename, a new +playlist, Add to playlist from a record's sheet, delete) → the import doors +(a pasted tracklist, a link with its catalog verification, a share from +another app) → You (the normalization switch and a source-quality chip, +each read back from the config) → the sleep timer, asserts what each step +should show, screenshots every step into `tests/mobile/out/`, and fails on +any exception or console error. The mock answers every command the phone +uses; one it does not know is named in the report (`[mock] no mock for X`) +rather than surfacing as a TypeError toast, console warnings carry their top +frames (the smoke bundle is built unminified for that), and every probe +carries the history mirror — the entry's depth against the layers the app +says the back gesture can peel (`hd`/`hwant`; a drift is how the blank +page after a confirmed delete was found). -The phone walk is the same driver on the real thing: a debug build's WebView +The phone walk is the same driver on the real thing (plus a step kind of +its own: `{ adb: [...] }` runs an adb command — the share-sheet intent, a +key): 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 diff --git a/Cargo.lock b/Cargo.lock index 3d39fef..c9439b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,6 +195,7 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-media-session", "tauri-plugin-opener", + "tauri-plugin-share", "tauri-plugin-storage", "tauri-plugin-updater", "tempfile", @@ -5842,6 +5843,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-share" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-storage" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index eeca415..295fae5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/persona-state", "crates/tauri-plugin-media-session", "crates/tauri-plugin-storage", + "crates/tauri-plugin-share", ] [workspace.package] diff --git a/crates/tauri-plugin-share/Cargo.toml b/crates/tauri-plugin-share/Cargo.toml new file mode 100644 index 0000000..74cda94 --- /dev/null +++ b/crates/tauri-plugin-share/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tauri-plugin-share" +version = "0.1.0" +description = "SoulStream on the phone's share sheet: a link or a tracklist sent from another app lands in the playlist import" +edition = "2021" +rust-version = "1.77.2" +# required by tauri-plugin's build script; must equal the crate name +links = "tauri-plugin-share" + +[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-share/android/.gitignore b/crates/tauri-plugin-share/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/crates/tauri-plugin-share/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/crates/tauri-plugin-share/android/build.gradle.kts b/crates/tauri-plugin-share/android/build.gradle.kts new file mode 100644 index 0000000..19905e8 --- /dev/null +++ b/crates/tauri-plugin-share/android/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.soulstream.share" + 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") + implementation(project(":tauri-android")) +} diff --git a/crates/tauri-plugin-share/android/src/main/AndroidManifest.xml b/crates/tauri-plugin-share/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bfd60d6 --- /dev/null +++ b/crates/tauri-plugin-share/android/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/crates/tauri-plugin-share/android/src/main/java/com/soulstream/share/ShareActivity.kt b/crates/tauri-plugin-share/android/src/main/java/com/soulstream/share/ShareActivity.kt new file mode 100644 index 0000000..54444a9 --- /dev/null +++ b/crates/tauri-plugin-share/android/src/main/java/com/soulstream/share/ShareActivity.kt @@ -0,0 +1,27 @@ +package com.soulstream.share + +import android.app.Activity +import android.content.Intent +import android.os.Bundle + +/** + * The share sheet's target. Takes the text (Spotify sends the link inside a + * sentence; the WebView finds the URL in it), keeps it for the plugin, + * brings the app's own task to the front through its launcher intent — the + * running one if there is one, so the page and its player are untouched — + * and finishes without ever drawing. + */ +class ShareActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val text = intent?.getStringExtra(Intent.EXTRA_TEXT) + ?: intent?.getStringExtra(Intent.EXTRA_SUBJECT) + if (!text.isNullOrBlank()) SharePlugin.pending = text + val launch = packageManager.getLaunchIntentForPackage(packageName) + if (launch != null) { + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(launch) + } + finish() + } +} diff --git a/crates/tauri-plugin-share/android/src/main/java/com/soulstream/share/SharePlugin.kt b/crates/tauri-plugin-share/android/src/main/java/com/soulstream/share/SharePlugin.kt new file mode 100644 index 0000000..52d7f56 --- /dev/null +++ b/crates/tauri-plugin-share/android/src/main/java/com/soulstream/share/SharePlugin.kt @@ -0,0 +1,32 @@ +package com.soulstream.share + +import android.app.Activity +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 + +/** + * What another app shared to SoulStream, held until the WebView asks. + * ShareActivity puts it here (the two live in one process, so a static + * holds across the hand-off, a cold start included); `pendingShare` gives + * it out once — the second ask answers nothing. + */ +@TauriPlugin +class SharePlugin(private val activity: Activity) : Plugin(activity) { + + companion object { + @Volatile + var pending: String? = null + } + + @Command + fun pendingShare(invoke: Invoke) { + val out = JSObject() + val text = pending + pending = null + if (text != null) out.put("text", text) + invoke.resolve(out) + } +} diff --git a/crates/tauri-plugin-share/build.rs b/crates/tauri-plugin-share/build.rs new file mode 100644 index 0000000..e2e0026 --- /dev/null +++ b/crates/tauri-plugin-share/build.rs @@ -0,0 +1,13 @@ +// The one command, as the Rust function is named; the ACL slugs it +// (`allow-pending-share`) into permissions/autogenerated. +const COMMANDS: &[&str] = &["pending_share"]; + +fn main() { + // Registers the command permission and, on Android, hands the Gradle + // module under android/ to tauri-build (only with + // TAURI_ANDROID_PROJECT_PATH set, see ANDROID.md). The module carries + // the ShareActivity and its intent filter, merged into the app's manifest. + tauri_plugin::Builder::new(COMMANDS) + .android_path("android") + .build(); +} diff --git a/crates/tauri-plugin-share/permissions/autogenerated/commands/pending_share.toml b/crates/tauri-plugin-share/permissions/autogenerated/commands/pending_share.toml new file mode 100644 index 0000000..c18bc44 --- /dev/null +++ b/crates/tauri-plugin-share/permissions/autogenerated/commands/pending_share.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-pending-share" +description = "Enables the pending_share command without any pre-configured scope." +commands.allow = ["pending_share"] + +[[permission]] +identifier = "deny-pending-share" +description = "Denies the pending_share command without any pre-configured scope." +commands.deny = ["pending_share"] diff --git a/crates/tauri-plugin-share/permissions/autogenerated/reference.md b/crates/tauri-plugin-share/permissions/autogenerated/reference.md new file mode 100644 index 0000000..48200a1 --- /dev/null +++ b/crates/tauri-plugin-share/permissions/autogenerated/reference.md @@ -0,0 +1,44 @@ +## Default Permission + +The share sheet: what another app sent to SoulStream (a playlist link, a +pasted tracklist), read once by the import. + +#### This default permission set includes the following: + +- `allow-pending-share` + +## Permission Table + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`share:allow-pending-share` + + + +Enables the pending_share command without any pre-configured scope. + +
+ +`share:deny-pending-share` + + + +Denies the pending_share command without any pre-configured scope. + +
diff --git a/crates/tauri-plugin-share/permissions/default.toml b/crates/tauri-plugin-share/permissions/default.toml new file mode 100644 index 0000000..c1006a2 --- /dev/null +++ b/crates/tauri-plugin-share/permissions/default.toml @@ -0,0 +1,8 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +The share sheet: what another app sent to SoulStream (a playlist link, a +pasted tracklist), read once by the import. +""" +permissions = ["allow-pending-share"] diff --git a/crates/tauri-plugin-share/permissions/schemas/schema.json b/crates/tauri-plugin-share/permissions/schemas/schema.json new file mode 100644 index 0000000..7c7d1d8 --- /dev/null +++ b/crates/tauri-plugin-share/permissions/schemas/schema.json @@ -0,0 +1,318 @@ +{ + "$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 pending_share command without any pre-configured scope.", + "type": "string", + "const": "allow-pending-share", + "markdownDescription": "Enables the pending_share command without any pre-configured scope." + }, + { + "description": "Denies the pending_share command without any pre-configured scope.", + "type": "string", + "const": "deny-pending-share", + "markdownDescription": "Denies the pending_share command without any pre-configured scope." + }, + { + "description": "The share sheet: what another app sent to SoulStream (a playlist link, a\npasted tracklist), read once by the import.\n\n#### This default permission set includes:\n\n- `allow-pending-share`", + "type": "string", + "const": "default", + "markdownDescription": "The share sheet: what another app sent to SoulStream (a playlist link, a\npasted tracklist), read once by the import.\n\n#### This default permission set includes:\n\n- `allow-pending-share`" + } + ] + } + } +} \ No newline at end of file diff --git a/crates/tauri-plugin-share/src/lib.rs b/crates/tauri-plugin-share/src/lib.rs new file mode 100644 index 0000000..abf9bc8 --- /dev/null +++ b/crates/tauri-plugin-share/src/lib.rs @@ -0,0 +1,61 @@ +//! The share sheet, on the phone. +//! +//! Another app's Share → SoulStream (a Spotify playlist, a YouTube set, a +//! pasted tracklist) starts the plugin's `ShareActivity`, which keeps the +//! text and brings the app to the front. The WebView asks for it with +//! `pending_share` → `{ text }` — at boot, and whenever the app comes back +//! to the front — and hands it to the playlist import. Read once: the +//! second ask answers nothing. On the desktop the plugin is registered so +//! the permission set resolves, and the command answers nothing. + +use serde::{Deserialize, Serialize}; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Shared { + pub text: Option, +} + +#[cfg(mobile)] +pub struct Share(tauri::plugin::PluginHandle); + +#[tauri::command] +fn pending_share(app: tauri::AppHandle) -> Result { + #[cfg(mobile)] + { + use tauri::Manager; + let share = app.state::>(); + share + .0 + .run_mobile_plugin::("pendingShare", ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = app; + Ok(Shared::default()) + } +} + +pub fn init() -> TauriPlugin { + Builder::new("share") + .invoke_handler(tauri::generate_handler![pending_share]) + .setup(|app, api| { + #[cfg(target_os = "android")] + { + use tauri::Manager; + let handle = api.register_android_plugin("com.soulstream.share", "SharePlugin")?; + app.manage(Share(handle)); + } + #[cfg(not(target_os = "android"))] + { + let _ = (app, api); + } + Ok(()) + }) + .build() +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 68a5f3f..31729ad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -59,6 +59,7 @@ audio-formats = { path = "../crates/audio-formats" } # 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" } +tauri-plugin-share = { path = "../crates/tauri-plugin-share" } semver = "1" tauri-plugin-opener = "2.5.5" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ac259ec..847fde6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -22,6 +22,7 @@ "dialog:allow-open", "media-session:default", "opener:default", - "storage:default" + "storage:default", + "share:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c10a281..e16e1fc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -83,6 +83,8 @@ pub fn run() { 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())?; + // The share sheet on the phone: a link sent from another app lands in the import. + app.handle().plugin(tauri_plugin_share::init())?; #[cfg(desktop)] app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; // Must run before anything touches the app-data dir. diff --git a/src/lib/api.js b/src/lib/api.js index 4eceba2..241df4a 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -497,6 +497,9 @@ export const rescanLibrary = () => invoke("rescan_library"); 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"); +// The phone's share plugin (crates/tauri-plugin-share): what another app +// shared to SoulStream, { text } — read once. +export const pendingShare = () => invoke("plugin:share|pending_share"); // 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/mockLibrary.js b/src/lib/mockLibrary.js index 9fdcd5d..966293f 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -84,6 +84,199 @@ function tracksFor(albumId) { })); } +/* ── playlists: the desktop's shapes (library::Playlist rows with the + command layer's duration_secs / unresolved_count, PlaylistItemPayload + rows with `missing`). One pinned playlist holding every row kind, a + setlist with coherent cue times, and an empty one. Mutations bump the + revision and emit soulseek:playlists-changed, as the real commands do. */ +let plSeq = 3; +let plItemSeq = 100; +const PL_T0 = 1_725_000_000; +const PL = [ + { id: 1, name: "Late night drive", track_count: 0, description: "Slow, wide, mostly synths.", kind: "manual", pinned: true, cover_path: null, created_at: PL_T0, updated_at: PL_T0, revision: 1, duration_secs: 0, unresolved_count: 0 }, + { id: 2, name: "Boiler Room 2019", track_count: 0, description: null, kind: "setlist", pinned: false, cover_path: null, created_at: PL_T0 + 10, updated_at: PL_T0 + 10, revision: 1, duration_secs: 0, unresolved_count: 0 }, + { id: 3, name: "Empty one", track_count: 0, description: null, kind: "manual", pinned: false, cover_path: null, created_at: PL_T0 + 20, updated_at: PL_T0 + 20, revision: 1, duration_secs: 0, unresolved_count: 0 }, +]; +function plLocalRow(albumId, k, extra = {}) { + const t = tracksFor(albumId)[k]; + const a = albums.find((x) => x.id === albumId); + return { title: t.title, artist: a.artist, album: a.title, duration_secs: t.duration_secs, path: t.path, art_path: a.art_path, kind: "local", source_line: null, start_ms: null, note: null, track_id: null, art_url: null, missing: false, ...extra }; +} +const PL_ITEMS = { + 1: [ + plLocalRow(1, 0), plLocalRow(2, 1), plLocalRow(5, 2, { missing: true }), plLocalRow(9, 3), + { title: "One More Time", artist: "Daft Punk", album: "Discovery", duration_secs: 200, path: null, art_path: null, kind: "stream", source_line: "Daft Punk - One More Time", start_ms: null, note: null, track_id: "3135550", art_url: svgArt(200, "D"), missing: false }, + { title: "ID", artist: "ID", album: null, duration_secs: null, path: null, art_path: null, kind: "unresolved", source_line: "07 ID - ID", start_ms: null, note: null, track_id: null, art_url: null, missing: false }, + ], + 2: [plLocalRow(3, 0, { start_ms: 0 }), plLocalRow(3, 1, { start_ms: 245_000 }), plLocalRow(3, 2, { start_ms: 1_130_000 })], + 3: [], +}; +for (const id of Object.keys(PL_ITEMS)) PL_ITEMS[id].forEach((r, i) => { r.id = ++plItemSeq; r.position = i; }); +function plSync(p) { + const rows = PL_ITEMS[p.id] || []; + p.track_count = rows.length; + p.duration_secs = rows.reduce((n, r) => n + (r.duration_secs || 0), 0); + p.unresolved_count = rows.filter((r) => r.kind === "unresolved").length; +} +PL.forEach(plSync); +function plTouch(p) { + if (p) { p.revision++; p.updated_at = Math.floor(Date.now() / 1000); plSync(p); } + setTimeout(() => mockEmit("soulseek:playlists-changed", {}), 30); +} +const plOf = (args) => PL.find((p) => p.id === Number(args?.id ?? args?.playlistId)); +function mockPlaylists(cmd, args) { + if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p }))); + if (cmd === "playlist_items") return new Promise((r) => setTimeout(() => r((PL_ITEMS[Number(args?.id)] || []).map((x) => ({ ...x }))), 80)); + if (cmd === "playlist_cover") { const p = plOf(args); return Promise.resolve(p && (p.id === 1 || p.cover_path) ? svgArt(p.id === 1 ? 330 : 280, p.id === 1 ? "L" : "M") : null); } + if (cmd === "create_playlist") { + const p = { id: ++plSeq, name: args?.name || "New Playlist", track_count: 0, description: null, kind: args?.kind || "manual", pinned: false, cover_path: null, created_at: Math.floor(Date.now() / 1000), updated_at: 0, revision: 0, duration_secs: 0, unresolved_count: 0 }; + PL.push(p); + PL_ITEMS[p.id] = []; + plTouch(p); + return Promise.resolve(p.id); + } + if (cmd === "rename_playlist") { const p = plOf(args); if (p) p.name = args.name; plTouch(p); return Promise.resolve(); } + if (cmd === "set_playlist_description") { const p = plOf(args); if (p) p.description = args.description || null; plTouch(p); return Promise.resolve(); } + if (cmd === "set_playlist_pinned") { const p = plOf(args); if (p) p.pinned = !!args.pinned; plTouch(p); return Promise.resolve(); } + if (cmd === "duplicate_playlist") { + const p = plOf(args); + if (!p) return Promise.reject(new Error("duplicate_playlist: no such playlist")); + const c = { ...p, id: ++plSeq, name: args?.name || p.name + " copy", pinned: false, revision: 0 }; + PL.push(c); + PL_ITEMS[c.id] = (PL_ITEMS[p.id] || []).map((x, i) => ({ ...x, id: ++plItemSeq, position: i })); + plTouch(c); + return Promise.resolve(c.id); + } + if (cmd === "delete_playlist") { + const i = PL.findIndex((p) => p.id === Number(args?.id)); + if (i >= 0) { delete PL_ITEMS[PL[i].id]; PL.splice(i, 1); } + plTouch(null); + return Promise.resolve(); + } + if (cmd === "playlist_remove_items") { + const p = plOf(args); + const ids = new Set((args?.itemIds || []).map(Number)); + if (p) { PL_ITEMS[p.id] = (PL_ITEMS[p.id] || []).filter((x) => !ids.has(x.id)); PL_ITEMS[p.id].forEach((x, i) => { x.position = i; }); } + plTouch(p); + return Promise.resolve(); + } + if (cmd === "playlist_add_items") { + const p = plOf(args); + if (!p) return Promise.reject(new Error("playlist_add_items: no such playlist")); + const rows = (args?.items || []).map((it) => ({ + title: it.title, artist: it.artist || "", album: it.album ?? null, duration_secs: it.duration_secs ?? null, + path: it.path ?? null, art_path: it.art_path ?? null, + kind: it.path ? "local" : (it.title && it.artist ? "stream" : "unresolved"), + source_line: it.source_line ?? null, start_ms: it.start_ms ?? null, note: it.note ?? null, + track_id: it.track_id ?? null, art_url: it.art_url ?? null, missing: false, id: ++plItemSeq, + })); + const list = PL_ITEMS[p.id]; + const at = args?.at == null ? list.length : Math.min(Number(args.at), list.length); + list.splice(at, 0, ...rows); + list.forEach((x, i) => { x.position = i; }); + plTouch(p); + return Promise.resolve(rows.map((r) => r.id)); + } + return undefined; +} + +/* ── the import doors: a pasted tracklist, a link, a YouTube set — a draft + in the backend's shape (ImportDraft / DraftEntry, resolved against the + mock library), then commit makes the playlist ── */ +function impEntry(ordinal, artist, title, raw, start_ms = null) { + const own = albums.find((a) => a.artist.toLowerCase() === artist.toLowerCase()); + const tr = own ? tracksFor(own.id).find((t) => t.title.toLowerCase() === title.toLowerCase()) : null; + const weak = !artist || !title || title.toLowerCase() === "id"; + return { + ordinal, title, artist, album: tr ? own.title : null, + duration_secs: tr ? tr.duration_secs : (weak ? null : 200 + ((ordinal * 37) % 180)), + start_ms, raw, + resolution: tr ? "local" : weak ? "unresolved" : "stream", + matched_by: tr ? "tags" : null, + path: tr ? tr.path : null, art_path: tr ? own.art_path : null, + track_id: null, art_url: null, isrc: null, + }; +} +function impParse(text) { + const out = []; + for (const raw of String(text || "").split(/\r?\n/)) { + const line = raw.trim(); + if (!line) continue; + const m = /^(?:(\d+):(\d\d)(?::(\d\d))?\s+)?(.*?)\s+[-–]\s+(.*)$/.exec(line); + const start_ms = m && m[1] != null ? ((m[3] != null ? (+m[1] * 3600 + +m[2] * 60 + +m[3]) : (+m[1] * 60 + +m[2])) * 1000) : null; + out.push(impEntry(out.length, m ? m[4].trim() : "", m ? m[5].trim() : line, raw, start_ms)); + } + return out; +} +function impDraft(entries, source_name, format, name = null, extra = {}) { + return { + source_name, format, name, description: null, + kind: entries.some((e) => e.start_ms != null) ? "setlist" : "manual", + entries, warnings: [], + resolved_count: entries.filter((e) => e.resolution === "local").length, + stream_count: entries.filter((e) => e.resolution === "stream").length, + unresolved_count: entries.filter((e) => e.resolution === "unresolved").length, + cover_url: null, + ...extra, + }; +} +function mockImport(cmd, args) { + if (cmd === "playlist_import_text") { + const entries = impParse(args?.text); + if (!entries.length) return Promise.reject(new Error("Nothing in that text reads as a track.")); + return new Promise((r) => setTimeout(() => r(impDraft(entries, "Pasted tracklist", "text")), 400)); + } + if (cmd === "playlist_import_link" || cmd === "playlist_import_youtube") { + const url = String(args?.url || ""); + if (!/^https?:\/\//.test(url)) return Promise.reject(new Error("That is not a link.")); + const set = cmd === "playlist_import_youtube"; + const format = set ? "youtube" : /spotify/.test(url) ? "spotify" : /apple/.test(url) ? "apple-music" : /tidal/.test(url) ? "tidal" : /deezer/.test(url) ? "deezer" : /youtu/.test(url) ? "youtube-playlist" : null; + if (!format) return Promise.reject(new Error("That link is not a playlist SoulStream can read.")); + const lines = set + ? ["00:00 Daft Punk - One More Time", "05:20 Daft Punk - Aerodynamic", "09:00 ID - ID", "12:10 Aphex Twin - Track 1"] + : ["Daft Punk - One More Time", "Daft Punk - Aerodynamic", "Daft Punk - Digital Love", "Aphex Twin - Track 1", "ID - ID"]; + const entries = impParse(lines.join("\n")); + return new Promise((r) => { + let done = 0; + const total = entries.length; + const tick = setInterval(() => { + done++; + mockEmit("playlist:link-progress", { done, total, waiting: false }); + if (done >= total) { + clearInterval(tick); + setTimeout(() => r(impDraft(entries, + set ? "Mock Channel — Boiler Room 2019" : "Mock Curator — Late Night Mix", format, + set ? "Boiler Room 2019 (live)" : "Late Night Mix", + { cover_url: svgArt(280, "M"), warnings: set ? ["Tracklist from the video's chapters."] : [] })), 200); + } + }, 180); + }); + } + if (cmd === "playlist_import_commit") { + const name = String(args?.name || "").trim(); + if (!name) return Promise.reject(new Error("The playlist needs a name")); + const p = { id: ++plSeq, name, track_count: 0, description: args?.description ?? null, kind: args?.kind === "setlist" ? "setlist" : "manual", pinned: false, cover_path: args?.coverUrl ? "mock://cover" : null, created_at: Math.floor(Date.now() / 1000), updated_at: 0, revision: 0, duration_secs: 0, unresolved_count: 0 }; + PL.push(p); + PL_ITEMS[p.id] = (args?.entries || []).map((e, i) => ({ + id: ++plItemSeq, position: i, title: e.title, artist: e.artist, album: e.album ?? null, duration_secs: e.duration_secs ?? null, + path: e.path ?? null, art_path: e.art_path ?? null, + kind: e.path ? "local" : e.resolution === "unresolved" ? "unresolved" : "stream", + source_line: e.raw ?? null, start_ms: e.start_ms ?? null, note: null, + track_id: e.track_id != null ? String(e.track_id) : null, art_url: e.art_url ?? null, missing: false, + })); + plTouch(p); + return Promise.resolve(p.id); + } + if (cmd === "catalog_search_tracks") { + const words = String(args?.query || "").toLowerCase().replace(/\b(artist|track|album):/g, " ").replace(/["']/g, " ").split(/\s+/).filter(Boolean); + const hits = CAT_TRACKS.filter((t) => { const hay = (t.artist + " " + t.title + " " + t.album).toLowerCase(); return words.every((w) => hay.includes(w)); }); + return new Promise((r) => setTimeout(() => r(hits.slice(0, args?.limit || 10)), 90)); + } + if (cmd === "catalog_track_by_isrc") return Promise.resolve(null); + if (cmd === "catalog_artist_track_candidates") return Promise.resolve([]); + return undefined; +} + const enabled = (() => { try { return localStorage.getItem("mockLibrary") === "1"; @@ -95,6 +288,7 @@ const enabled = (() => { /** Returns a Promise for mocked commands, or undefined to pass through. */ export function mockInvoke(cmd, args) { if (!enabled) return undefined; + if (cmd === "app_status") return Promise.resolve({ version: "mock", connected_as: null }); if (cmd === "library_albums") return Promise.resolve(albums); if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor)); if (cmd === "recent_albums") return Promise.resolve([]); @@ -105,7 +299,7 @@ export function mockInvoke(cmd, args) { const num = m ? +m[1] : 0; return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26)))); } - if (cmd === "list_playlists") return Promise.resolve([]); + { const pl = mockPlaylists(cmd, args) ?? mockImport(cmd, args); if (pl) return pl; } // 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") { @@ -120,7 +314,9 @@ export function mockInvoke(cmd, args) { ? 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); + const r = mockCatalog(cmd, args) ?? mockNet(cmd, args); + if (r === undefined) console.warn("[mock] no mock for " + cmd + " — the real IPC is not here"); + return r; } /* ── the fake network ─────────────────────────────────────────────────── @@ -270,22 +466,40 @@ function mockCatalog(cmd, args) { 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; + cacheSizes.set(path, args?.size || 0); 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 === "get_settings") return Promise.resolve({ ...mockSettings, stream_source_prefs: { ...mockSettings.stream_source_prefs }, download_source_prefs: { ...mockSettings.download_source_prefs } }); + if (cmd === "set_settings") { + /* the command's arguments are camelCase; the config's fields are snake_case */ + for (const [k, v] of Object.entries(args || {})) mockSettings[k.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase())] = v; + 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(); + if (cmd === "plugin:share|pending_share") { + /* the harness shares by setting window.__ssMock.share and waking the page */ + const t = (typeof window !== "undefined" && window.__ssMock?.share) || null; + if (t && window.__ssMock) window.__ssMock.share = null; + return Promise.resolve({ text: t }); + } return undefined; } let token = 0; let downloadSeq = 0; -const cacheReady = new Set(); // paths a preload has fully fetched +const mockSettings = { + download_dir: "C:\\mock\\Library", + volume_normalization: true, + stream_source_prefs: { max: "any", min: "any", priority: "quality" }, + download_source_prefs: { max: "any", min: "any", priority: "quality" }, +}; +const cacheReady = new Set(); // paths a preload or a finished stream has fully fetched +const cacheSizes = new Map(); // path → the file's size, for stream_cache_len const preloading = new Set(); let xfer = null; // the one fake transfer: { received, total, timer } let posTimer = null; // the fake engine's position ticks @@ -328,17 +542,38 @@ function mockNet(cmd, args) { return Promise.resolve(t); } if (cmd === "stop_search") return Promise.resolve(); + if (cmd === "player_state") return Promise.resolve({ status: "idle", position: 0 }); + if (cmd === "preload_local") return Promise.resolve(); + if (cmd === "waveform") { + const n = args?.buckets || 900; + return new Promise((r) => setTimeout(() => r(Array.from({ length: n }, (_, i) => 0.2 + 0.75 * Math.abs(Math.sin(i * 0.23) * Math.cos(i * 0.017)))), 120)); + } + if (cmd === "lyrics_get") return Promise.resolve(null); + if (cmd === "play_local") { + /* a file: the engine reports playing after a beat, then the position + from where it was asked to start */ + if (xfer) { clearInterval(xfer.timer); xfer = null; } + clearInterval(posTimer); + const duration = args?.duration || 240; + let secs = Number(args?.startSecs) || 0; + later(300, () => { + mockEmit("soulseek:player", { kind: "playing" }); + posTimer = setInterval(() => { secs += 0.5; mockEmit("soulseek:player", { kind: "position", secs, duration }); }, 500); + }); + 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 }; + cacheSizes.set(args?.path, total); 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); + if (done) { clearInterval(xfer.timer); cacheReady.add(xfer.path); } }, 400); let secs = 0; later(1200, () => { @@ -353,7 +588,18 @@ function mockNet(cmd, args) { 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_cache_len") return Promise.resolve(cacheReady.has(args?.path) ? (cacheSizes.get(args.path) || 0) : (xfer && xfer.path === args?.path ? Math.round(xfer.received) : 0)); + if (cmd === "save_to_library") { + /* a byte-complete cache files at once; anything else goes to the download pipeline */ + let organized = 0, queued = 0; + for (const f of args?.files || []) { + if (cacheReady.has(f.path)) { organized++; continue; } + queued++; + mockInvoke("download_file", { username: f.username, path: f.path, size: f.size }); + } + if (organized) later(30, () => mockEmit("soulseek:library-changed", {})); + return Promise.resolve([organized, queued]); + } 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(); diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index caea356..e07f45e 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -9,8 +9,10 @@ import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js"; import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js"; import { streamSong, saveSong } from "./resolve.svelte.js"; + import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist } from "./playlists.svelte.js"; + import { DOORS, start as startImport } from "./importer.svelte.js"; import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js"; - import { albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js"; + import { lib, albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js"; import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js"; const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]]; @@ -26,6 +28,7 @@ const t = top(); if (t.n === "album") return "album-more"; if (t.n === "library") return "library-more"; + if (t.n === "playlist") return "playlist-more"; return "home-more"; }); @@ -51,6 +54,94 @@ const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id); const after = $derived(Math.max(0, player.queue.length - player.qi - 1)); + /* ── playlists: the shelf card's sheet, the page's overflow (the bar's + More resolves to it), a row's sheet, the pick-a-playlist sheet, and + the prompt / confirm sheets they open ── */ + const plArg = $derived.by(() => { + if (kind === "playlist") { + const id = shown?.arg?.playlist?.id; + if (id == null) return null; + return { playlist: lib.playlists.find((p) => p.id === id) || shown.arg.playlist, items: null }; + } + if (kind !== "playlist-more") return null; + const id = shown?.arg?.playlist?.id ?? top().c.id; + const p = lib.playlists.find((x) => x.id === id); + if (!p) return null; + return { playlist: p, items: pls.current?.id === p.id ? pls.current.items : (shown?.arg?.items || []) }; + }); + const oops = (e) => { player.error = String(e); }; + async function playPl(p, shuffle = false) { + try { + const items = (await api.playlistItems(p.id)) || []; + if (!(shuffle ? shufflePlay(p, items) : playFrom(p, items))) player.error = "Nothing in “" + p.name + "” can play yet."; + } catch (e) { oops(e); } + } + function confirmDelete(p) { + openHalf("confirm", { + title: "Delete “" + p.name + "”?", + body: (p.track_count ? "Its " + p.track_count + " row" + (p.track_count === 1 ? "" : "s") + " go with it. " : "") + "The files stay in your library.", + ok: "Delete", danger: true, + onconfirm: async () => { + try { + if (top().n === "playlist" && top().c.id === p.id) back(); + await deletePlaylist(p); + } catch (e) { oops(e); } + }, + }); + } + /* the prompts take the playlist as a VALUE: a {@const} of the sheet's + block is gone by the time the prompt submits (the sheet switched + kinds), and reading it then is a stale derived */ + function renamePrompt(p) { + openHalf("prompt", { title: "Rename playlist", value: p.name, placeholder: "Name", ok: "Rename", onsubmit: (v) => renamePlaylist(p, v).catch(oops) }); + } + function describePrompt(p) { + openHalf("prompt", { title: "Description", value: p.description || "", placeholder: "What this playlist is", ok: "Save", multiline: true, allowEmpty: true, onsubmit: (v) => describePlaylist(p, v).catch(oops) }); + } + function newPlaylistThenAdd(entries) { + openHalf("prompt", { + title: "New playlist", placeholder: "Name", value: "New Playlist " + (lib.playlists.length + 1), ok: "Create", + onsubmit: async (name) => { + try { + const id = await createPlaylist(name); + await addEntries(lib.playlists.find((x) => x.id === id) || { id, name }, entries); + } catch (e) { oops(e); } + }, + }); + } + /* the import doors: a prompt for the link or the text, then the review + screen while the backend reads and resolves */ + function importPrompt(door) { + const d = DOORS.find((x) => x[0] === door); + openHalf("prompt", { + title: d[2].replace(/…$/, ""), + ok: "Import", + multiline: door === "text", + placeholder: door === "text" ? "00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby\n…or pasted track links" + : door === "youtube" ? "https://www.youtube.com/watch?v=…" + : "https://open.spotify.com/playlist/…", + onsubmit: (v) => { push("import", {}); void startImport(door, v); }, + }); + } + /* the prompt's field, seeded when the prompt opens */ + let promptVal = $state(""); + $effect(() => { + const h = nav.half; + if (h?.kind === "prompt") promptVal = h.arg?.value ?? ""; + }); + function submitPrompt() { + const a = shown?.arg; + if (!a) return; + const v = promptVal.trim(); + if (!v && !a.allowEmpty) return; + closeHalf(); + a.onsubmit?.(v); + } + function focusLater(node) { + const t = setTimeout(() => node.focus(), 280); + return { destroy: () => clearTimeout(t) }; + } + /* a catalog track (no file on this phone): the resolver plays it, Save fetches it — the desktop's row buttons, on the sheet */ const isCatalog = (t) => !!t && !t.path && !!t.catalog; @@ -100,6 +191,7 @@ {#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); })} + {@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: 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 })))} @@ -110,6 +202,7 @@ {: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 }])))} + {@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
{#if album?.id != null && !onThisAlbum(album.id)} {@render row("disc", "Go to album", album.title, act(() => push("album", { id: album.id })))} @@ -137,6 +230,9 @@
{@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 })))))} + {#if tracks.length} + {@render row("playlist", "Add to playlist", tracks.length + " tracks", () => openHalf("pickPlaylist", { entries: tracks.map((t) => ({ t, album })) }))} + {/if}
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
@@ -145,6 +241,108 @@
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 === "playlist" || kind === "playlist-more") && plArg} + {@const p = plArg.playlist} + {@const rows = plArg.items || []} + {@const unres = rows.filter((i) => i.kind === "unresolved")} +
+ {#if coverFor(p)}{:else}{/if} + {p.name}{p.kind === "setlist" ? "Setlist" : "Playlist"} · {p.track_count} track{p.track_count === 1 ? "" : "s"}{p.duration_secs ? " · " + longDur(p.duration_secs) : ""}{p.pinned ? " · pinned" : ""} +
+
+ {#if kind === "playlist"} + {@render row("play", "Play", "", act(() => playPl(p)))} + {@render row("shuffle", "Shuffle", "", act(() => playPl(p, true)))} +
+ {/if} + {@render row("pin", p.pinned ? "Unpin" : "Pin", p.pinned ? "" : "keeps it at the top of the shelf", act(() => togglePin(p).catch(oops)))} + {@render row("pencil", "Rename", "", () => renamePrompt(p))} + {#if kind === "playlist-more"} + {@render row("file-text", p.description ? "Edit description" : "Add a description", p.description || "", () => describePrompt(p))} + {/if} + {@render row("layers", "Duplicate", "a copy, “" + p.name + " copy”", act(() => duplicatePlaylist(p).then((id) => push("playlist", { id })).catch(oops)))} + {#if kind === "playlist-more" && unres.length} + {@render row("x", "Remove unresolved entries", unres.length + " that never matched a track", act(() => removeItems(p, rows, unres).catch(oops)))} + {/if} + {#if kind === "playlist-more" && bulkable(rows) && !bulk.active} +
+ {@render row("library", "Add to library", "files every already-streamed track — nothing is downloaded", act(() => { void runBulk(p, rows, "cached"); }))} + {@render row("download", "Download all tracks", bulkable(rows) + " not in the library yet, by your download rules", act(() => { void runBulk(p, rows, "download"); }))} + {/if} +
+ {@render row("trash", "Delete playlist", "", () => confirmDelete(p))} +
+ {:else if kind === "pltrack" && shown?.arg?.item} + {@const p = shown.arg.playlist} + {@const rows = shown.arg.items || []} + {@const item = shown.arg.item} +
+ {#if item.path && item.art_path}{:else if item.art_url}{:else}{/if} + {item.title}{item.artist}{item.album ? " · " + item.album : ""}{item.kind === "unresolved" ? " · unresolved" : item.missing ? " · file missing" : item.kind === "stream" ? " · streams from the network" : ""} +
+
+ {#if queueable(item)} + {@render row("play", "Play from here", "the rest of the playlist follows", act(() => playFrom(p, rows, item)))} + {@render row("skip-forward", "Play next", "", act(() => playNext([entryOf(item, p)])))} + {@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([entryOf(item, p)])))} + {#if !item.path} + {@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack({ title: item.title, artist: item.artist }, null); })} + {/if} + {:else if item.kind === "unresolved"} +
This row never matched a track{item.source_line ? " — it came in as “" + item.source_line + "”" : ""}. Repair it on the desktop, or remove it.
+ {:else if item.missing} +
The file is no longer where the library last saw it.
+ {/if} + {#if item.artist} + {@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))} + {/if} +
+ {@render row("x", "Remove from playlist", "undo for a few seconds after", act(() => removeItems(p, rows, [item]).catch(oops)))} +
+ {:else if kind === "pickPlaylist" && shown?.arg?.entries} + {@const entries = shown.arg.entries} +
Add to playlist · {entries.length === 1 ? entries[0].t.title : entries.length + " tracks"}
+
+ {@render row("plus", "New playlist", "", () => newPlaylistThenAdd(entries))} +
+ {#each lib.playlists as p (p.id)} + + {/each} + {#if !lib.playlists.length}
No playlists yet — make one above.
{/if} +
+ {:else if kind === "prompt" && shown?.arg} + {@const a = shown.arg} +
{a.title}
+
+ {#if a.multiline} + + {:else} + { if (e.key === "Enter") submitPrompt(); }} use:focusLater /> + {/if} +
+ + +
+
+ {:else if kind === "confirm" && shown?.arg} + {@const a = shown.arg} +
{a.title}
+ {#if a.body}
{a.body}
{/if} +
+ + +
+ {:else if kind === "import"} +
Import a playlist
+
+ {#each DOORS as [door, icon, t, m] (door)} + {@render row(icon, t, m, () => importPrompt(door))} + {/each} +
+
A playlist file or a folder of audio files imports on the desktop, where the files are.
{:else if kind === "librarysort"}
Sort & view
Order
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index 7114cc2..6a8d913 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -22,6 +22,7 @@ import Artist from "./screens/Artist.svelte"; import CatalogAlbum from "./screens/CatalogAlbum.svelte"; import Playlist from "./screens/Playlist.svelte"; + import Import from "./screens/Import.svelte"; import Community from "./screens/Community.svelte"; import Room from "./screens/Room.svelte"; import Thread from "./screens/Thread.svelte"; @@ -40,6 +41,10 @@ import { slsk, bindSlsk, connectStored } from "./slsk.svelte.js"; import { net, bindNet } from "./net.svelte.js"; import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js"; + import { notice, clearNote } from "./notice.svelte.js"; + import * as pls from "./playlists.svelte.js"; + import { importer, bindImporter } from "./importer.svelte.js"; + import { bindShare } from "./share.svelte.js"; import * as sources from "../lib/sources.js"; import { greeting, drag } from "./util.js"; @@ -62,6 +67,7 @@ case "thread": return { title: e.c.username, kicker: "Chat", flush: true }; case "you": return { title: "You", kicker: "You" }; case "transfers": return { title: "Transfers", kicker: "Downloads" }; + case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" }; case "album": { const a = albumById(e.c.id); return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true }; @@ -79,7 +85,7 @@ } case "playlist": { const p = lib.playlists.find((x) => x.id === e.c.id); - return { title: p?.name ?? "Playlist", kicker: "Playlist", flush: true }; + return { title: p?.name ?? "Playlist", kicker: p?.kind === "setlist" ? "Setlist" : "Playlist", flush: true }; } default: return { title: e.n, kicker: "" }; } @@ -174,9 +180,11 @@ watchLibrary(); bindTransfers(); bindHome(); + bindImporter(); + bindShare(); /* the probe handle — the desktop has __soulstream for the same reason: tests/mobile/smoke.mjs drives the real app through it */ - window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, next, prev, toggle, seekSecs, playQueueAt, patch }; + window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, next, prev, toggle, seekSecs, playQueueAt, patch }; return () => { if (unlisten) unlisten(); }; }); @@ -214,6 +222,8 @@ {:else if entry.n === "playlist"} + {:else if entry.n === "import"} + {:else if entry.n === "community"} {:else if entry.n === "room"} @@ -279,4 +289,10 @@ {#if player.error || lib.error}
{ player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}
{/if} + {#if notice.text} +
+ {notice.text} + {#if notice.action}{/if} +
+ {/if} diff --git a/src/mobile/PlaylistShelf.svelte b/src/mobile/PlaylistShelf.svelte new file mode 100644 index 0000000..902c9c5 --- /dev/null +++ b/src/mobile/PlaylistShelf.svelte @@ -0,0 +1,75 @@ + + +
+ {#if lib.playlists.length} + {lib.playlists.length} playlist{lib.playlists.length === 1 ? "" : "s"} · {totals.tracks.toLocaleString()} track{totals.tracks === 1 ? "" : "s"}{totals.secs ? " · " + longDur(totals.secs) : ""}{pinned.length ? " · " + pinned.length + " pinned" : ""} + {:else} + No playlists yet + {/if} +
+
+ + +
+ +{#if !lib.playlists.length} +
No playlists on this phone yet.Make one here, or add a track to a new one from its sheet. Playlists are part of the library folder, so the ones the desktop keeps come over with it.
+{/if} + +{#snippet card(p)} + push("playlist", { id: p.id }, screenEl)} onmore={() => openHalf("playlist", { playlist: p })} /> +{/snippet} + +{#if pinned.length} +

Pinned

+
+ {#each pinned as p (p.id)}{@render card(p)}{/each} +
+ {#if rest.length}

All playlists

{/if} +{/if} +{#if rest.length} +
+ {#each rest as p (p.id)}{@render card(p)}{/each} +
+{/if} diff --git a/src/mobile/Release.svelte b/src/mobile/Release.svelte index 81000e1..7976498 100644 --- a/src/mobile/Release.svelte +++ b/src/mobile/Release.svelte @@ -6,7 +6,7 @@ /* `path` is a library cover (through the thumbnail cache); `url` a catalog cover, shown straight from the network */ - let { title, sub = "", path = null, url = null, badge = null, w2 = false, onclick, onmore = null } = $props(); + let { title, sub = "", path = null, url = null, badge = null, mark = null, icon = "disc", w2 = false, onclick, onmore = null } = $props(); const src = $derived(url || (path ? art[path] : "")); function hold(e) { if (!onmore) return; @@ -18,7 +18,8 @@ + +{:else if importer.error && !w} +
That didn't import.{importer.error}
+
+{:else if w} +
+
+ {FORMAT_LABEL[w.format] ?? w.format} + {#if w.kind === "setlist"}Setlist{/if} +
+
{w.source_name}
+
{w.resolved_count} in library{#if w.stream_count > 0}{" · "}{found}/{w.stream_count} in catalog{/if}{#if w.unresolved_count > 0}{" · "}{w.unresolved_count} unknown{/if}
+ {#if w.warnings?.length} +
{#each w.warnings as x, i (i)}
{x}
{/each}
+ {/if} + {#if w.format === "spotify" && w.entries.length >= 100} +
Spotify shows the first 100 here. For the rest, paste the playlist's track links as a tracklist.
+ {/if} +
+
+ {#each w.entries as e (e.ordinal)} + {@const [cls, label] = verdict(e)} +
+ {w.kind === "setlist" ? stamp(e.start_ms) : e.ordinal + 1} + {e.title}{e.artist}{e.album ? " — " + e.album : ""} + {label} + {e.duration_secs != null ? mmss(e.duration_secs) : ""} +
+ {/each} +
+
+ + {#if w.unresolved_count > 0} + + {/if} +
+ {#if importer.error}
{importer.error}
{/if} +
+ + +
+{:else} +
Nothing to review.Open a door from the Playlists shelf.
+
+{/if} + +
diff --git a/src/mobile/screens/Library.svelte b/src/mobile/screens/Library.svelte index f695624..68040a5 100644 --- a/src/mobile/screens/Library.svelte +++ b/src/mobile/screens/Library.svelte @@ -6,6 +6,7 @@ import Release from "../Release.svelte"; import Cover from "../Cover.svelte"; import Tracks from "./Tracks.svelte"; + import PlaylistShelf from "../PlaylistShelf.svelte"; import * as api from "../../lib/api.js"; import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js"; import { push, go, patch, openHalf } from "../nav.svelte.js"; @@ -136,19 +137,7 @@ {: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.
diff --git a/src/mobile/screens/Playlist.svelte b/src/mobile/screens/Playlist.svelte index 2fea1fa..7a5eb67 100644 --- a/src/mobile/screens/Playlist.svelte +++ b/src/mobile/screens/Playlist.svelte @@ -1,70 +1,137 @@ {#if pl} -
-
{#if cover}{:else}{/if}
+
+
{#if cover}{:else}{/if}
+
{isSetlist(pl) ? "Setlist" : "Playlist"}{#if pl.pinned}Pinned{/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} + {#if pl.description}
{pl.description}
{/if} +
{items.length} track{items.length === 1 ? "" : "s"}{total ? ` · ${longDur(total)}` : ""}{#if unresolved}{" · "}{unresolved} unresolved{/if}{#if missing}{" · "}{missing} missing{/if}
- - + + +
+ {#if bulk.playlistId === pl.id && (bulk.active || bulk.summary)} +
+ {#if bulk.active} + + {bulk.mode === "cached" ? "Adding streamed tracks to the library" : "Downloading tracks"} — {bulk.done}/{bulk.total}{bulk.organized ? ` · ${bulk.organized} saved` : ""}{bulk.queued ? ` · ${bulk.queued} downloading` : ""}{bulk.missed ? ` · ${bulk.missed} missed` : ""} + + {:else} + + {bulk.summary} + + {/if} +
+ {/if}
-
- {#each items as x, i (x.id)} - - {/each} -
+ + {#if loaded && !items.length} +
This playlist is empty.Add tracks from a record's or a track's sheet — long-press one and choose Add to playlist.
+ {:else} + {#if items.length} +
+ + (q = e.currentTarget.value)} type="search" placeholder="Search in playlist" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search in playlist" /> + {#if q}{/if} +
+ {/if} + {#if loaded && items.length && !visible.length} +
Nothing here matches “{q}”.
+ {/if} +
+ {#each visible as item, i (item.id)} + {@const now = nowItemId != null && nowItemId === item.id} + + {/each} +
+ {/if} {:else} -
That playlist is gone.
+
Playlist not found.It may have been deleted.
{/if}
diff --git a/src/mobile/screens/You.svelte b/src/mobile/screens/You.svelte index 4fc169d..57c7e40 100644 --- a/src/mobile/screens/You.svelte +++ b/src/mobile/screens/You.svelte @@ -8,6 +8,7 @@ import { transfers, active } from "../transfers.svelte.js"; import { player } from "../player.svelte.js"; import { settings, setSetting } from "../prefs.svelte.js"; + import { refreshSourcePrefs } from "../../lib/sources.js"; let cfg = $state(null); let scanning = $state(false); @@ -17,7 +18,7 @@ const normalization = $derived(cfg ? cfg.volume_normalization !== false : true); async function toggleNormalization() { const next = !normalization; - try { await api.setSettings({ volume_normalization: next }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); } + try { await api.setSettings({ volumeNormalization: next }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); } } /* the library folder: picked on the phone, set as the app's download dir (the folder IS the library), rescanned. A folder in shared storage @@ -39,7 +40,7 @@ try { const r = await api.pickLibraryFolder(); if (!r?.path) { player.error = "That folder has no path the phone can scan — pick one on this phone's storage."; return; } - await api.setSettings({ download_dir: r.path }); + await api.setSettings({ downloadDir: r.path }); cfg = await api.getSettings(); await rescan(); } catch (e) { @@ -53,6 +54,24 @@ scanning = false; } + /* the desktop's Source quality settings (Settings → Sources): a window + per mode and the priority inside it; the resolver re-reads them */ + const MODES = [["stream", "Streaming"], ["download", "Downloads"]]; + const MAX_Q = [["any", "No limit"], ["lossy_only", "Lossy only"]]; + const MIN_Q = [["any", "Any quality"], ["kbps192", "192 kbps and up"], ["kbps256", "256 kbps and up"], ["kbps320", "320 kbps and up"], ["lossless", "Lossless only"]]; + const PRI = [["quality", "Best quality"], ["speed", "Fastest transfer"]]; + const PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" }; + const prefsOf = (mode) => ({ ...PREF_DEFAULTS, ...((mode === "stream" ? cfg?.stream_source_prefs : cfg?.download_source_prefs) ?? {}) }); + async function setPref(mode, field, value) { + if (prefsOf(mode)[field] === value) return; + const next = { ...prefsOf(mode), [field]: value }; + try { + await api.setSettings(mode === "stream" ? { streamSourcePrefs: next } : { downloadSourcePrefs: next }); + cfg = await api.getSettings(); + await refreshSourcePrefs(); + } catch (e) { player.error = String(e); } + } + const ROWS = [ ["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"], ["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"], @@ -110,6 +129,32 @@
+
Sources
+
Independent for streaming and downloads. The ceiling and floor bound which peer files qualify; priority decides whether the resolver takes the best format or the fastest transfer among them.
+{#each MODES as [key, label] (key)} +
+
{label}
+
+
Maximumlossy only avoids huge lossless transfers
+
+ {#each MAX_Q as [v, l] (v)}{/each} +
+
+
+
Minimumfiles below the floor are skipped; a lossy file must report its bitrate
+
+ {#each MIN_Q as [v, l] (v)}{/each} +
+
+
+
Priorityamong the files inside that window
+
+ {#each PRI as [v, l] (v)}{/each} +
+
+
+{/each} +
This phone
SoulStream{lib.version || "—"}
@@ -117,8 +162,8 @@
- 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. + The network and the account come next. + They are the desktop's settings pages, drawn with the phone's components.
diff --git a/src/mobile/share.svelte.js b/src/mobile/share.svelte.js new file mode 100644 index 0000000..3c9549e --- /dev/null +++ b/src/mobile/share.svelte.js @@ -0,0 +1,32 @@ +/* A share from another app (the share sheet → the share plugin's + ShareActivity) lands in the playlist import: a playlist link opens the + link door, anything else is read as a pasted tracklist. Asked for at + boot and whenever the app comes back to the front — that is when the + hand-off has happened. */ +import * as api from "../lib/api.js"; +import { go, patch, push } from "./nav.svelte.js"; +import { start as startImport } from "./importer.svelte.js"; + +const URL_RE = /https?:\/\/[^\s<>"']+/i; +let checking = false; +export async function checkShare() { + if (checking) return; + checking = true; + try { + const r = await api.pendingShare(); + const text = String(r?.text || "").trim(); + if (!text) return; + const url = URL_RE.exec(text)?.[0] || null; + go("library"); + patch({ tab: "playlists" }); + push("import", {}); + void startImport(url ? "link" : "text", url || text); + } catch { /* not a phone, or the plugin is not here */ } finally { checking = false; } +} +let bound = false; +export function bindShare() { + if (bound) return; + bound = true; + void checkShare(); + document.addEventListener("visibilitychange", () => { if (!document.hidden) void checkShare(); }); +} diff --git a/src/mobile/shell.css b/src/mobile/shell.css index a687373..6211dc3 100644 --- a/src/mobile/shell.css +++ b/src/mobile/shell.css @@ -1258,3 +1258,80 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion=" transition-duration: 0.01ms !important; } } + +/* ── playlists: the desktop's shelf and page, at phone scale ─────────── */ +.plstats { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 var(--pad) 12px; } +.plstats .t-sub { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.newpl { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px 0 10px; font-size: 13px; font-weight: 750; flex: none; } +.plsec { padding-top: 6px; } +.plsec h2 { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); } +.plgrid { padding-bottom: 8px; } +/* the pinned mark on a card, the desktop's pinned-mark */ +.cover-wrap .mark { position: absolute; top: 7px; left: 7px; z-index: 2; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 0, 0, 0.55); color: #fff; display: grid; place-items: center; } +/* the page: the record page's shape with a smaller cover (a mosaic reads at any size) */ +.rec-head.pl .rec-cover { width: 56%; max-width: 260px; } +.rec-meta .kind .pin-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 8px; padding: 2px 7px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent-hover); letter-spacing: 0.4px; vertical-align: 1px; } +.rec-desc { margin-top: 8px; font-size: 13.5px; line-height: 1.45; color: var(--text-secondary); padding: 0 8px; } +.rec-line .warn { color: #f5a524; font-weight: 650; } +.sfield.plfilter { margin-top: 10px; } +.pl-rows .trk .thumb { width: 38px; height: 38px; border-radius: 6px; overflow: hidden; flex: none; background: rgba(255, 255, 255, 0.06); display: grid; place-items: center; color: var(--text-muted); } +.pl-rows .trk .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.pl-rows .trk .n.stamped { width: auto; min-width: 22px; font-size: 11.5px; } +.pl-rows .trk.unresolved .t, .pl-rows .trk.missing .t { color: var(--text-secondary); } +.pl-rows .trk .t .tag { margin-left: 6px; vertical-align: 1px; } +.tag.un { background: rgba(232, 163, 61, 0.16); color: #e8a33d; } +.tag.miss { background: rgba(241, 94, 108, 0.16); color: #f15e6c; } +.tag.seg { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent-hover); } +/* the neutral toast: a fact with one action (Undo); the error toast keeps its red */ +.toast.notice { display: flex; align-items: center; gap: 12px; background: #1f1f26; color: #fff; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.1); } +.toast.notice .nt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.toast.notice .na { flex: none; color: var(--accent-hover); font-weight: 800; font-size: 13px; padding: 4px 8px; margin: -4px -8px -4px 0; border-radius: 6px; } +.toast.notice .na:active { background: rgba(255, 255, 255, 0.1); } +/* the prompt and confirm sheets */ +.prompt { padding: 4px var(--pad) 16px; display: flex; flex-direction: column; gap: 12px; } +.prompt .field.area { height: auto; min-height: 84px; padding: 12px 14px; border-radius: 14px; resize: none; line-height: 1.4; font-family: inherit; } +.prompt-acts { display: flex; gap: 8px; } +.prompt-acts .btn { flex: 1; } +.btn.btn-fill.danger { background: #f15e6c; color: #fff; } +.btn.btn-fill.danger:active { background: #d94a58; } + +/* ── the import doors and the review ─────────────────────────────────── */ +.plbtns { display: flex; gap: 8px; padding: 0 var(--pad) 12px; } +.plbtns .newpl { flex: 1; justify-content: center; } +.imp-head { padding: 4px var(--pad) 10px; display: flex; flex-direction: column; gap: 8px; } +.imp-src { font-size: 14.5px; font-weight: 700; } +.imp-counts { font-size: 13px; color: var(--text-muted); } +.imp-counts b { font-weight: 800; } +.imp-counts .ok { color: #3ddc84; } +.imp-counts .st { color: var(--accent-hover); } +.imp-counts .un { color: #e8a33d; } +.imp-warn { font-size: 12.5px; line-height: 1.5; color: #e8a33d; padding: 8px 12px; border-radius: 10px; background: rgba(232, 163, 61, 0.1); } +.imp-rows .trk { min-height: 44px; } +.imp-rows .trk .tag { flex: none; } +.imp-rows .trk.excluded { opacity: 0.4; } +.tag.ok { background: rgba(61, 220, 132, 0.16); color: #3ddc84; } +.tag.st { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent-hover); } +.tag.dim { opacity: 0.7; } +.imp-form { margin: 10px var(--pad) 0; } +.imp-foot { display: flex; gap: 8px; padding: 14px var(--pad) 4px; } +.imp-foot .btn { flex: 1; } +.imp-wait { margin: 40px var(--pad) 0; padding: 26px 18px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; font-size: 13px; color: var(--text-secondary); } +.imp-wait b { color: #fff; font-size: 15px; } +.imp-wait .ring { width: 26px; height: 26px; border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.15); border-top-color: var(--accent); animation: imp-spin 0.9s linear infinite; } +@keyframes imp-spin { to { transform: rotate(360deg); } } + +/* ── You → Sources: a window per mode, the priority inside it ─────────── */ +.pref-head { padding: 12px 14px 2px; font-size: 11px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); } +.pref { padding: 8px 0 12px; } +.pref + .pref { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); } +.pref-l { padding: 0 14px 8px; } +.pref-l .t { display: block; font-size: 14.5px; font-weight: 650; } +.pref-l .m { display: block; font-size: 12px; color: var(--text-muted); margin-top: 1px; } +.pref .chips { padding: 0 14px; } + +/* ── a playlist's bulk run: the desktop's bulkline, under the actions ── */ +.bulkline { display: inline-flex; align-items: center; gap: 8px; margin: 2px auto 4px; padding: 7px 8px 7px 12px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); font-size: 12.5px; color: var(--text-secondary); max-width: 100%; text-align: left; } +.bulkline > span:not(.ring) { min-width: 0; } +.bulkline .ring { width: 14px; height: 14px; flex: none; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.15); border-top-color: var(--accent); animation: imp-spin 0.9s linear infinite; } +.bulkline .bulkx { display: grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; color: var(--text-muted); } +.bulkline .bulkx:active { background: rgba(255, 255, 255, 0.12); } diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 0ccc1c6..c40187d 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -73,7 +73,7 @@ const PROBE = `(function () { 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 || '', + toast: (document.querySelector('.toast:not(.notice)') || {}).textContent || '', live: (document.querySelector('.mini .m.live') || {}).textContent || '', peers: document.querySelectorAll('.peer').length, songs: document.querySelectorAll('.cat-song').length, @@ -98,6 +98,30 @@ const PROBE = `(function () { 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 || '', + plcards: document.querySelectorAll('.plgrid .release').length, + plnames: [].map.call(document.querySelectorAll('.plgrid .release .title'), function (t) { return t.textContent.trim(); }).join('|'), + plpinned: !!document.querySelector('.plsec.pinned'), + plrows: document.querySelectorAll('.pl-rows .trk').length, + pltags: [].map.call(document.querySelectorAll('.pl-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'), + plkind: (document.querySelector('.rec-head.pl .kind') || {}).textContent || '', + plfilter: !!document.querySelector('.plfilter input'), + plthumbs: document.querySelectorAll('.pl-rows .thumb img').length, + imp: document.querySelectorAll('.imp-rows .trk').length, + impTags: [].map.call(document.querySelectorAll('.imp-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'), + impCounts: (document.querySelector('.imp-counts') || {}).textContent || '', + impFmt: (document.querySelector('.imp-head .stats .s') || {}).textContent || '', + impWait: !!document.querySelector('.imp-wait'), + impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '', + keepOn: !!document.querySelector('.imp-form .sw.on'), + bulkline: (document.querySelector('.bulkline') || {}).textContent || '', + impErr: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /didn't import/.test(x.textContent); }), + srcMax: (function () { var g = document.querySelector('.prefs[data-mode="stream"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(), + rowArt: document.querySelectorAll('.screen .row .art img').length, + notice: (document.querySelector('.toast.notice') || {}).textContent || '', + promptVal: (document.querySelector('.halfsheet .prompt .field') || {}).value || '', + /* the history mirror: the entry's depth against the layers the app says the back gesture can peel */ + hd: (history.state && history.state.d != null) ? history.state.d : -1, + hwant: (function () { try { var n = __ss.nav; return (n.sheet ? 1 : 0) + (n.half ? 1 : 0) + (n.stacks[n.tab].length - 1) + (n.tab !== 'home' ? 1 : 0); } catch (e) { return -1; } })(), cardImgs: document.querySelectorAll('.rail .acard img').length, cardPhs: document.querySelectorAll('.rail .acard svg').length, }; @@ -170,9 +194,75 @@ const MOCK_STEPS = [ { 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 }, + /* Playlists: the shelf (pinned first), a page holding every row kind, + a local row playing, the stream row through the resolver, the + filter, a removal and its undo, the page's sheet — unpin, rename — + a new playlist from the shelf, a record's tracks added to it from + the record's sheet, and its deletion */ + { do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 }, + { do: `__click('.plgrid .release', 'Late night')`, name: "playlist", wait: 1200 }, + { do: `document.querySelectorAll('.pl-rows .trk')[1].click()`, name: "playlist-playing", wait: 1200 }, + { do: `__click('.pl-rows .trk', 'One More Time')`, name: "playlist-stream", wait: 5000 }, + { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'track 4'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter", wait: 500 }, + { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 }, + { do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 700 }, + { do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 }, + { do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1000 }, + /* the bulk actions from the page's sheet: Add to library files the + streamed row (its cache is complete); Download all finds it cached too */ + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 }, + { do: `__click('.halfsheet .row', 'Add to library')`, name: "playlist-bulk-cached", wait: 1500 }, + { do: `__click('.bulkline .bulkx', null)`, wait: 400 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 }, + { do: `__click('.halfsheet .row', 'Download all tracks')`, name: "playlist-bulk-download", wait: 1500 }, + { do: `__click('.bulkline .bulkx', null)`, wait: 400 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 700 }, + { do: `__click('.halfsheet .row', 'Unpin')`, name: "playlist-unpinned", wait: 900 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 }, + { do: `__click('.halfsheet .row', 'Rename')`, name: "playlist-rename", wait: 700 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Late night drive II'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Rename')`, name: "playlist-renamed", wait: 1000 }, + { do: `history.back()`, name: "playlists-again", wait: 900 }, + { do: `__click('.newpl', 'New playlist')`, name: "playlist-new-prompt", wait: 700 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Create')`, name: "playlist-new", wait: 1200 }, + { do: `history.back()`, wait: 800 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 }, + { do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Add to playlist')`, name: "playlist-pick", wait: 700 }, + { do: `__click('.halfsheet .row', 'New Playlist')`, name: "playlist-added", wait: 1200 }, + { do: `__click('.segtabs button', 'Playlists')`, wait: 700 }, + { do: `__click('.plgrid .release', 'New Playlist')`, name: "playlist-filled", wait: 1200 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 }, + { do: `__click('.halfsheet .row', 'Delete playlist')`, name: "playlist-delete-confirm", wait: 700 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1200 }, + /* the import doors: a pasted tracklist reviewed and created with the + unresolved row kept out; then a link — its stream rows verified + against the catalog while the review is up — created as it came */ + { do: `__click('.newpl', 'Import')`, name: "import-sheet", wait: 700 }, + { do: `__click('.halfsheet .row', 'Paste a tracklist')`, name: "import-paste-prompt", wait: 700 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nAphex Twin - Track 1\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 2200 }, + { do: `__click('.imp-form .row', 'Keep unresolved')`, name: "import-keep-off", wait: 500 }, + { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 1500 }, + { do: `history.back()`, wait: 900 }, + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Playlist from a link')`, wait: 700 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-link-wait", wait: 500 }, + { name: "import-link-review", wait: 3400 }, + { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-link-created", wait: 1500 }, + { do: `history.back()`, name: "playlists-after-import", wait: 900 }, + /* a share from another app: the plugin hands the text over when the + app comes to the front, and it lands in the import */ + { do: `__ssMock.share = 'Check this out https://music.apple.com/us/playlist/late/pl.u-mock'; document.dispatchEvent(new Event('visibilitychange'))`, name: "share-import", wait: 3600 }, + { do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 }, { 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: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 500 }, + { do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 500 }, + { do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 500 }, + { do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, wait: 400 }, { 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 }, @@ -183,7 +273,10 @@ 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, + you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit", + "you-norm-off": (i) => i.norm === "off", + "you-norm-on": (i) => i.norm === "on", + "you-lossy": (i) => i.srcMax === "Lossy only", "sleep-sheet": (i) => i.half.includes("up"), "sleep-set": (i) => !i.half.includes("up"), home: (i) => /^(Good |Still)/.test(i.title), @@ -220,6 +313,41 @@ const MOCK_EXPECT = { 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, + playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned, + artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 3, + playlist: (i) => i.screen.includes("tr-push") && i.title.startsWith("Late night drive") && i.plrows === 6 && i.plthumbs >= 3 && /unresolved/.test(i.pltags) && /file missing/.test(i.pltags) && /^Playlist/.test(i.plkind) && /Pinned/.test(i.plkind) && i.plfilter, + "playlist-playing": (i) => i.mini && i.playing.includes("Track 2") && !i.toast, + "playlist-stream": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live), + "playlist-filter": (i) => i.plrows === 1, + "playlist-row-sheet": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText), + "playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice), + "playlist-undone": (i) => i.plrows === 6 && !i.notice, + "playlist-bulk-cached": (i) => /1 saved to library/.test(i.bulkline) && !i.half.includes("up"), + "playlist-bulk-download": (i) => /1 saved to library/.test(i.bulkline), + "playlist-more": (i) => i.half.includes("up") && /Unpin/.test(i.halfText) && /Rename/.test(i.halfText) && /Delete playlist/.test(i.halfText), + "playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind), + "playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive", + "playlist-renamed": (i) => i.title.startsWith("Late night drive II"), + "playlists-again": (i) => i.plcards === 3 && !i.plpinned && /Late night drive II/.test(i.plnames), + "playlist-new-prompt": (i) => i.half.includes("up") && /^New Playlist \d+$/.test(i.promptVal), + "playlist-new": (i) => i.screen.includes("tr-push") && /^New Playlist/.test(i.title) && i.plrows === 0, + "playlist-pick": (i) => i.half.includes("up") && /New playlist/.test(i.halfText) && /Late night drive II/.test(i.halfText), + "playlist-added": (i) => /Added \d+ tracks to New Playlist/.test(i.notice), + "playlist-filled": (i) => /^New Playlist/.test(i.title) && i.plrows >= 5, + "playlist-delete-confirm": (i) => i.half.includes("up") && /Delete/.test(i.halfText), + "playlist-deleted": (i) => i.title.startsWith("Your library") && i.plcards === 3 && !/New Playlist/.test(i.plnames), + "import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText), + "import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "", + "import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && /1 in library/.test(i.impCounts) && /tags/.test(i.impTags) && /unresolved/.test(i.impTags) && /catalog/.test(i.impTags) && i.keepOn && /Create playlist \(3 tracks\)/.test(i.impBtn), + "import-keep-off": (i) => !i.keepOn && /Create playlist \(2 tracks\)/.test(i.impBtn), + "import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2, + /* the wait card is transient: the still can take longer than the mock's fetch */ + "import-link-wait": (i) => i.title.startsWith("Import") && (i.impWait || i.imp === 5), + "import-link-review": (i) => i.imp === 5 && /Spotify/.test(i.impFmt) && /catalog/.test(i.impTags) && !/matching/.test(i.impTags) && /Create playlist \(5 tracks\)/.test(i.impBtn), + "import-link-created": (i) => i.title.startsWith("Late Night Mix") && i.plrows === 5, + "playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5, + "share-import": (i) => i.title.startsWith("Import") && /Apple Music/.test(i.impFmt) && i.imp === 5, + "share-cancel": (i) => i.title.startsWith("Your library"), "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), @@ -253,6 +381,48 @@ const PHONE_STEPS = [ { 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 */ + /* playlists on the real core: one made here, a record's tracks added + from the record's sheet, a row played, the playlist deleted again — + the phone's library is left as it was */ + { do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 }, + { do: `__click('.newpl', 'New playlist')`, wait: 700 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Walk test'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Create')`, name: "playlist-new", wait: 1500 }, + { do: `history.back()`, wait: 800 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 }, + { do: `(function () { var all = document.querySelectorAll('.grid2 .release'); var c = null; for (var i = 0; i < all.length; i++) { if (!${albumText} || all[i].textContent.indexOf(${albumText}) >= 0) { c = all[i]; break; } } if (!c) throw new Error('no album card'); c.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 900 }, + { do: `__click('.halfsheet .row', 'Add to playlist')`, name: "playlist-pick", wait: 800 }, + { do: `__click('.halfsheet .row', 'Walk test')`, name: "playlist-added", wait: 1500 }, + { do: `__click('.segtabs button', 'Playlists')`, wait: 800 }, + { do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 }, + { do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 800 }, + { do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 }, + /* the paste door on the real core: two lines that resolve to a record + in the library, reviewed, created, deleted again */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 3500 }, + { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 2000 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 }, + { do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 }, + /* the share sheet: a Spotify playlist link sent by "another app" (an + intent on the plugin's activity) lands in the import — the review is + left without creating anything */ + { adb: ["shell", "am", "start", "-a", "android.intent.action.SEND", "-t", "text/plain", "--es", "android.intent.extra.TEXT", "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", "-n", "com.soulstream.app/com.soulstream.share.ShareActivity"], name: "share-import", wait: 9000 }, + { do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 }, + /* You: the settings save for real (camelCase args) — the switch and a + chip, each read back from the config, each restored */ + { do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 900 }, + { do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 900 }, + { do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 900 }, + { do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 900 }, + { do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, name: "you-nolimit", wait: 900 }, + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, { 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 }, @@ -276,6 +446,24 @@ const PHONE_EXPECT = { "hold-sheet": (i) => i.half.includes("up"), tracks: (i) => i.title.startsWith("Your library"), "artist-cold": (i) => i.screen.includes("has-hero"), + you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax, + "you-norm-off": (i) => i.norm === "off" && !i.toast, + "you-norm-on": (i) => i.norm === "on" && !i.toast, + "you-lossy": (i) => i.srcMax === "Lossy only" && !i.toast, + "you-nolimit": (i) => i.srcMax === "No limit" && !i.toast, + playlists: (i) => i.title.startsWith("Your library"), + "playlist-new": (i) => i.screen.includes("tr-push") && i.title.startsWith("Walk test") && i.plrows === 0 && !i.toast, + "playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText), + "playlist-added": (i) => /Added .* to Walk test/.test(i.notice) && !i.toast, + "playlist-filled": (i) => i.title.startsWith("Walk test") && i.plrows >= 1, + "playlist-playing": (i) => i.mini && !!i.playing && !i.toast, + "playlist-more": (i) => i.half.includes("up") && /Delete playlist/.test(i.halfText), + "playlist-deleted": (i) => i.title.startsWith("Your library") && !/Walk test/.test(i.plnames) && !i.toast, + "import-review": (i) => i.title.startsWith("Import") && i.imp === 2 && !i.toast, + "import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2 && !i.toast, + "import-deleted": (i) => i.title.startsWith("Your library") && !/Imported playlist/.test(i.plnames) && !i.toast, + "share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr), + "share-cancel": (i) => i.title.startsWith("Your library"), 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), @@ -349,7 +537,8 @@ async function main() { } 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" }); + /* unminified, so a warning's frames name the function */ + const b = spawnSync("npx", ["vite", "build", "--outDir", "dist-smoke", "--base", "./", "--minify", "false"], { 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 */ @@ -378,7 +567,10 @@ async function main() { 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(" ")); + if (p.type !== "error" && p.type !== "warning") return; + const frames = (p.stackTrace?.callFrames || []).slice(0, 5) + .map((f) => (f.functionName || "?") + "@" + (f.url || "").split("/").pop() + ":" + (f.lineNumber + 1) + ":" + (f.columnNumber + 1)); + problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" ") + (frames.length ? "\n at " + frames.join("\n at ") : "")); }; cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); }; if (!PHONE) { @@ -388,6 +580,10 @@ async function main() { await sleep(600); } for (const s of steps) { + /* a phone step can be an adb command — an intent, a key */ + if (s.adb) { + try { adb(...s.adb); } catch (e) { problems.push("ADB " + (s.name || s.adb.join(" ")) + ": " + e.message); } + } if (s.do) { try { /* the helpers live in the page; a reload step wipes them */ @@ -415,8 +611,18 @@ async function main() { adb("shell", "am", "force-stop", "com.soulstream.app"); await sleep(800); adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity"); - await sleep(6000); + /* a relaunch restores a PAUSED track, which starts the media service + as a foreground service that must promote itself at once — on + 2026-09-13 it did not, and Android killed the app ten seconds + later (a Save died with it). Wait past that window BEFORE + resuming, and the pid must be the same one afterwards. */ + adb("logcat", "-c"); + await sleep(13000); const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim(); + const fatal = (adb("logcat", "-d", "-s", "AndroidRuntime:E").toString().match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length; + const alive = !!pid2 && !fatal; + if (!alive) problems.push("EXPECT survives-paused-restore " + JSON.stringify({ pid: pid2, fatal })); + console.log((alive ? " ok " : " FAIL ") + "paused-restore".padEnd(16) + " " + JSON.stringify({ pid: pid2 || null, fatal })); adb("forward", "--remove-all"); adb("forward", `tcp:${PORT}`, `localabstract:webview_devtools_remote_${pid2}`); cdp = await CDP.attach(PORT);