android: the downloads say so in the shade

A Save still running with the app in the background now posts a progress
notice — how many files, the one it is on, a bar, Stop all and Open — and
takes it down when the queue empties. It sits on its own low-importance
channel so it can be silenced without silencing the player's card, and
rides its own dataSync foreground service rather than the media one: a
mediaPlayback service stops itself when the bar empties, a download is not
playback, and nothing else was keeping the process alive for a Save with
nothing playing — which is how the first real Save died, four tracks in.
Stop all goes through the same cancel(id) a row's tap takes, and Transfers
grew the matching button beside Clear finished. The walk pushes two rows
straight into the transfer store and reads the real notification back out
of `dumpsys notification`, without fetching anything.
This commit is contained in:
KeelrLeek committed 2026-09-14 00:44:11 -05:00
1 parent 4fc6e9c630
commit 5738c2d7bf
31 files changed
+1358 -6

No files matched your search

+72 -2
View File
@@ -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/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/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/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/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 | | `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-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/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/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 | | `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 rewriting the walk's `.trk .r` selectors, for no gain a screen reader
would notice. 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 ## Performance
`tests/mobile/perf.mjs` logs the frames the WebView produces through `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 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 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 → 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 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 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 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 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 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 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 minutes; its audio check counts SoulStream's OWN tracks' underruns from the
mixer dump — the thread-wide counters move with any app's notification mixer dump — the thread-wide counters move with any app's notification
Generated
+11
View File
@@ -197,6 +197,7 @@ dependencies = [
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-share", "tauri-plugin-share",
"tauri-plugin-storage", "tauri-plugin-storage",
"tauri-plugin-transfer-notice",
"tauri-plugin-updater", "tauri-plugin-updater",
"tempfile", "tempfile",
"tokio", "tokio",
@@ -5863,6 +5864,16 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
] ]
[[package]]
name = "tauri-plugin-transfer-notice"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin",
]
[[package]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.11.0" version = "2.11.0"
+1
View File
@@ -15,6 +15,7 @@ members = [
"crates/tauri-plugin-media-session", "crates/tauri-plugin-media-session",
"crates/tauri-plugin-storage", "crates/tauri-plugin-storage",
"crates/tauri-plugin-share", "crates/tauri-plugin-share",
"crates/tauri-plugin-transfer-notice",
] ]
[workspace.package] [workspace.package]
@@ -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"] }
@@ -0,0 +1,2 @@
/build
/.tauri
@@ -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"))
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- the foreground service that carries the downloads while the app is
in the background; a fetch is dataSync, not playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- the notice is a notification; asked for at runtime on Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- no intent filter: the notice's own buttons come back as
onStartCommand intents on this very service -->
<service
android:name="com.soulstream.transfernotice.TransferService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -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)
}
}
@@ -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,
)
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- the status-bar glyph: an arrow into a tray, white on transparent, as
the shade wants. Its own copy on purpose — R.drawable.ic_soulstream_note
belongs to the media-session module's R class and is not visible here -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M11,3h2v7h3.5L12,15.5 7.5,10H11V3z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,17.5h14v2.5H5z" />
</vector>
@@ -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();
}
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`transfer-notice:allow-clear`
</td>
<td>
Enables the clear command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:deny-clear`
</td>
<td>
Denies the clear command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:allow-registerListener`
</td>
<td>
Enables the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:deny-registerListener`
</td>
<td>
Denies the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:allow-removeListener`
</td>
<td>
Enables the removeListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:deny-removeListener`
</td>
<td>
Denies the removeListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:allow-update`
</td>
<td>
Enables the update command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`transfer-notice:deny-update`
</td>
<td>
Denies the update command without any pre-configured scope.
</td>
</tr>
</table>
@@ -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"]
@@ -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 `<h4>` 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 `<h4>` 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`"
}
]
}
}
}
@@ -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<String>,
pub line: Option<String>,
#[serde(default)]
pub percent: i32,
#[serde(default)]
pub indeterminate: bool,
}
#[cfg(mobile)]
pub struct TransferNoticePlugin<R: Runtime>(tauri::plugin::PluginHandle<R>);
#[tauri::command]
fn update<R: Runtime>(app: tauri::AppHandle<R>, state: TransferNotice) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let notice = app.state::<TransferNoticePlugin<R>>();
notice
.0
.run_mobile_plugin::<serde_json::Value>("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<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let notice = app.state::<TransferNoticePlugin<R>>();
notice
.0
.run_mobile_plugin::<serde_json::Value>("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<R: Runtime>() -> TauriPlugin<R> {
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()
}
+4
View File
@@ -60,6 +60,10 @@ audio-formats = { path = "../crates/audio-formats" }
tauri-plugin-media-session = { path = "../crates/tauri-plugin-media-session" } tauri-plugin-media-session = { path = "../crates/tauri-plugin-media-session" }
tauri-plugin-storage = { path = "../crates/tauri-plugin-storage" } tauri-plugin-storage = { path = "../crates/tauri-plugin-storage" }
tauri-plugin-share = { path = "../crates/tauri-plugin-share" } 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" semver = "1"
tauri-plugin-opener = "2.5.5" tauri-plugin-opener = "2.5.5"
+5
View File
@@ -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() { fn main() {
tauri_build::build() tauri_build::build()
} }
+2 -1
View File
@@ -23,6 +23,7 @@
"media-session:default", "media-session:default",
"opener:default", "opener:default",
"storage:default", "storage:default",
"share:default" "share:default",
"transfer-notice:default"
] ]
} }
+4
View File
@@ -85,6 +85,10 @@ pub fn run() {
app.handle().plugin(tauri_plugin_storage::init())?; app.handle().plugin(tauri_plugin_storage::init())?;
// The share sheet on the phone: a link sent from another app lands in the import. // The share sheet on the phone: a link sent from another app lands in the import.
app.handle().plugin(tauri_plugin_share::init())?; 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)] #[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Must run before anything touches the app-data dir. // Must run before anything touches the app-data dir.
+4
View File
@@ -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 // The phone's share plugin (crates/tauri-plugin-share): what another app
// shared to SoulStream, { text } — read once. // shared to SoulStream, { text } — read once.
export const pendingShare = () => invoke("plugin:share|pending_share"); 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. // A photo from the phone (the storage plugin): { path } in the app's cache.
export const pickImage = () => invoke("plugin:storage|pick_image"); export const pickImage = () => invoke("plugin:storage|pick_image");
// Download a found album copy into (Cache)\_audition\<rel>\ for the version // Download a found album copy into (Cache)\_audition\<rel>\ for the version
+5
View File
@@ -635,6 +635,11 @@ function mockCatalog(cmd, args) {
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true }); 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|request_all_files_access") return Promise.resolve();
if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\mock\\photo.jpg" }); 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") { if (cmd === "plugin:share|pending_share") {
/* the harness shares by setting window.__ssMock.share and waking the page */ /* the harness shares by setting window.__ssMock.share and waking the page */
const t = (typeof window !== "undefined" && window.__ssMock?.share) || null; const t = (typeof window !== "undefined" && window.__ssMock?.share) || null;
+11 -1
View File
@@ -34,6 +34,7 @@
import Transfers from "./screens/Transfers.svelte"; import Transfers from "./screens/Transfers.svelte";
import Diagnostics from "./screens/Diagnostics.svelte"; import Diagnostics from "./screens/Diagnostics.svelte";
import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js"; 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 { bindHome } from "./home.svelte.js";
import Stub from "./screens/Stub.svelte"; import Stub from "./screens/Stub.svelte";
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js"; 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 ───────── */ /* ── the card gets the cover once the thumbnail has landed ───────── */
$effect(() => { $effect(() => {
const k = player.album?.art_path; const k = player.album?.art_path;
@@ -213,6 +222,7 @@
bindNet(); bindNet();
watchLibrary(); watchLibrary();
bindTransfers(); bindTransfers();
bindXferNote();
bindHome(); bindHome();
bindImporter(); bindImporter();
bindShare(); bindShare();
@@ -223,7 +233,7 @@
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000); setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* the probe handle — the desktop has __soulstream for the same reason: /* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */ 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(); }; return () => { if (unlisten) unlisten(); };
}); });
</script> </script>
+6 -2
View File
@@ -4,7 +4,7 @@
cancels it, finished rows clear together. The desktop's hub list. */ cancels it, finished rows clear together. The desktop's hub list. */
import Icon from "../../components/Icon.svelte"; import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.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"; import { fmtBytes } from "../util.js";
const rows = $derived(list()); const rows = $derived(list());
@@ -19,7 +19,10 @@
<div class="phead"> <div class="phead">
<div class="row"> <div class="row">
<h1>Transfers</h1> <h1>Transfers</h1>
{#if rows.some((d) => DONE.has(d.state))}<button class="all" onclick={clearFinished}>Clear finished</button>{/if} <div class="acts">
{#if live}<button class="all" onclick={cancelAll}>Cancel all</button>{/if}
{#if rows.some((d) => DONE.has(d.state))}<button class="all" onclick={clearFinished}>Clear finished</button>{/if}
</div>
</div> </div>
</div> </div>
@@ -27,6 +30,7 @@
<div class="stub"> <div class="stub">
<b>Nothing in flight.</b> <b>Nothing in flight.</b>
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. 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.
</div> </div>
{:else} {:else}
<div class="sec"><h2>{live ? `${live} in flight` : "Done"} <em>· {rows.length} total</em></h2></div> <div class="sec"><h2>{live ? `${live} in flight` : "Done"} <em>· {rows.length} total</em></h2></div>
+12
View File
@@ -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 with its words already in it (PreviewOverlay.svelte's .sr-announce, at
phone scale) */ phone scale) */
.sr-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } .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; }
+6
View File
@@ -26,6 +26,12 @@ export async function cancel(id) {
try { await api.cancelDownload(id); } catch { /* already gone */ } 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" */ /** "queued · 3rd in line", "transferring · 42%", "saved", "failed · reason" */
export function stateLine(d) { export function stateLine(d) {
switch (d.state) { switch (d.state) {
+115
View File
@@ -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 */
}
}
+81
View File
@@ -154,6 +154,15 @@ const PROBE = `(function () {
bars: document.querySelectorAll('.trk .lbar').length, bars: document.querySelectorAll('.trk .lbar').length,
save: (document.querySelector('.rec-acts .save') || {}).textContent || '', save: (document.querySelector('.rec-acts .save') || {}).textContent || '',
xfers: document.querySelectorAll('.row.xfer').length, 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 || '', feat: (document.querySelector('.feat .kick') || {}).textContent || '',
gaplines: document.querySelectorAll('.gapline').length, gaplines: document.querySelectorAll('.gapline').length,
folderRow: !![].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Library folder/.test(x.textContent); }), 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: `__settle()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 600 }, { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 600 },
{ do: `__click('.screen .row', 'Transfers')`, name: "transfers", wait: 1200 }, { 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 }, { do: `history.back()`, wait: 600 },
/* Gaps: the followed shelf, its missing records dimmed */ /* Gaps: the followed shelf, its missing records dimmed */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, { 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-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), "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, 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: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
"gaps-open": (i) => i.gapCards >= 3, "gaps-open": (i) => i.gapCards >= 3,
playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned, 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: `__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: `(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 }, { 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')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 }, { do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
/* the real config's contact list, read back over the real IPC */ /* 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-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": (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, "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"), 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-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-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), 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=<id>` 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 `<key>=<class> (<value>)`, 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 /* The system's view of the app's media session: who owns it and what the
* playback state says. From `dumpsys media_session`. */ * playback state says. From `dumpsys media_session`. */
function mediaSessionState() { function mediaSessionState() {
@@ -1294,6 +1372,9 @@ async function main() {
if (!s.name) continue; if (!s.name) continue;
await still(path.join(OUT, `${prefix}${s.name}.png`)); await still(path.join(OUT, `${prefix}${s.name}.png`));
const info = await cdp.evaluate(PROBE); 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; const ok = expect[s.name] ? expect[s.name](info) : true;
if (!ok) problems.push("EXPECT " + s.name + " " + JSON.stringify(info)); if (!ok) problems.push("EXPECT " + s.name + " " + JSON.stringify(info));
console.log((ok ? " ok " : " FAIL ") + s.name.padEnd(16) + " " + JSON.stringify(info)); console.log((ok ? " ok " : " FAIL ") + s.name.padEnd(16) + " " + JSON.stringify(info));