diff --git a/ANDROID.md b/ANDROID.md
index b397718..69764a0 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -18,9 +18,11 @@ implements is `research/mobile-mockup/` (gitignored; see its README).
| `src/mobile/lib.svelte.js` | albums, recents, follows, playlists; artists derived from the albums |
| `src/mobile/catalog.svelte.js` | the catalog lookups, cached per session: an artist by name (the desktop's most-followed-exact-match rule), a catalog record by id |
| `src/mobile/media.svelte.js` | the lock-screen card's wiring: mirrors the bar into the media session plugin on every transport change (plus a top-up every 30 s), carries out the card's, the headset's and audio focus's actions |
+| `src/mobile/xfernote.svelte.js` | the shade's download notice: mirrors what is still in flight into the transfers plugin (throttled, one post per 700 ms of changed content) and carries out its Stop all and Open |
| `src/mobile/slsk.svelte.js` | the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's `slsk` store and events |
| `src/mobile/contacts.svelte.js` | the local view of a person on the network: a petname, a group, a note and the two marks (`contacts_meta_list` / `contact_meta_set`) — this phone only, published nowhere |
| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |
+| `crates/tauri-plugin-transfer-notice/` | a second Tauri plugin with a second Kotlin foreground service (`TransferService`, `dataSync`) — the downloads' own progress notification on its own low-importance channel, with Stop all and Open on it, and the reason a Save outlives the app going to the background with nothing playing |
| `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `SyncRoom.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the tap-to-sync room, the waveform lane, the two card pieces (a long press on a card is its sheet) |
| `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Playlist, Community (sign in; rooms, chat, buddies, taste — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) |
| `src/mobile/prefs.svelte.js` | Appearance, kept in localStorage: ambience, accent from artwork, motion |
@@ -584,6 +586,68 @@ it means splitting `.trk` (one flex row with a whole-row `:active`) and
rewriting the walk's `.trk .r` selectors, for no gain a screen reader
would notice.
+## The downloads in the shade
+
+Transfers is a screen inside the app; the desktop says what a Save is doing
+with the ring in its title bar, the phone with a badge on the nav. Neither
+is anywhere to be seen once the app is in the background, which is exactly
+when a Save is left to run on its own. So a running Save now shows in the
+shade: how many files are still in flight, the one it is on, a progress
+bar, and two buttons — **Stop all** and **Open**.
+
+Its own channel ("Downloads", `IMPORTANCE_LOW`, no badge, private on a
+locked screen, since a filename is nobody else's business), separate from
+Playback so one can be silenced without the other, and created when the app
+starts rather than when the first download does — a user who wants the
+downloads quiet should not have to wait for one to be noisy first.
+
+Its own service too, and that is the part worth spelling out.
+`foregroundServiceType` is per-service: a download is `dataSync`, playback
+is `mediaPlayback`. `MediaService` stops itself the moment the bar empties,
+so folding the notice into it would take the card down mid-Save; keeping it
+alive for a download instead would leave a `mediaPlayback` service running
+with no session, which Android 14+ treats as a violation. And nothing else
+keeps the process alive for a Save with nothing playing — which is exactly
+how the first real Save died on 2026-09-13, four tracks in. A `dataSync`
+foreground service that exists while downloads are in flight is the
+structural fix, and it can only be its own service. The same promote-first
+rule applies: `onStartCommand` calls `startForeground()` before it looks at
+anything, because Android kills the app about ten seconds after a
+foreground start that never did. Android 15 caps a `dataSync` service at
+six cumulative hours a day and kills the app if it is still up when the
+callback returns, so `onTimeout` takes the notice down rather than being
+taken down with it; the fetches are the Rust core's own and Transfers still
+lists them.
+
+The buttons come back as the plugin event `action`
+(`addPluginListener("transfer-notice", "action", …)`). **Stop all** runs
+`cancelAll()` in `transfers.svelte.js`, which is the same `cancel(id)` a
+row's tap calls, once per live row — not a second path to the same thing.
+**Open** brings the activity forward and puts the You tab on Transfers, and
+the notice's body does the same. Transfers grew the matching button: Cancel
+all while anything is in flight, beside Clear finished.
+
+`src/mobile/xfernote.svelte.js` is the mirror, and it is driven by a Svelte
+effect over the transfer store rather than off the `soulseek:download`
+event. That is deliberate twice over: the effect tracks progress as well as
+arrivals, and it means a row written straight into the store drives the
+notice exactly as a fetched one does — which is how the walk exercises this
+without fetching anything. Posts are throttled to one per 700 ms of changed
+content; download progress arrives far faster than anyone reads it, and the
+system rate-limits (and drops) notifications posted in a flood.
+
+`crates/tauri-plugin-transfer-notice` is shaped like the media-session
+plugin: `build.rs` hands its `android/` Gradle module to tauri-build,
+`src/lib.rs` registers the Kotlin class and exposes `update` / `clear`, and
+the Kotlin side is two files — the plugin (arguments in, the `action` event
+out) and `TransferService`. The app's capability grants
+`transfer-notice:default`; the plugin is registered on every platform so
+the permission resolves everywhere, and on the desktop its commands answer
+"not here". The tauri-build trap above applies to this plugin as well: the
+Android module only reaches the app project when
+`TAURI_ANDROID_PROJECT_PATH` is set, which is why adding it also touched
+`src-tauri/build.rs`.
+
## Performance
`tests/mobile/perf.mjs` logs the frames the WebView produces through
@@ -718,7 +782,9 @@ 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 →
-a catalog record, its Save and the Transfers burst → Gaps → the playlist
+a catalog record, its Save and the Transfers burst (and the shade's notice:
+posted with a count while rows are in flight, gone when they are not) →
+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
@@ -747,7 +813,11 @@ harness's to set.
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; every CDP call times out after 45 s and the reload step re-attaches
+key — and a step may carry a `probe` as well, a function whose reading of
+the system is merged into that step's own expectation, which is how the
+shade's notice is asserted out of `dumpsys notification`; the shade steps
+push two rows straight into the transfer store rather than fetching
+anything, on purpose; every CDP call times out after 45 s and the reload step re-attaches
to the page, since a WebView that stops answering once hung a walk for ten
minutes; its audio check counts SoulStream's OWN tracks' underruns from the
mixer dump — the thread-wide counters move with any app's notification
diff --git a/Cargo.lock b/Cargo.lock
index c9439b3..be8c962 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -197,6 +197,7 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-share",
"tauri-plugin-storage",
+ "tauri-plugin-transfer-notice",
"tauri-plugin-updater",
"tempfile",
"tokio",
@@ -5863,6 +5864,16 @@ dependencies = [
"tauri-plugin",
]
+[[package]]
+name = "tauri-plugin-transfer-notice"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+]
+
[[package]]
name = "tauri-plugin-updater"
version = "2.11.0"
diff --git a/Cargo.toml b/Cargo.toml
index 295fae5..b3040e9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,7 @@ members = [
"crates/tauri-plugin-media-session",
"crates/tauri-plugin-storage",
"crates/tauri-plugin-share",
+ "crates/tauri-plugin-transfer-notice",
]
[workspace.package]
diff --git a/crates/tauri-plugin-transfer-notice/Cargo.toml b/crates/tauri-plugin-transfer-notice/Cargo.toml
new file mode 100644
index 0000000..3edf236
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "tauri-plugin-transfer-notice"
+version = "0.1.0"
+description = "SoulStream's downloads in the phone's shade: a progress notification on its own low-importance channel, carried by its own dataSync foreground service, so a Save outlives the app going to the background"
+edition = "2021"
+rust-version = "1.77.2"
+# required by tauri-plugin's build script; must equal the crate name
+links = "tauri-plugin-transfer-notice"
+
+[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-transfer-notice/android/.gitignore b/crates/tauri-plugin-transfer-notice/android/.gitignore
new file mode 100644
index 0000000..c0f21ec
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/android/.gitignore
@@ -0,0 +1,2 @@
+/build
+/.tauri
diff --git a/crates/tauri-plugin-transfer-notice/android/build.gradle.kts b/crates/tauri-plugin-transfer-notice/android/build.gradle.kts
new file mode 100644
index 0000000..22dcc28
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/android/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.soulstream.transfernotice"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+ // NotificationCompat, ServiceCompat, ContextCompat — no media here,
+ // there is no session in this one
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation(project(":tauri-android"))
+}
diff --git a/crates/tauri-plugin-transfer-notice/android/src/main/AndroidManifest.xml b/crates/tauri-plugin-transfer-notice/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..401f685
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/android/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/tauri-plugin-transfer-notice/android/src/main/java/com/soulstream/transfernotice/TransferNoticePlugin.kt b/crates/tauri-plugin-transfer-notice/android/src/main/java/com/soulstream/transfernotice/TransferNoticePlugin.kt
new file mode 100644
index 0000000..c03919c
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/android/src/main/java/com/soulstream/transfernotice/TransferNoticePlugin.kt
@@ -0,0 +1,76 @@
+package com.soulstream.transfernotice
+
+import android.Manifest
+import android.app.Activity
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import app.tauri.annotation.Command
+import app.tauri.annotation.InvokeArg
+import app.tauri.annotation.TauriPlugin
+import app.tauri.plugin.Invoke
+import app.tauri.plugin.JSObject
+import app.tauri.plugin.Plugin
+
+/** What the notice says — the Rust `TransferNotice`, camelCase. */
+@InvokeArg
+class NoticeArgs {
+ /** how many files are still in flight; zero means take the notice down */
+ var count: Int = 0
+ var title: String? = null
+ var line: String? = null
+ var percent: Int = 0
+ /** nobody has stated a size yet, so the bar has no end to walk towards */
+ var indeterminate: Boolean = false
+}
+
+/**
+ * The bridge between the WebView and the service that owns the notice.
+ * `update` mirrors what Save is fetching (and starts the service the first
+ * time); `clear` takes the notice down. Its buttons come back as the plugin
+ * event `action` with `{ action }` — "cancel" or "open".
+ */
+@TauriPlugin
+class TransferNoticePlugin(private val activity: Activity) : Plugin(activity) {
+ private var askedForNotifications = false
+
+ init {
+ TransferHub.onAction = { action ->
+ val payload = JSObject()
+ payload.put("action", action)
+ trigger("action", payload)
+ }
+ /* the channel exists from app start, so a user can silence the
+ downloads on their own before the first one ever runs */
+ TransferHub.ensureChannel(activity.applicationContext)
+ }
+
+ @Command
+ fun update(invoke: Invoke) {
+ val args = invoke.parseArgs(NoticeArgs::class.java)
+ ensureNotificationPermission()
+ TransferHub.update(activity.applicationContext, args)
+ invoke.resolve()
+ }
+
+ @Command
+ fun clear(invoke: Invoke) {
+ TransferHub.clear(activity.applicationContext)
+ invoke.resolve()
+ }
+
+ /** Android 13+: the notice is a notification and needs the permission;
+ * asked once, the first time something is fetched. Its own request code
+ * — 4711 is the media card's, and two requests that share one would
+ * answer each other. The service runs either way; without the
+ * permission it simply has nothing to show. */
+ private fun ensureNotificationPermission() {
+ if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return
+ askedForNotifications = true
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED
+ ) return
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4712)
+ }
+}
diff --git a/crates/tauri-plugin-transfer-notice/android/src/main/java/com/soulstream/transfernotice/TransferService.kt b/crates/tauri-plugin-transfer-notice/android/src/main/java/com/soulstream/transfernotice/TransferService.kt
new file mode 100644
index 0000000..db29cd2
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/android/src/main/java/com/soulstream/transfernotice/TransferService.kt
@@ -0,0 +1,196 @@
+package com.soulstream.transfernotice
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+
+/**
+ * What is being fetched, shared between the plugin (which hears the
+ * WebView) and the service (which owns the notice). The service is started
+ * on the first update and refreshed on every one after; a count of zero is
+ * a clear, since the phone's own rule is that an empty queue has no notice.
+ */
+object TransferHub {
+ const val CHANNEL = "downloads"
+
+ /** 1 is the media card's, and ids are per-package */
+ const val NOTIFICATION_ID = 2
+
+ const val ACTION_CANCEL = "com.soulstream.transfernotice.CANCEL"
+ const val ACTION_OPEN = "com.soulstream.transfernotice.OPEN"
+
+ @Volatile var onAction: ((String) -> Unit)? = null
+ @Volatile var state: NoticeArgs? = null
+ @Volatile var service: TransferService? = null
+
+ /** Its own channel, low importance and no badge: a download is not an
+ * alarm, and silencing it must not silence the player's card. A
+ * filename is not for a locked screen, so the notice is private there. */
+ fun ensureChannel(ctx: Context) {
+ if (Build.VERSION.SDK_INT < 26) return
+ val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (manager.getNotificationChannel(CHANNEL) != null) return
+ val channel = NotificationChannel(CHANNEL, "Downloads", NotificationManager.IMPORTANCE_LOW)
+ channel.description = "What Save is fetching into your library"
+ channel.setShowBadge(false)
+ channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ manager.createNotificationChannel(channel)
+ }
+
+ fun update(ctx: Context, np: NoticeArgs) {
+ if (np.count <= 0) {
+ clear(ctx)
+ return
+ }
+ state = np
+ val running = service
+ if (running == null) {
+ /* Android 12+ refuses a foreground start from the background. A
+ Save is always begun from the foreground, so the first start
+ lands; one that cannot simply leaves the fetch without a card */
+ try {
+ ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java))
+ } catch (e: Exception) {
+ android.util.Log.w("SoulStream", "transfer notice could not start: $e")
+ }
+ } else {
+ running.refresh()
+ }
+ }
+
+ fun clear(ctx: Context) {
+ state = null
+ val running = service
+ /* refresh() on a null state is the one path that takes the notice
+ down and stops the service; stopService is for a service that was
+ never here */
+ if (running != null) running.refresh() else ctx.stopService(Intent(ctx, TransferService::class.java))
+ }
+
+ fun act(action: String) {
+ onAction?.invoke(action)
+ }
+}
+
+/**
+ * The foreground service behind the notice: a progress notification with
+ * Stop all and Open on it, and — the point — the reason the process is not
+ * frozen while a Save runs with nothing playing. The fetches themselves
+ * stay where they are, on the Rust core's own connections; this only says
+ * what they are doing.
+ */
+class TransferService : Service() {
+ private var inForeground = false
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ TransferHub.ensureChannel(this)
+ TransferHub.service = this
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ /* Started with startForegroundService, so Android demands
+ startForeground() promptly WHATEVER the state — a queue that
+ emptied while the start was in flight included — or it kills the
+ whole app about ten seconds later. MediaService learned this the
+ same way, on 2026-09-13, and it was a Save that died. Promote
+ first, with the notice there is or a plain one; refresh() steps
+ back at once if there is nothing left to say. */
+ if (!inForeground) promote(build(TransferHub.state ?: NoticeArgs()))
+ when (intent?.action) {
+ TransferHub.ACTION_CANCEL -> TransferHub.act("cancel")
+ TransferHub.ACTION_OPEN -> {
+ packageManager.getLaunchIntentForPackage(packageName)?.let {
+ it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ try { startActivity(it) } catch (_: Exception) { /* no launcher activity */ }
+ }
+ TransferHub.act("open")
+ }
+ }
+ refresh()
+ return START_NOT_STICKY
+ }
+
+ /** Mirror TransferHub.state into the notice — or take it down. */
+ fun refresh() {
+ val np = TransferHub.state
+ if (np == null) {
+ /* the queue emptied: the notice goes, whole */
+ if (inForeground) {
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ inForeground = false
+ }
+ stopSelf()
+ return
+ }
+ /* promote() on an already-promoted service is just a notify */
+ promote(build(np))
+ }
+
+ private fun promote(notification: Notification) {
+ if (Build.VERSION.SDK_INT >= 29) {
+ ServiceCompat.startForeground(
+ this, TransferHub.NOTIFICATION_ID, notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
+ )
+ } else {
+ startForeground(TransferHub.NOTIFICATION_ID, notification)
+ }
+ inForeground = true
+ }
+
+ /** Android 15 caps a dataSync service at six hours a day and kills the
+ * app if it is still up when this returns. Take the notice down: the
+ * fetches are the Rust core's own and Transfers still lists them. */
+ override fun onTimeout(startId: Int, fgsType: Int) {
+ TransferHub.clear(applicationContext)
+ }
+
+ override fun onDestroy() {
+ TransferHub.service = null
+ super.onDestroy()
+ }
+
+ private fun build(np: NoticeArgs): Notification {
+ val open = serviceIntent(11, TransferHub.ACTION_OPEN)
+ val cancel = serviceIntent(12, TransferHub.ACTION_CANCEL)
+ return NotificationCompat.Builder(this, TransferHub.CHANNEL)
+ .setSmallIcon(R.drawable.ic_soulstream_down)
+ .setContentTitle(np.title ?: "Saving")
+ .setContentText(np.line ?: "")
+ .setProgress(100, np.percent.coerceIn(0, 100), np.indeterminate)
+ .setContentIntent(open)
+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ .setOnlyAlertOnce(true)
+ .setShowWhen(false)
+ .setOngoing(true)
+ .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop all", cancel)
+ .addAction(android.R.drawable.ic_menu_upload, "Open", open)
+ .build()
+ }
+
+ /** The notice only exists while this service is in the foreground, so
+ * its own buttons are never a background service start. The request
+ * codes are 11 and 12 on purpose — the media plugin uses 0 in the same
+ * package, and two PendingIntents that filterEqual overwrite each other. */
+ private fun serviceIntent(code: Int, action: String): PendingIntent {
+ val i = Intent(this, TransferService::class.java).setAction(action)
+ return PendingIntent.getForegroundService(
+ this, code, i,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+}
diff --git a/crates/tauri-plugin-transfer-notice/android/src/main/res/drawable/ic_soulstream_down.xml b/crates/tauri-plugin-transfer-notice/android/src/main/res/drawable/ic_soulstream_down.xml
new file mode 100644
index 0000000..6f5681f
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/android/src/main/res/drawable/ic_soulstream_down.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/crates/tauri-plugin-transfer-notice/build.rs b/crates/tauri-plugin-transfer-notice/build.rs
new file mode 100644
index 0000000..78d1a30
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/build.rs
@@ -0,0 +1,16 @@
+// `update` and `clear` are ours. registerListener / removeListener are the
+// mobile plugin runtime's own commands, the ones `addPluginListener`
+// invokes to hear the `action` event — commands like any other to the ACL,
+// and without them here the listener is refused and the notice's buttons go
+// nowhere. The media-session plugin carries the same scar.
+const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"];
+
+fn main() {
+ // Registers the command permissions (permissions/autogenerated) and, on
+ // Android, hands the Gradle module under android/ to tauri-build, which
+ // includes it in the app project (tauri.settings.gradle) — but only with
+ // TAURI_ANDROID_PROJECT_PATH set, see ANDROID.md.
+ tauri_plugin::Builder::new(COMMANDS)
+ .android_path("android")
+ .build();
+}
diff --git a/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/clear.toml b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/clear.toml
new file mode 100644
index 0000000..83de181
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/clear.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-clear"
+description = "Enables the clear command without any pre-configured scope."
+commands.allow = ["clear"]
+
+[[permission]]
+identifier = "deny-clear"
+description = "Denies the clear command without any pre-configured scope."
+commands.deny = ["clear"]
diff --git a/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/registerListener.toml b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/registerListener.toml
new file mode 100644
index 0000000..f035e95
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/registerListener.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-registerListener"
+description = "Enables the registerListener command without any pre-configured scope."
+commands.allow = ["registerListener"]
+
+[[permission]]
+identifier = "deny-registerListener"
+description = "Denies the registerListener command without any pre-configured scope."
+commands.deny = ["registerListener"]
diff --git a/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/removeListener.toml b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/removeListener.toml
new file mode 100644
index 0000000..ebc31bc
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/removeListener.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-removeListener"
+description = "Enables the removeListener command without any pre-configured scope."
+commands.allow = ["removeListener"]
+
+[[permission]]
+identifier = "deny-removeListener"
+description = "Denies the removeListener command without any pre-configured scope."
+commands.deny = ["removeListener"]
diff --git a/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/update.toml b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/update.toml
new file mode 100644
index 0000000..92645d3
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/commands/update.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-update"
+description = "Enables the update command without any pre-configured scope."
+commands.allow = ["update"]
+
+[[permission]]
+identifier = "deny-update"
+description = "Denies the update command without any pre-configured scope."
+commands.deny = ["update"]
diff --git a/crates/tauri-plugin-transfer-notice/permissions/autogenerated/reference.md b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/reference.md
new file mode 100644
index 0000000..5db95db
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/permissions/autogenerated/reference.md
@@ -0,0 +1,126 @@
+## Default Permission
+
+The downloads notice on the phone: `update` mirrors what Save is fetching,
+`clear` takes it down, and the two listener commands are what lets the page
+hear the notice's buttons (`addPluginListener`).
+
+#### This default permission set includes the following:
+
+- `allow-update`
+- `allow-clear`
+- `allow-registerListener`
+- `allow-removeListener`
+
+## Permission Table
+
+
+
+
Identifier
+
Description
+
+
+
+
+
+
+`transfer-notice:allow-clear`
+
+
+
+
+Enables the clear command without any pre-configured scope.
+
+
+
+
+
+
+
+`transfer-notice:deny-clear`
+
+
+
+
+Denies the clear command without any pre-configured scope.
+
+
+
+
+
+
+
+`transfer-notice:allow-registerListener`
+
+
+
+
+Enables the registerListener command without any pre-configured scope.
+
+
+
+
+
+
+
+`transfer-notice:deny-registerListener`
+
+
+
+
+Denies the registerListener command without any pre-configured scope.
+
+
+
+
+
+
+
+`transfer-notice:allow-removeListener`
+
+
+
+
+Enables the removeListener command without any pre-configured scope.
+
+
+
+
+
+
+
+`transfer-notice:deny-removeListener`
+
+
+
+
+Denies the removeListener command without any pre-configured scope.
+
+
+
+
+
+
+
+`transfer-notice:allow-update`
+
+
+
+
+Enables the update command without any pre-configured scope.
+
+
+
+
+
+
+
+`transfer-notice:deny-update`
+
+
+
+
+Denies the update command without any pre-configured scope.
+
+
+
+
diff --git a/crates/tauri-plugin-transfer-notice/permissions/default.toml b/crates/tauri-plugin-transfer-notice/permissions/default.toml
new file mode 100644
index 0000000..e7613b7
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/permissions/default.toml
@@ -0,0 +1,9 @@
+"$schema" = "schemas/schema.json"
+
+[default]
+description = """
+The downloads notice on the phone: `update` mirrors what Save is fetching,
+`clear` takes it down, and the two listener commands are what lets the page
+hear the notice's buttons (`addPluginListener`).
+"""
+permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"]
diff --git a/crates/tauri-plugin-transfer-notice/permissions/schemas/schema.json b/crates/tauri-plugin-transfer-notice/permissions/schemas/schema.json
new file mode 100644
index 0000000..ccf16ba
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/permissions/schemas/schema.json
@@ -0,0 +1,354 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "PermissionFile",
+ "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
+ "type": "object",
+ "properties": {
+ "default": {
+ "description": "The default permission set for the plugin",
+ "anyOf": [
+ {
+ "$ref": "#/definitions/DefaultPermission"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "set": {
+ "description": "A list of permissions sets defined",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/PermissionSet"
+ }
+ },
+ "permission": {
+ "description": "A list of inlined permissions",
+ "default": [],
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Permission"
+ }
+ }
+ },
+ "definitions": {
+ "DefaultPermission": {
+ "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
+ "type": "object",
+ "required": [
+ "permissions"
+ ],
+ "properties": {
+ "version": {
+ "description": "The version of the permission.",
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "uint64",
+ "minimum": 1.0
+ },
+ "description": {
+ "description": "Human-readable description of what the permission does. Tauri convention is to use `
` headings in markdown content for Tauri documentation generation purposes.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "permissions": {
+ "description": "All permissions this set contains.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "PermissionSet": {
+ "description": "A set of direct permissions grouped together under a new name.",
+ "type": "object",
+ "required": [
+ "description",
+ "identifier",
+ "permissions"
+ ],
+ "properties": {
+ "identifier": {
+ "description": "A unique identifier for the permission.",
+ "type": "string"
+ },
+ "description": {
+ "description": "Human-readable description of what the permission does.",
+ "type": "string"
+ },
+ "permissions": {
+ "description": "All permissions this set contains.",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/PermissionKind"
+ }
+ }
+ }
+ },
+ "Permission": {
+ "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
+ "type": "object",
+ "required": [
+ "identifier"
+ ],
+ "properties": {
+ "version": {
+ "description": "The version of the permission.",
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "uint64",
+ "minimum": 1.0
+ },
+ "identifier": {
+ "description": "A unique identifier for the permission.",
+ "type": "string"
+ },
+ "description": {
+ "description": "Human-readable description of what the permission does. Tauri internal convention is to use `
` headings in markdown content for Tauri documentation generation purposes.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "commands": {
+ "description": "Allowed or denied commands when using this permission.",
+ "default": {
+ "allow": [],
+ "deny": []
+ },
+ "allOf": [
+ {
+ "$ref": "#/definitions/Commands"
+ }
+ ]
+ },
+ "scope": {
+ "description": "Allowed or denied scoped when using this permission.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/Scopes"
+ }
+ ]
+ },
+ "platforms": {
+ "description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/definitions/Target"
+ }
+ }
+ }
+ },
+ "Commands": {
+ "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
+ "type": "object",
+ "properties": {
+ "allow": {
+ "description": "Allowed command.",
+ "default": [],
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "deny": {
+ "description": "Denied command, which takes priority.",
+ "default": [],
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "Scopes": {
+ "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
+ "type": "object",
+ "properties": {
+ "allow": {
+ "description": "Data that defines what is allowed by the scope.",
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/definitions/Value"
+ }
+ },
+ "deny": {
+ "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/definitions/Value"
+ }
+ }
+ }
+ },
+ "Value": {
+ "description": "All supported ACL values.",
+ "anyOf": [
+ {
+ "description": "Represents a null JSON value.",
+ "type": "null"
+ },
+ {
+ "description": "Represents a [`bool`].",
+ "type": "boolean"
+ },
+ {
+ "description": "Represents a valid ACL [`Number`].",
+ "allOf": [
+ {
+ "$ref": "#/definitions/Number"
+ }
+ ]
+ },
+ {
+ "description": "Represents a [`String`].",
+ "type": "string"
+ },
+ {
+ "description": "Represents a list of other [`Value`]s.",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Value"
+ }
+ },
+ {
+ "description": "Represents a map of [`String`] keys to [`Value`]s.",
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/Value"
+ }
+ }
+ ]
+ },
+ "Number": {
+ "description": "A valid ACL number.",
+ "anyOf": [
+ {
+ "description": "Represents an [`i64`].",
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "description": "Represents a [`f64`].",
+ "type": "number",
+ "format": "double"
+ }
+ ]
+ },
+ "Target": {
+ "description": "Platform target.",
+ "oneOf": [
+ {
+ "description": "MacOS.",
+ "type": "string",
+ "enum": [
+ "macOS"
+ ]
+ },
+ {
+ "description": "Windows.",
+ "type": "string",
+ "enum": [
+ "windows"
+ ]
+ },
+ {
+ "description": "Linux.",
+ "type": "string",
+ "enum": [
+ "linux"
+ ]
+ },
+ {
+ "description": "Android.",
+ "type": "string",
+ "enum": [
+ "android"
+ ]
+ },
+ {
+ "description": "iOS.",
+ "type": "string",
+ "enum": [
+ "iOS"
+ ]
+ }
+ ]
+ },
+ "PermissionKind": {
+ "type": "string",
+ "oneOf": [
+ {
+ "description": "Enables the clear command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-clear",
+ "markdownDescription": "Enables the clear command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the clear command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-clear",
+ "markdownDescription": "Denies the clear command without any pre-configured scope."
+ },
+ {
+ "description": "Enables the registerListener command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-registerListener",
+ "markdownDescription": "Enables the registerListener command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the registerListener command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-registerListener",
+ "markdownDescription": "Denies the registerListener command without any pre-configured scope."
+ },
+ {
+ "description": "Enables the removeListener command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-removeListener",
+ "markdownDescription": "Enables the removeListener command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the removeListener command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-removeListener",
+ "markdownDescription": "Denies the removeListener command without any pre-configured scope."
+ },
+ {
+ "description": "Enables the update command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-update",
+ "markdownDescription": "Enables the update command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the update command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-update",
+ "markdownDescription": "Denies the update command without any pre-configured scope."
+ },
+ {
+ "description": "The downloads notice on the phone: `update` mirrors what Save is fetching,\n`clear` takes it down, and the two listener commands are what lets the page\nhear the notice's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`",
+ "type": "string",
+ "const": "default",
+ "markdownDescription": "The downloads notice on the phone: `update` mirrors what Save is fetching,\n`clear` takes it down, and the two listener commands are what lets the page\nhear the notice's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`"
+ }
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/crates/tauri-plugin-transfer-notice/src/lib.rs b/crates/tauri-plugin-transfer-notice/src/lib.rs
new file mode 100644
index 0000000..8507dc3
--- /dev/null
+++ b/crates/tauri-plugin-transfer-notice/src/lib.rs
@@ -0,0 +1,110 @@
+//! The downloads notice on the phone.
+//!
+//! The Transfers screen only exists inside the app: the desktop says what a
+//! Save is doing with its title-bar ring, the phone with a badge on the nav,
+//! and neither is visible once the app is in the background — which is
+//! exactly when a Save is left to run on its own. So a running Save says so
+//! where the phone says things, in the shade: how many files are left, the
+//! one it is on, a progress bar, Stop all and Open.
+//!
+//! Its own channel, low importance, so it can be silenced without silencing
+//! the player's card. Its own foreground service (`TransferService`,
+//! `dataSync`) rather than the media one, because a download is not
+//! playback: `MediaService` stops itself the moment the bar empties, and a
+//! `mediaPlayback` service with no session is a policy violation. That
+//! service is also what keeps the process alive for a Save with nothing
+//! playing — the way the first real Save died, four tracks in.
+//!
+//! Two commands, both fed by the phone UI (src/mobile/xfernote.svelte.js):
+//! `update` with what is still in flight, `clear` when the queue empties.
+//! The notice's buttons arrive as the plugin event `action`
+//! (`addPluginListener("transfer-notice", "action", …)`): `cancel` stops
+//! everything still going, `open` brings the app forward on Transfers.
+//!
+//! On the desktop the plugin is registered too, so the permission set
+//! resolves on every platform, but its commands answer "not here".
+
+use serde::{Deserialize, Serialize};
+use tauri::{
+ plugin::{Builder, TauriPlugin},
+ Runtime,
+};
+
+/// What the notice says: how many files are still in flight, the one it is
+/// on, and how far the burst has come. `indeterminate` is a queue whose
+/// sizes no peer has stated yet — a bar with no end to walk towards.
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct TransferNotice {
+ #[serde(default)]
+ pub count: u32,
+ pub title: Option,
+ pub line: Option,
+ #[serde(default)]
+ pub percent: i32,
+ #[serde(default)]
+ pub indeterminate: bool,
+}
+
+#[cfg(mobile)]
+pub struct TransferNoticePlugin(tauri::plugin::PluginHandle);
+
+#[tauri::command]
+fn update(app: tauri::AppHandle, state: TransferNotice) -> Result<(), String> {
+ #[cfg(mobile)]
+ {
+ use tauri::Manager;
+ let notice = app.state::>();
+ notice
+ .0
+ .run_mobile_plugin::("update", state)
+ .map(|_| ())
+ .map_err(|e| e.to_string())
+ }
+ #[cfg(not(mobile))]
+ {
+ let _ = (app, state);
+ Err("the downloads notice is a phone feature".into())
+ }
+}
+
+#[tauri::command]
+fn clear(app: tauri::AppHandle) -> Result<(), String> {
+ #[cfg(mobile)]
+ {
+ use tauri::Manager;
+ let notice = app.state::>();
+ notice
+ .0
+ .run_mobile_plugin::("clear", ())
+ .map(|_| ())
+ .map_err(|e| e.to_string())
+ }
+ #[cfg(not(mobile))]
+ {
+ let _ = app;
+ Err("the downloads notice is a phone feature".into())
+ }
+}
+
+pub fn init() -> TauriPlugin {
+ Builder::new("transfer-notice")
+ .invoke_handler(tauri::generate_handler![update, clear])
+ .setup(|app, api| {
+ #[cfg(target_os = "android")]
+ {
+ use tauri::Manager;
+ let handle = api.register_android_plugin(
+ "com.soulstream.transfernotice",
+ "TransferNoticePlugin",
+ )?;
+ app.manage(TransferNoticePlugin(handle));
+ }
+ #[cfg(not(target_os = "android"))]
+ {
+ let _ = (app, api);
+ }
+ Ok(())
+ })
+ .build()
+}
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 31729ad..ee78eec 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -60,6 +60,10 @@ audio-formats = { path = "../crates/audio-formats" }
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" }
+# The phone's downloads notice (a second Kotlin foreground service, dataSync).
+# Registered everywhere so the permission set resolves on every platform; on
+# the desktop its commands answer "not here".
+tauri-plugin-transfer-notice = { path = "../crates/tauri-plugin-transfer-notice" }
semver = "1"
tauri-plugin-opener = "2.5.5"
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index d860e1e..cfe2dc2 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -1,3 +1,8 @@
+// tauri-build only rewrites gen/android/tauri.settings.gradle and
+// app/tauri.build.gradle.kts — the lines that include every plugin's own
+// Android module — when this build script re-runs, so touch this file
+// whenever a plugin is added (ANDROID.md). The downloads notice was the
+// last one.
fn main() {
tauri_build::build()
}
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 847fde6..eb91c14 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -23,6 +23,7 @@
"media-session:default",
"opener:default",
"storage:default",
- "share:default"
+ "share:default",
+ "transfer-notice:default"
]
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index f21c577..8e386fa 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -85,6 +85,10 @@ pub fn run() {
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())?;
+ // The downloads in the phone's shade: a progress notice on its own
+ // channel and its own dataSync service, so a Save survives the app
+ // going to the background with nothing playing.
+ app.handle().plugin(tauri_plugin_transfer_notice::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 51a4797..64fe815 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -506,6 +506,10 @@ export const requestAllFilesAccess = () => invoke("plugin:storage|request_all_fi
// 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");
+// The phone's downloads notice (crates/tauri-plugin-transfer-notice): what
+// Save is still fetching, mirrored into the shade; `clear` takes it down.
+export const transferNotice = (state) => invoke("plugin:transfer-notice|update", { state });
+export const clearTransferNotice = () => invoke("plugin:transfer-notice|clear");
// A photo from the phone (the storage plugin): { path } in the app's cache.
export const pickImage = () => invoke("plugin:storage|pick_image");
// Download a found album copy into (Cache)\_audition\\ for the version
diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js
index 09c77d1..6691cd1 100644
--- a/src/lib/mockLibrary.js
+++ b/src/lib/mockLibrary.js
@@ -635,6 +635,11 @@ function mockCatalog(cmd, args) {
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:storage|pick_image") return Promise.resolve({ path: "C:\\mock\\photo.jpg" });
+ /* the downloads notice: the mock walk cannot see the shade, so the calls
+ are simply answered — what the walk reads is __ss.xfn, the app's own
+ record of having posted and cleared */
+ if (cmd === "plugin:transfer-notice|update") return Promise.resolve();
+ if (cmd === "plugin:transfer-notice|clear") 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;
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte
index bd8756f..798630d 100644
--- a/src/mobile/Mobile.svelte
+++ b/src/mobile/Mobile.svelte
@@ -34,6 +34,7 @@
import Transfers from "./screens/Transfers.svelte";
import Diagnostics from "./screens/Diagnostics.svelte";
import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";
+ import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js";
import { bindHome } from "./home.svelte.js";
import Stub from "./screens/Stub.svelte";
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js";
@@ -148,6 +149,14 @@
});
});
+ /* ── the shade's download notice follows the transfer store ───────
+ An effect, not a hook off the download event: the effect tracks the
+ store itself, progress ticks and all, so a row written straight into
+ it — the walk, with nothing fetched — drives the notice exactly as a
+ real one does. It reads the store and never the notice's own record,
+ which is why that record is a plain object. */
+ $effect(() => { syncShade(); });
+
/* ── the card gets the cover once the thumbnail has landed ───────── */
$effect(() => {
const k = player.album?.art_path;
@@ -213,6 +222,7 @@
bindNet();
watchLibrary();
bindTransfers();
+ bindXferNote();
bindHome();
bindImporter();
bindShare();
@@ -223,7 +233,7 @@
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* 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, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
+ window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
return () => { if (unlisten) unlisten(); };
});
diff --git a/src/mobile/screens/Transfers.svelte b/src/mobile/screens/Transfers.svelte
index 3fb9462..d4a8119 100644
--- a/src/mobile/screens/Transfers.svelte
+++ b/src/mobile/screens/Transfers.svelte
@@ -4,7 +4,7 @@
cancels it, finished rows clear together. The desktop's hub list. */
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
- import { transfers, DONE, list, active, clearFinished, cancel, stateLine } from "../transfers.svelte.js";
+ import { transfers, DONE, list, active, clearFinished, cancel, cancelAll, stateLine } from "../transfers.svelte.js";
import { fmtBytes } from "../util.js";
const rows = $derived(list());
@@ -19,7 +19,10 @@
Nothing in flight.
Save a record or a song from the catalog and it arrives here — the best copy by the download rules, filed into your library when it lands.
+ A Save keeps going with the app in the background, and says so in the shade — one notice with what is left, and Stop all on it.
{:else}
{live ? `${live} in flight` : "Done"} · {rows.length} total
diff --git a/src/mobile/shell.css b/src/mobile/shell.css
index a60059e..82ac276 100644
--- a/src/mobile/shell.css
+++ b/src/mobile/shell.css
@@ -1513,3 +1513,15 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
with its words already in it (PreviewOverlay.svelte's .sr-announce, at
phone scale) */
.sr-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
+
+/* The Transfers head's two words: Cancel all while anything is in flight,
+ Clear finished after. The .sec's own small caps, which this head never
+ had — "Clear finished" has been sitting there at body size.
+
+ The space between them goes on the words, NOT on .phead .acts: Library
+ and Community hang their header icons in that same wrapper and they sit
+ flush, so a gap there would push three round buttons apart on two
+ screens this round never looked at. */
+.phead .all { font-size: 12.5px; font-weight: 750; color: var(--text-secondary); white-space: nowrap; }
+.phead .all:active { color: #fff; }
+.phead .all + .all { margin-left: 15px; }
diff --git a/src/mobile/transfers.svelte.js b/src/mobile/transfers.svelte.js
index 6165c32..1dae65c 100644
--- a/src/mobile/transfers.svelte.js
+++ b/src/mobile/transfers.svelte.js
@@ -26,6 +26,12 @@ export async function cancel(id) {
try { await api.cancelDownload(id); } catch { /* already gone */ }
}
+/** Stop everything still in flight — what the shade's Stop all and the
+ * screen's own button both call, through the one path a row's tap takes. */
+export async function cancelAll() {
+ for (const d of active()) await cancel(d.id);
+}
+
/** "queued · 3rd in line", "transferring · 42%", "saved", "failed · reason" */
export function stateLine(d) {
switch (d.state) {
diff --git a/src/mobile/xfernote.svelte.js b/src/mobile/xfernote.svelte.js
new file mode 100644
index 0000000..408bb24
--- /dev/null
+++ b/src/mobile/xfernote.svelte.js
@@ -0,0 +1,115 @@
+/* The downloads in the phone's shade.
+
+ The Transfers screen is inside the app: the desktop says what a Save is
+ doing with its title-bar ring, the phone with a badge on the nav, and
+ neither is anywhere to be seen once the app is in the background — which
+ is exactly when a Save is left to run on its own. So a running Save says
+ so where the phone says things: one notice with how many files are left,
+ the one it is on, a bar, Stop all and Open.
+
+ Its own channel, so it can be silenced without silencing the player's
+ card, and its own foreground service, so the fetch outlives the playback
+ that happened to be keeping the process alive. Everything real is in
+ crates/tauri-plugin-transfer-notice; this is the wiring — the mirror of
+ transfers.svelte.js, exactly as media.svelte.js mirrors the player. */
+import * as api from "../lib/api.js";
+import { addPluginListener } from "@tauri-apps/api/core";
+import { active, cancelAll } from "./transfers.svelte.js";
+import { note } from "./notice.svelte.js";
+import { go, push, top, closeSheet } from "./nav.svelte.js";
+
+/* What the phone was last told, and how often. A PLAIN object, not $state:
+ nothing renders it, and the effect that drives the post must never read
+ what it writes — a self-read in an $effect kills the subtree. The walk
+ reads it through __ss.xfn, since a notification is not a thing a headless
+ page can be asked about. */
+export const xfernote = {
+ posted: false,
+ updates: 0,
+ clears: 0,
+ max: 0,
+ title: "",
+ line: "",
+ percent: -1,
+ failed: 0,
+};
+
+/* a re-post is not free, and progress ticks arrive far faster than anyone
+ reads them: the lever is the rate */
+const MIN_GAP = 700;
+let timer = null;
+let lastAt = 0;
+let lastKey = "";
+
+/** What is in flight, as the notice would say it — or null when nothing is. */
+function shape() {
+ const live = active();
+ if (!live.length) return null;
+ const newest = [...live].sort((a, b) => b.id - a.id); /* the screen's own order */
+ const cur = newest.find((d) => d.state === "transferring") || newest[0];
+ let received = 0, total = 0, known = 0;
+ for (const d of live) if (d.total) { received += d.received || 0; total += d.total; known++; }
+ return {
+ count: live.length,
+ title: live.length === 1 ? "Saving 1 file" : `Saving ${live.length} files`,
+ line: cur.state === "transferring" ? (cur.name || cur.path || "") : "waiting in their queue",
+ percent: total ? Math.round((received / total) * 100) : 0,
+ indeterminate: !known,
+ };
+}
+const keyOf = (s) => (s ? `${s.count}|${s.title}|${s.line}|${s.percent}` : "");
+
+/** Called from Mobile.svelte's effect: reads the store — so the effect
+ * tracks it — and schedules at most one post per MIN_GAP. */
+export function syncShade() {
+ const key = keyOf(shape());
+ if (key === lastKey || timer) return;
+ timer = setTimeout(post, Math.max(0, MIN_GAP - (Date.now() - lastAt)));
+}
+
+/** The one place that talks to the plugin. Reads the store again on the way
+ * out, so what lands in the shade is the latest, not the change that
+ * scheduled it. */
+async function post() {
+ timer = null;
+ lastAt = Date.now();
+ const s = shape();
+ lastKey = keyOf(s);
+ if (!s) {
+ if (!xfernote.posted) return;
+ xfernote.posted = false;
+ xfernote.clears++;
+ try { await api.clearTransferNotice(); } catch { xfernote.failed++; }
+ return;
+ }
+ xfernote.posted = true;
+ xfernote.updates++;
+ if (s.count > xfernote.max) xfernote.max = s.count;
+ xfernote.title = s.title;
+ xfernote.line = s.line;
+ xfernote.percent = s.percent;
+ try { await api.transferNotice(s); } catch { xfernote.failed++; }
+}
+
+let bound = false;
+/** The notice's own two buttons. Harmless where there is no plugin. */
+export async function bindXferNote() {
+ if (bound) return;
+ bound = true;
+ try {
+ await addPluginListener("transfer-notice", "action", (p) => {
+ if (p?.action === "cancel") {
+ /* the same cancel(id) a row's tap takes, once per live row —
+ never a second path to the same thing */
+ void cancelAll();
+ note("Downloads stopped.");
+ } else if (p?.action === "open") {
+ closeSheet();
+ go("you");
+ if (top().n !== "transfers") push("transfers", {});
+ }
+ });
+ } catch {
+ /* not a phone */
+ }
+}
diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs
index 1a3a372..5acdc63 100644
--- a/tests/mobile/smoke.mjs
+++ b/tests/mobile/smoke.mjs
@@ -154,6 +154,15 @@ const PROBE = `(function () {
bars: document.querySelectorAll('.trk .lbar').length,
save: (document.querySelector('.rec-acts .save') || {}).textContent || '',
xfers: document.querySelectorAll('.row.xfer').length,
+ /* the shade's notice, as the app's own record of having posted it —
+ a notification is not a thing the page can be asked about */
+ xfnOn: (function () { try { return !!__ss.xfn.posted; } catch (e) { return false; } })(),
+ xfnClr: (function () { try { return __ss.xfn.clears; } catch (e) { return -1; } })(),
+ xfnMax: (function () { try { return __ss.xfn.max; } catch (e) { return -1; } })(),
+ xfnBad: (function () { try { return __ss.xfn.failed; } catch (e) { return -1; } })(),
+ xfnTitle: (function () { try { return __ss.xfn.title; } catch (e) { return ''; } })(),
+ xfnLine: (function () { try { return __ss.xfn.line; } catch (e) { return ''; } })(),
+ xfnBtn: !![].slice.call(document.querySelectorAll('.phead .all')).find(function (x) { return /Cancel all/.test(x.textContent); }),
feat: (document.querySelector('.feat .kick') || {}).textContent || '',
gaplines: document.querySelectorAll('.gapline').length,
folderRow: !![].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Library folder/.test(x.textContent); }),
@@ -524,6 +533,15 @@ const MOCK_STEPS = [
{ do: `__settle()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 600 },
{ do: `__click('.screen .row', 'Transfers')`, name: "transfers", wait: 1200 },
+ /* the shade's copy of this screen, driven from the store itself: two
+ rows written straight in — nothing fetched — so the notice is posted
+ with a count and the file it is on, and Cancel all is on the head;
+ then the rows are taken away again and the notice must go with them */
+ { do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 1300 },
+ /* 2600 ms on purpose: the album burst drains 3.4-5.3 s after it starts
+ and this lands ten seconds after the Save, so "nothing in flight" is
+ settled state rather than a race against a mock still finishing */
+ { do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2600 },
{ do: `history.back()`, wait: 600 },
/* Gaps: the followed shelf, its missing records dimmed */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
@@ -853,6 +871,13 @@ const MOCK_EXPECT = {
"cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"),
"cat-album-save": (i) => /^Saving \d+ from /.test(i.save) && /^\d+$/.test(i.navBadges) && /\|You, \d+ transfers?$/.test(i.navTabs),
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
+ /* xfnMax >= 5 can only have come from the real album burst, and it is
+ monotonic, so it is not a race; the title and the line come from the
+ two injected rows (990001 is the newest transferring one, so it is
+ deterministically the current file); xfnBad === 0 proves the call
+ reached a handler instead of being swallowed */
+ "shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnMax >= 5 && /^Saving \d+ files$/.test(i.xfnTitle) && /walk-a\.flac/.test(i.xfnLine) && i.xfnBtn && i.xfers >= 13,
+ "shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.xfnBad === 0 && !i.xfnBtn,
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,
@@ -1031,6 +1056,15 @@ const PHONE_STEPS = [
{ do: `__click('.screen .row', 'Listening port')`, wait: 800 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50300'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-restored", wait: 1200 },
+ /* the shade, for real. The walk starts no download — two rows go
+ straight into the transfer store, which is the notice's only input —
+ and the phone's own notification is read back out of dumpsys, then
+ the rows are taken away and it has to be gone. Nothing is fetched
+ into Kyle's library, and the ids are far above the backend's counter */
+ { do: `__click('.screen .row', 'Transfers')`, name: "shade-open", wait: 1200 },
+ { do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 2400, probe: shadeNotice },
+ { do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2400, probe: shadeNotice },
+ { do: `history.back()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
/* the real config's contact list, read back over the real IPC */
@@ -1088,6 +1122,12 @@ const PHONE_EXPECT = {
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && !i.toast,
"you-port": (i) => !i.half.includes("up") && /^50310 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
"you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
+ "shade-open": (i) => i.title.startsWith("Transfers") && !i.toast,
+ "shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnBtn && /downloads/.test(i.shadeChans) && /^Saving \d+ files?$/.test(i.shadeTitle) && /walk-a\.flac/.test(i.shadeText),
+ /* the dump has to have been read for the negative to mean anything: one
+ that came back empty would pass "no downloads channel" on nothing at
+ all. A format change fails the step above, loudly, first */
+ "shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.shadeSeen > 0 && !/downloads/.test(i.shadeChans),
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),
@@ -1118,6 +1158,44 @@ const PHONE_EXPECT = {
end: (i) => /^(Good |Still)/.test(i.title),
};
+/* What OUR package has in the shade right now: the channels its live
+ * notifications sit on, and the downloads one's own title and text.
+ * `--noredact` is what makes the extras print their values rather than a
+ * placeholder. Nobody else's notifications are read — `seen` counts the
+ * dump's records only so that "no downloads channel" cannot pass by the
+ * dump having come back empty. */
+function shadeNotice() {
+ try {
+ const dump = adb("shell", "dumpsys", "notification", "--noredact").toString();
+ const blocks = dump.split("NotificationRecord(").slice(1);
+ const chans = [];
+ let title = "", text = "";
+ for (const block of blocks) {
+ const head = block.slice(0, 6000);
+ if (!/pkg=com\.soulstream\.app\b/.test(head)) continue;
+ /* Notification.toString() carries `channel=` on the record's
+ own first line; older dumps spell it as the channel object */
+ let ch = (head.match(/channel=([\w.\-]+)/) || [])[1] || "";
+ if (!ch || ch === "NotificationChannel") ch = (head.match(/mId='([^']*)'/) || [])[1] || ch;
+ if (ch) chans.push(ch);
+ if (ch !== "downloads") continue;
+ /* the extras print as `= ()`, and the class is
+ whatever CharSequence the builder kept — String on this one,
+ but a Spannable on a device that styles it, so the class is
+ matched loosely and stray quotes are trimmed */
+ const extra = (key) => {
+ const m = head.match(new RegExp(key.replace(/\./g, "\\.") + "=[A-Za-z]+ \\(([^\\n]*?)\\)\\s*\\n"));
+ return m ? m[1].replace(/^"|"$/g, "") : "";
+ };
+ title = extra("android.title");
+ text = extra("android.text");
+ }
+ return { shadeSeen: blocks.length, shadeChans: chans.join("|"), shadeTitle: title, shadeText: text };
+ } catch (e) {
+ return { shadeSeen: 0, shadeChans: "ERR " + e.message, shadeTitle: "", shadeText: "" };
+ }
+}
+
/* The system's view of the app's media session: who owns it and what the
* playback state says. From `dumpsys media_session`. */
function mediaSessionState() {
@@ -1294,6 +1372,9 @@ async function main() {
if (!s.name) continue;
await still(path.join(OUT, `${prefix}${s.name}.png`));
const info = await cdp.evaluate(PROBE);
+ /* a step may want the system's own view as well; dumpsys is slow,
+ so only the steps that ask for it pay for it */
+ if (PHONE && s.probe) { try { Object.assign(info, s.probe()); } catch (e) { problems.push("PROBE " + s.name + ": " + e.message); } }
const ok = expect[s.name] ? expect[s.name](info) : true;
if (!ok) problems.push("EXPECT " + s.name + " " + JSON.stringify(info));
console.log((ok ? " ok " : " FAIL ") + s.name.padEnd(16) + " " + JSON.stringify(info));