android: a playlist file, picked

The storage plugin grew `pick_file` beside `pick_folder` and `pick_image`:
the document sheet with the caller's own filter, answered with a path Rust
can open. A document already on this phone hands its path over as it lies —
the document sibling of the same volume:relative id the folder picker
resolves, plus the Downloads provider's raw: — so a 300 MB pick copies
nothing; one that has no path is copied into cacheDir/picked under its own
name, and only as far as the caller allows, because a playlist is kilobytes
and a DJ set is not. Those copies are swept at the start of every pick,
which pick_image's never were.

The import sheet has its fifth door, on round 24's screen, wait card and
review, and the note that apologised for its absence says the one true thing
about it instead. `playlist_import_probe` took a path and a name the way
`playlist_import_folder` took a path, and the name is the load-bearing half:
the parser picks its format off the extension and titles the draft from the
stem, so a cache copy under an invented name would have read every playlist
as plain text. The mock walk drives the door and both of the picker's
refusals through round 31's fail door; the phone walk opens the real sheet
and dismisses it with BACK, reading nothing and writing nothing.

The brief asked for a second consumer — sending a file in a chat — and there
is nothing behind it: `community_pick_file` is the persona layer's three
native dialogs, `soulseek-client` has `say_room` and `message_user` and
nothing that sends a file, and both frontends' thread composers are text
only. Nothing was invented for it. The plugin command is general enough — a
caller's own filter, a zero-copy path, a bounded copy — that the one reader
still waiting, the local set listener the phone puts off by design, needs no
further plugin change. Playlist export is not on that list and never was:
writing a document out is the share plugin's errand, not a picker's. The
Kotlin is unbuilt — nothing on this machine compiles it.
This commit is contained in:
KeelrLeek committed 2026-09-14 02:55:56 -05:00
1 parent 04c87e0db1
commit defd8c5e6d
16 files changed
+547 -45

No files matched your search

+38 -11
View File
@@ -207,6 +207,27 @@ the system screen that grants it (You shows the row only while the folder
is outside the app's private data and access is missing). Nothing is read
outside the folder the user picked.
**A file** from the phone is the same plugin's `pick_file`, beside
`pick_folder` and `pick_image`: Android's document picker with the caller's
own MIME filter, answered with `{ path, name, size, copied }`. It copies as
little as it can, because an image is small and an audio file is not. A
document already on this phone's storage hands its own path over as it lies
— the DOCUMENT sibling of the tree id the folder picker resolves, plus the
Downloads provider's `raw:` — so picking a 300 MB file costs nothing and
touches no cache. One that has no path (a cloud provider's, a document a
provider only streams) is copied into `cacheDir/picked` under its own
display name, and only as far as the caller's `maxCopyBytes` allows: zero
refuses the copy outright, which is what an audio-sized pick wants, and the
copy counts itself as it goes because a provider's stated size is often
absent and sometimes wrong. Those copies are swept at the start of every
pick — unlike `pick_image`'s, which live in the cache for ever — so a path
from one pick is gone by the next. The display name travels beside the path
because it is load-bearing: `playlist_formats::detect_format` reads the
extension off it and `parse` titles the draft from its stem, so a copy under
an invented name would read every playlist as plain text. The dialog plugin
cannot serve here: `tauri-plugin-dialog` does open the sheet from Rust, but
it answers with a `content://` URI, and `std::fs` cannot take one.
**Playback** (You → Playback) has the desktop's volume normalization switch
(−14 LUFS through the player crate's BS.1770 meter; on by default). There is
no graphic equalizer in the app — the "eq" in the code is the animated bars
@@ -445,7 +466,8 @@ describe / pin / duplicate / delete, `playlist_add_items`,
desktop's commands already emit; the page re-reads its rows when the
playlist's revision moves.
- **Importing** (`importer.svelte.js`, `screens/Import.svelte`): the
shelf's Import opens four doors — a link (YouTube · Spotify · Apple
shelf's Import opens five doors — a playlist file on this phone (M3U ·
M3U8 · PLS · XSPF · CSV · JSON), a link (YouTube · Spotify · Apple
Music · TIDAL · Deezer; a YouTube playlist link rides this one too, the
backend dispatches it by host), a pasted tracklist (plain lines, a
timestamped setlist, or track links), a YouTube DJ set's posted
@@ -456,7 +478,15 @@ describe / pin / duplicate / delete, `playlist_add_items`,
wait screen counts them as they come. The folder door picks with the
storage plugin's document-tree picker and hands the resolved path to
`playlist_import_folder`, which grew a `path` argument for it: on the
phone that command had no dialog to open and always answered nothing.
phone that command had no dialog to open and always answered nothing. The
file door does the same through the plugin's `pick_file`, and
`playlist_import_probe` grew `path` AND `name` for it — the picked
document's own display name is what `detect_format` reads the extension
off and what titles the draft, and a document this phone had no path for
arrives as a cache copy under a sanitized filename. A copied file's rows
still find the library: a relative locator that misses falls back to the
basename index, which is the one thing that reads differently from the
desktop, and the sheet says so.
The backend stages the draft over plain HTTP (the same commands as the
desktop, no yt-dlp involved), the review screen shows every row's
verdict, the stream rows are verified against the catalog while it is up
@@ -491,9 +521,8 @@ describe / pin / duplicate / delete, `playlist_add_items`,
anything else the pasted-tracklist door. The phone walk sends the intent
itself (`adb shell am start -a android.intent.action.SEND …`).
Not on the phone yet: the playlist-file door — it wants a file picker the
storage plugin has still to grow — the listener door (it needs yt-dlp, a
desktop tool), drag reorder, and Share on SoulStream.
Not on the phone yet: the listener door (it needs
yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.
## The first run
@@ -1120,8 +1149,10 @@ shelf and a page with every row kind (a local row, the stream row through
the resolver, the filter, a removal and its undo, unpin, rename, a new
playlist, Add to playlist from a record's sheet, delete) → the import doors
(a pasted tracklist, a link with its catalog verification, pasted track
links read a page each, a folder of audio files off the phone, a share
from another app) → the playlist repair (an unknown row relinked from the
links read a page each, a folder of audio files off the phone, a playlist
file through the phone's own document picker — with that picker cancelled
and a file it can hand no path for refused — and a share from another
app) → the playlist repair (an unknown row relinked from the
library) → the artwork viewer → a queue row's sheet → a catalog song's,
a catalog card's and the record page's sheets → the now-playing Save →
a peer's sheet, a raw file's sheet, a user's shares browsed and a folder
@@ -1228,10 +1259,6 @@ symptom.
- **Off by design — external runtimes.** yt-dlp and deno, the vocal
separator, the lyric alignment models, and the listener door that needs
them.
- **Waiting on a picker.** The storage plugin can WRITE a document now
(`create_document`, which the playlist export goes through) but it cannot
read one. Playlist-FILE import wants a `pick_file` in the storage plugin,
beside the `pick_folder` and `pick_image` it already has.
- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top
stand in), catalog track previews, the full discography page, and the
fused-artist portrait verdict — that last one needs the identity merge the
@@ -29,6 +29,23 @@ class CreateDocumentArgs {
var text: String? = null
}
/** What a pick may offer and what it may copy. The filter is the document
* sheet's own, comma-separated ("audio/mpeg,audio/flac"); blank offers
* every type. `maxCopyBytes` is how big a document may be COPIED into the
* app's cache when it has no path of its own on this phone — 0 refuses the
* copy, which is what an audio-sized pick wants. Both are plain scalars on
* purpose: String / Int / Boolean is what the other plugins' args prove. */
@InvokeArg
class PickFileArgs {
var mimeTypes: String? = null
var maxCopyBytes: Int = 0
}
/** The copy walked past what the caller allowed. Its own exception, so the
* partial file can be deleted and the caller told in megabytes rather than
* in "could not be read". */
private class TooBig(message: String) : Exception(message)
/**
* The phone's storage for the library: a folder picked through Android's
* document tree picker (its content URI kept with a persistable grant, and
@@ -204,4 +221,174 @@ class StoragePlugin(private val activity: Activity) : Plugin(activity) {
else -> invoke.reject("the picker failed")
}
}
/** A FILE from the phone: the document picker with the caller's own
* filter. A document that is already on this phone's storage is handed
* over as it lies — nothing is copied, so a 300 MB pick costs nothing
* — and one that has no path of its own is copied into cacheDir/picked
* under its own display name, as far as `maxCopyBytes` allows and no
* further. Zero refuses the copy outright.
*
* The copies are swept at the start of every pick: pickImage's own
* copies live in the cache for ever, and an audio file is not an
* image. So a path handed out by one pick is gone by the next — read
* it in the same breath.
*/
@Command
fun pickFile(invoke: Invoke) {
val args = invoke.parseArgs(PickFileArgs::class.java)
sweepPicked()
val types = (args.mimeTypes ?: "").split(',').map { it.trim() }.filter { it.isNotEmpty() }
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
if (types.size == 1) {
intent.type = types[0]
} else {
intent.type = "*/*"
if (types.isNotEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
}
startActivityForResult(invoke, intent, "filePicked")
}
@ActivityCallback
fun filePicked(invoke: Invoke, result: ActivityResult) {
when (result.resultCode) {
Activity.RESULT_OK -> {
val uri = result.data?.data
if (uri == null) { invoke.reject("no file came back"); return }
val info = openableInfo(uri)
val name = info.first ?: "picked"
val stated = info.second
val out = JSObject()
out.put("name", name)
/* the document's own path first, and nothing copied: a row
naming a file beside it still resolves against the real
folder, and the size stops mattering */
val here = documentPath(uri)
if (here != null) {
out.put("path", here)
out.put("copied", false)
if (stated != null) out.put("size", stated)
invoke.resolve(out)
return
}
/* the same Invoke the pick was made with — PluginHandle
holds it in the result closure — so the cap is read back
off the caller's own arguments rather than stashed in a
field a recreated activity would have reset to zero */
val cap = invoke.parseArgs(PickFileArgs::class.java).maxCopyBytes.toLong()
if (cap <= 0L) { invoke.reject("that file is not on this phone's storage"); return }
if (stated != null && stated > cap) {
invoke.reject("that file is " + (stated / (1024 * 1024)) + " MB — too large to copy here")
return
}
try {
val copy = copyInto(uri, name, cap)
if (copy == null) { invoke.reject("that file could not be read"); return }
out.put("path", copy.absolutePath)
out.put("copied", true)
out.put("size", copy.length())
invoke.resolve(out)
} catch (e: TooBig) {
invoke.reject(e.message ?: "that file is too large to copy here")
} catch (e: Exception) {
invoke.reject("that file could not be read: " + e.message)
}
}
Activity.RESULT_CANCELED -> invoke.reject("cancelled")
else -> invoke.reject("the picker failed")
}
}
/** DISPLAY_NAME and SIZE off the provider. The name is load-bearing:
* the Rust parser picks the format off the extension and titles the
* draft from the stem, so a copy under an invented name would read
* every playlist as plain text. SIZE is often absent, and absent is
* not zero — the copy counts for itself. */
private fun openableInfo(uri: Uri): Pair<String?, Long?> {
var name: String? = null
var size: Long? = null
try {
activity.contentResolver.query(uri, null, null, null, null)?.use { c ->
if (c.moveToFirst()) {
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (ni >= 0 && !c.isNull(ni)) name = c.getString(ni)
val si = c.getColumnIndex(OpenableColumns.SIZE)
if (si >= 0 && !c.isNull(si)) size = c.getLong(si)
}
}
} catch (_: Exception) { /* a provider that answers nothing still has a document */ }
return Pair(name, size)
}
/** The document's own path, when this phone has one for it: the
* Downloads provider's "raw:/storage/…", and the external-storage
* provider's "primary:Music/set.m3u" — the DOCUMENT sibling of the
* tree id pathOf resolves. Null when it names no path, and null when
* this app may not read the one it names (all-files access is not
* always granted, and a path Rust cannot open is worse than none). */
private fun documentPath(uri: Uri): String? {
val id = try { DocumentsContract.getDocumentId(uri) } catch (_: Exception) { return null }
val path = if (id.startsWith("raw:")) {
id.substring(4)
} else if (uri.authority == "com.android.externalstorage.documents") {
val i = id.indexOf(':')
if (i < 0) return null
val volume = id.substring(0, i)
val rel = id.substring(i + 1).trimEnd('/')
val root = if (volume == "primary") Environment.getExternalStorageDirectory().absolutePath else "/storage/$volume"
if (rel.isEmpty()) root else "$root/$rel"
} else {
return null
}
return if (java.io.File(path).canRead()) path else null
}
private fun pickedDir(): java.io.File {
val dir = java.io.File(activity.cacheDir, "picked")
if (!dir.isDirectory) dir.mkdirs()
return dir
}
/** The last pick's copy, gone. A playlist is kilobytes and a DJ set is
* not, and nothing here is worth keeping once the caller has read it. */
private fun sweepPicked() {
try { pickedDir().listFiles()?.forEach { it.delete() } } catch (_: Exception) { /* the copy is a courtesy */ }
}
/** The picked document, copied under its own name so the extension
* survives, counted as it goes so a provider that understated its size
* cannot fill the cache; a partial copy is deleted. */
private fun copyInto(uri: Uri, name: String, cap: Long): java.io.File? {
val target = java.io.File(pickedDir(), safeName(name))
val input = activity.contentResolver.openInputStream(uri) ?: return null
var total = 0L
try {
input.use { i ->
target.outputStream().use { o ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = i.read(buf)
if (n < 0) break
total += n.toLong()
if (total > cap) throw TooBig("that file is larger than " + (cap / (1024 * 1024)) + " MB")
o.write(buf, 0, n)
}
}
}
} catch (e: Exception) {
target.delete()
throw e
}
return target
}
/** A name the cache can hold: the document's own, minus anything that
* could walk out of the folder. What the CALLER parses by is the
* display name, which travels beside the path untouched. */
private fun safeName(name: String): String {
val base = name.substringAfterLast('/').substringAfterLast('\\').trim()
val clean = base.replace(Regex("[^A-Za-z0-9 ._()\\[\\]+&,'-]"), "_")
return if (clean.isEmpty() || clean == "." || clean == "..") "picked" else clean.take(120)
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
// The three commands, as the Rust functions are named; the ACL slugs them
// The commands, as the Rust functions are named; the ACL slugs them
// (`allow-pick-folder`, …) into permissions/autogenerated.
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image"];
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image", "pick_file"];
fn main() {
// Registers the command permissions and, on Android, hands the Gradle
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pick-file"
description = "Enables the pick_file command without any pre-configured scope."
commands.allow = ["pick_file"]
[[permission]]
identifier = "deny-pick-file"
description = "Denies the pick_file command without any pre-configured scope."
commands.deny = ["pick_file"]
@@ -1,8 +1,10 @@
## Default Permission
The phone's storage: pick a library folder (a Storage Access Framework tree,
resolved to a path when the volume allows), and Android's all-files access —
whether it is granted, and the system screen that grants it.
resolved to a path when the volume allows), pick a photo or a file (the
document picker, answered with a path — the document's own where this phone
has one, else a bounded copy in the app's cache), and Android's all-files
access — whether it is granted, and the system screen that grants it.
#### This default permission set includes the following:
@@ -10,6 +12,7 @@ whether it is granted, and the system screen that grants it.
- `allow-all-files-access`
- `allow-request-all-files-access`
- `allow-pick-image`
- `allow-pick-file`
## Permission Table
@@ -49,6 +52,32 @@ Denies the all_files_access command without any pre-configured scope.
<tr>
<td>
`storage:allow-pick-file`
</td>
<td>
Enables the pick_file command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:deny-pick-file`
</td>
<td>
Denies the pick_file command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:allow-pick-folder`
</td>
@@ -3,7 +3,9 @@
[default]
description = """
The phone's storage: pick a library folder (a Storage Access Framework tree,
resolved to a path when the volume allows), and Android's all-files access —
whether it is granted, and the system screen that grants it.
resolved to a path when the volume allows), pick a photo or a file (the
document picker, answered with a path — the document's own where this phone
has one, else a bounded copy in the app's cache), and Android's all-files
access — whether it is granted, and the system screen that grants it.
"""
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image"]
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image", "allow-pick-file"]
@@ -306,6 +306,18 @@
"const": "deny-all-files-access",
"markdownDescription": "Denies the all_files_access command without any pre-configured scope."
},
{
"description": "Enables the pick_file command without any pre-configured scope.",
"type": "string",
"const": "allow-pick-file",
"markdownDescription": "Enables the pick_file command without any pre-configured scope."
},
{
"description": "Denies the pick_file command without any pre-configured scope.",
"type": "string",
"const": "deny-pick-file",
"markdownDescription": "Denies the pick_file command without any pre-configured scope."
},
{
"description": "Enables the pick_folder command without any pre-configured scope.",
"type": "string",
@@ -343,10 +355,10 @@
"markdownDescription": "Denies the request_all_files_access command without any pre-configured scope."
},
{
"description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`",
"description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`",
"type": "string",
"const": "default",
"markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`"
"markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`"
}
]
}
+81 -6
View File
@@ -8,11 +8,26 @@
//! for a folder in shared storage, Android's all-files access, which only
//! the user can grant on a system screen.
//!
//! Three commands: `pick_folder` → `{ uri, path }` (path null when the tree
//! is not on a volume with a path), `all_files_access` → `{ granted }`, and
//! `request_all_files_access`, which opens the system screen. On the desktop
//! the plugin is registered so the permission set resolves, and every
//! command answers "not here".
//! Five commands: `pick_folder` → `{ uri, path }` (path null when the tree
//! is not on a volume with a path), `all_files_access` → `{ granted }`,
//! `request_all_files_access`, which opens the system screen, `pick_image`
//! → `{ path }` (a photo, copied into the app's cache), and `pick_file` →
//! `{ path, name, size, copied }` — the document picker with the caller's
//! own MIME filter.
//!
//! `pick_file` copies as little as it can, because an image is small and an
//! audio file is not. A document already on this phone's storage hands its
//! own path over as it lies, so picking a 300 MB file costs nothing; one
//! with no path of its own is copied into `cacheDir/picked` under its own
//! display name, and only as far as `maxCopyBytes` allows — zero refuses
//! the copy outright. Those copies are swept at the start of every pick, so
//! a path from one pick is gone by the next: read it in the same breath.
//! The display name travels beside the path because it is load-bearing —
//! the playlist parser picks its format off the extension and titles the
//! draft from the stem.
//!
//! On the desktop the plugin is registered so the permission set resolves,
//! and every command answers "not here".
use serde::{Deserialize, Serialize};
use tauri::{
@@ -33,6 +48,32 @@ pub struct AccessState {
pub granted: bool,
}
/// What a pick offers and what it may copy. `mime_types` is the document
/// sheet's own filter, comma-separated; empty offers every type.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickFileArgs {
pub mime_types: Option<String>,
/// How big a document may be COPIED into the app's cache when it has no
/// path of its own on this phone. Zero refuses the copy.
pub max_copy_bytes: u32,
}
/// A picked document. `path` is the document's own when this phone has one
/// for it, else the cache copy's (`copied`); `name` is the display name the
/// provider gave it, which is what decides a playlist's format and title.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedFile {
pub path: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub copied: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedImage {
@@ -97,6 +138,40 @@ fn request_all_files_access<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(),
}
}
/// A FILE from the phone: the document picker with the caller's own MIME
/// filter, answered with a path the Rust side can open. A document already
/// on this phone's storage is handed over as it lies; one that has no path
/// is copied into the app's cache under its own name, and only as far as
/// `max_copy_bytes` allows — zero refuses the copy, which is what a pick
/// that could be hundreds of megabytes wants.
#[tauri::command]
async fn pick_file<R: Runtime>(
app: tauri::AppHandle<R>,
mime_types: Option<String>,
max_copy_bytes: Option<u32>,
) -> Result<PickedFile, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<PickedFile>(
"pickFile",
PickFileArgs {
mime_types,
max_copy_bytes: max_copy_bytes.unwrap_or(0),
},
)
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, mime_types, max_copy_bytes);
Err("the file picker here is the desktop's dialog".into())
}
}
/// A photo from the phone: the document picker limited to images, the
/// picked content copied into the app's cache — a plain path for the Rust
/// side (a playlist's cover, for one).
@@ -180,7 +255,7 @@ pub fn create_document<R: Runtime>(
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("storage")
.invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image])
.invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image, pick_file])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
+32 -4
View File
@@ -670,13 +670,27 @@ pub struct ImportDraft {
/// Open a playlist file and stage it for review. Parsing and resolution only —
/// nothing is written until `playlist_import_commit`.
///
/// The phone has no dialog here, so it picks the file itself (the storage
/// plugin's document picker) and hands the path in — with the picked
/// document's own `name` beside it, because a document that had no path of
/// its own arrives as a copy in the app's cache, and the NAME is what
/// `detect_format` reads the extension off and what titles the draft. The
/// desktop passes neither and opens its own dialog.
#[tauri::command]
pub async fn playlist_import_probe(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
path: Option<String>,
name: Option<String>,
) -> Result<Option<ImportDraft>, String> {
let picked = match path {
Some(p) if !p.trim().is_empty() => Some(p),
_ => {
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
#[cfg(desktop)]
{
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter(
@@ -686,14 +700,28 @@ pub async fn playlist_import_probe(
.pick_file(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let Some(source) = rx.await.unwrap_or(None) else {
}
#[cfg(mobile)]
{
let _ = &app;
let _ = tx.send(None);
}
rx.await.unwrap_or(None)
}
};
let Some(source) = picked else {
return Ok(None);
};
let source_path = PathBuf::from(&source);
let name = source_path
// The picked name wins over the path's own: a cache copy is named after
// the document, but only the document's name is guaranteed to be it.
let name = match name {
Some(n) if !n.trim().is_empty() => n,
_ => source_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| source.clone());
.unwrap_or_else(|| source.clone()),
};
// Size-check before reading, and read off the async runtime — a playlist
// picked from a dead network share must not hang the whole backend.
let read_path = source_path.clone();
+15 -2
View File
@@ -279,9 +279,14 @@ export const playlistCover = (id, maxEdge = null) =>
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
export const playlistCoverSet = (id, path) => invoke("playlist_cover_set", { id, path });
// Import: probe opens a file dialog and stages a reviewed draft; text stages
// Import: probe stages a reviewed draft from a playlist file; text stages
// a pasted tracklist; commit writes the accepted draft in one transaction.
export const playlistImportProbe = () => invoke("playlist_import_probe");
// The desktop passes neither argument and opens its own dialog; the phone
// picks with the storage plugin and hands the path in — with the picked
// document's own name, which is what chooses the parser and titles the
// draft (a copied file is not always named what the document was).
export const playlistImportProbe = (path = null, name = null) =>
invoke("playlist_import_probe", { path, name });
// The desktop opens its own folder dialog (no path); the phone picks with
// the storage plugin and hands the resolved path in.
export const playlistImportFolder = (path = null) => invoke("playlist_import_folder", { path });
@@ -519,6 +524,14 @@ export const transferNotice = (state) => invoke("plugin:transfer-notice|update",
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");
// A FILE from the phone (the storage plugin): { path, name, size, copied }.
// `mimeTypes` is the document sheet's filter, comma-separated; `maxCopyBytes`
// is how big a document may be copied into the app's cache when it has no
// path of its own on this phone — 0 refuses the copy, which is what an
// audio-sized pick wants. The name comes back beside the path because a
// copy is named after the document and only the document's name is it.
export const pickFile = (mimeTypes, maxCopyBytes = 0) =>
invoke("plugin:storage|pick_file", { mimeTypes, maxCopyBytes });
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
// second batch's indices continuing, since index 0 means "track 1".
+22
View File
@@ -328,6 +328,21 @@ function mockImport(cmd, args) {
}, 600);
});
}
if (cmd === "playlist_import_probe") {
const path = String(args?.path || "");
if (!path) return Promise.resolve(null); /* the desktop's dialog, dismissed */
/* the NAME decides the format, the way playlist_formats::detect_format
does — the extension first, the stem as the draft's title. The path
is only where the bytes were. */
const name = String(args?.name || path.split(/[\\/]/).filter(Boolean).pop() || "Playlist");
const ext = (name.split(".").pop() || "").toLowerCase();
const format = ext === "m3u" || ext === "m3u8" ? "m3u" : ext === "pls" ? "pls" : ext === "xspf" ? "xspf"
: ext === "csv" ? "csv" : ext === "json" ? "soulstream" : "text";
/* the paste door's own three lines, so the verdicts are already
proven: one in the library, one stream, one unresolved */
const entries = impParse("Daft Punk - One More Time\nAphex Twin - Track 1\nID - ID");
return new Promise((r) => setTimeout(() => r(impDraft(entries, name, format, name.replace(/\.[^.]*$/, ""))), 500));
}
if (cmd === "playlist_import_folder") {
const root = String(args?.path || "");
if (!root) return Promise.resolve(null); /* the desktop's dialog, dismissed */
@@ -784,6 +799,13 @@ function mockCatalog(cmd, args) {
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: !fresh });
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" });
/* a document that HAD to be copied, with the two names apart: the cache
file is safeName()'s — every character outside the plugin's allowed
set replaced, so the "#" is an underscore there — and the document's
own is what the draft must be read and titled by. Same on purpose:
if the name stopped travelling, the path's basename would still say
m3u8 and only the title would quietly go wrong, which is the bug. */
if (cmd === "plugin:storage|pick_file") return Promise.resolve({ path: "C:\\mock\\picked\\Warehouse set _3.m3u8", name: "Warehouse set #3.m3u8", size: 2048, copied: true });
/* 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 */
+15 -3
View File
@@ -14,7 +14,7 @@
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
import { catalog, yearOf, artistKey, picture, heroFromPhoto, heroClear } from "./catalog.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem, coverFromPhoto, coverClear, EXPORTS, exportPlaylist } from "./playlists.svelte.js";
import { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.svelte.js";
import { DOORS, start as startImport, pickImportFolder, startFolder, pickImportFile, startFile } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, tracksOf, loadLibrary, refreshPlaylists } from "./lib.svelte.js";
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics, setAutoSync, openTapSync, tapProgress, reduceVocals, backToOriginal } from "./lyrics.svelte.js";
@@ -175,6 +175,18 @@
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
});
}
/* the playlist-file door picks FIRST too: the review only opens for a
file that was chosen, and a document this phone can hand no path for
is refused with the reason rather than half-read */
async function fileDoor() {
closeHalf();
try {
const picked = await pickImportFile();
if (!picked) return;
push("import", {});
void startFile(picked.path, picked.name);
} catch (e) { oops(e); }
}
/* the folder door picks FIRST: the review only opens for a folder that
was chosen, and a tree with no path on this phone cannot be walked */
async function folderDoor() {
@@ -556,10 +568,10 @@
<div class="sheet-title">Import a playlist</div>
<div class="sheet-rows">
{#each DOORS as [door, icon, t, m] (door)}
{@render row(icon, t, m, () => (door === "folder" ? folderDoor() : importPrompt(door)))}
{@render row(icon, t, m, () => (door === "folder" ? folderDoor() : door === "file" ? fileDoor() : importPrompt(door)))}
{/each}
</div>
<div class="sec-note" style="padding-bottom: 14px">A playlist file — M3U, XSPF, PLS or CSV — imports on the desktop; the phone has no file picker for one yet.</div>
<div class="sec-note" style="padding-bottom: 14px">A playlist file's rows are matched against the library on this phone — by path where the file names one, and by filename otherwise, so a file copied here from another machine still finds what you have.</div>
{:else if kind === "plmatch" && shown?.arg?.item}
{@const p = shown.arg.playlist}
{@const item = shown.arg.item}
+2 -2
View File
@@ -51,7 +51,7 @@
import { notice, clearNote } from "./notice.svelte.js";
import { say, signedOut } from "./trouble.js";
import * as pls from "./playlists.svelte.js";
import { importer, bindImporter, startFolder } from "./importer.svelte.js";
import { importer, bindImporter, startFolder, startFile } from "./importer.svelte.js";
import { bindShare } from "./share.svelte.js";
import { downloadsAllowed, cellular } from "./network.svelte.js";
import { contacts, loadContacts, display } from "./contacts.svelte.js";
@@ -305,7 +305,7 @@
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* 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, catalog, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
return () => { if (unlisten) unlisten(); };
});
</script>
+51 -8
View File
@@ -1,8 +1,9 @@
/* Importing a playlist on the phone — the desktop's PlaylistImportModal's
rules, on the doors that need no file picker: a link (YouTube · Spotify ·
Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, a
timestamped setlist, or track links — a paste holding one is read as
links), a YouTube DJ set's posted tracklist, and a folder of audio files
rules, on every door it has: a playlist file off this phone (M3U · M3U8 ·
PLS · XSPF · CSV · JSON), a link (YouTube · Spotify · Apple Music ·
TIDAL · Deezer), a pasted tracklist (plain lines, a timestamped setlist,
or track links — a paste holding one is read as links), a YouTube DJ
set's posted tracklist, and a folder of audio files
on this phone. The backend stages a draft (parsing and resolution against
the library); nothing is written until commit. While the review is up
the stream rows are verified against the catalog — the desktop's
@@ -14,7 +15,7 @@ import { findCatalogTrack } from "../lib/catalogMatch.js";
import { refreshPlaylists } from "./lib.svelte.js";
export const importer = $state({
door: null, // "link" | "text" | "youtube" | "folder"
door: null, // "file" | "link" | "text" | "youtube" | "folder"
source: "", // what was pasted
busy: false,
error: "",
@@ -39,10 +40,10 @@ export const FORMAT_LABEL = {
link path, so a list copied out of Spotify never parses as track names. */
const TRACK_LINK = /(?:spotify:track:[A-Za-z0-9]{22})|(?:\/\/(?:open\.spotify\.com\/(?:[\w-]+\/)*track\/|(?:www\.)?deezer\.com\/(?:[\w-]+\/)*track\/|(?:[\w-]+\.)?tidal\.com\/(?:[\w-]+\/)*track\/|music\.apple\.com\/[^\s]*(?:\/song\/|[?&]i=)))/i;
/** [door, icon, title, hint] — the desktop's Import menu, minus the
* playlist-file door (that one still wants a file picker). A YouTube
* playlist link goes through "link": the backend dispatches it. */
/** [door, icon, title, hint] — the desktop's Import menu, all five doors.
* A YouTube playlist link goes through "link": the backend dispatches it. */
export const DOORS = [
["file", "file-text", "Playlist file…", "M3U · M3U8 · PLS · XSPF · CSV · JSON"],
["link", "link", "Playlist from a link…", "YouTube · Spotify · Apple Music · TIDAL · Deezer"],
["text", "pencil", "Paste a tracklist…", "plain lines, a timestamped setlist, or track links"],
["youtube", "brand-youtube", "YouTube DJ set…", "tracks from chapters, description or comments"],
@@ -72,6 +73,26 @@ export async function start(door, source) {
if (g === gen) { importer.error = String(e); importer.busy = false; }
}
}
/** The extensions the backend's parser knows; anything else is refused here
* rather than read as "pasted text" with a nonsense title. */
const PLAYLIST_EXT = /\.(m3u8?|pls|xspf|csv|json|txt)$/i;
/** The playlist-FILE door picks FIRST, like the folder door — the phone's
* own document picker (the storage plugin's `pick_file`). Every type is
* offered on purpose: Android's providers report m3u and pls under half a
* dozen types and often as plain bytes, so a filter that names one greys
* out the very file being looked for; the extension is checked here
* instead. A document with no path on this phone is copied into the app's
* cache under its own name, up to the size the parser itself refuses.
* Returns { path, name } — or null when the picker was dismissed. */
export async function pickImportFile() {
let picked;
try { picked = await api.pickFile("*/*", 8 * 1024 * 1024); }
catch (e) { if (/cancelled/.test(String(e))) return null; throw e; }
if (!picked?.path) throw new Error("That file isn't on this phone's storage — copy it here first, then import it.");
const name = picked.name || "";
if (!PLAYLIST_EXT.test(name)) throw new Error("“" + (name || "That file") + "” isn't a playlist file — M3U, M3U8, PLS, XSPF, CSV, JSON or TXT.");
return { path: picked.path, name };
}
/** The folder door picks FIRST — the phone's own document-tree picker — so
* the review only opens for a folder that was actually chosen. The tree has
* to be on this phone's storage: the backend walks a path, and a cloud
@@ -83,6 +104,28 @@ export async function pickImportFolder() {
if (!picked?.path) throw new Error("That folder has no path the phone can read — pick one on this phone's storage.");
return picked.path;
}
/** Stage a playlist FILE. The name travels beside the path rather than
* being read off it: the picked document's own name is what chooses the
* parser and titles the draft, and a document the phone had no path for
* arrives as a cache copy. Rows naming a file by a relative path resolve
* against the file's own folder; a copied file's rows fall back to the
* library's basename index, so they still find what this phone holds. A
* playlist file does produce stream rows, so the catalog pass runs. */
export async function startFile(path, name) {
const g = ++gen;
Object.assign(importer, { door: "file", source: path, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false, links: false });
try {
const draft = await api.playlistImportProbe(path, name);
if (g !== gen) return;
importer.busy = false;
if (!draft) { importer.error = "Nothing came back from that file."; return; }
importer.draft = draft;
importer.name = draft.name ?? "";
if (draft.entries?.length) queueMicrotask(() => void verifyStreams(g));
} catch (e) {
if (g === gen) { importer.error = String(e); importer.busy = false; }
}
}
/** Stage a folder of audio files. The backend walks the folder and its disc
* subfolders, orders by the files' own track numbers when every one carries
* a distinct one and by natural filename order otherwise, and matches each
+1
View File
@@ -37,6 +37,7 @@
function leave() { cancel(); back(); }
const waitLine = $derived(importer.door === "link" ? "Reading the link…"
: importer.door === "youtube" ? "Reading the set's tracklist…"
: importer.door === "file" ? "Reading the playlist file…"
: importer.door === "folder" ? "Reading the folder…"
: importer.links ? "Reading the pasted links…"
: "Reading the tracklist…");
+38
View File
@@ -196,6 +196,7 @@ const PROBE = `(function () {
impWaitLine: (document.querySelector('.imp-wait b') || {}).textContent || '',
impWarn: [].map.call(document.querySelectorAll('.imp-warn div'), function (d) { return d.textContent.trim(); }).join('|'),
impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '',
impSrc: (document.querySelector('.imp-src') || {}).textContent || '',
keepOn: !!document.querySelector('.imp-form .sw.on'),
bulkline: (document.querySelector('.bulkline') || {}).textContent || '',
brwDirs: document.querySelectorAll('.brw-dir').length,
@@ -716,6 +717,24 @@ const MOCK_STEPS = [
app comes to the front, and it lands in the import */
{ do: `__ssMock.share = 'Check this out https://music.apple.com/us/playlist/late/pl.u-mock'; document.dispatchEvent(new Event('visibilitychange'))`, name: "share-import", wait: 3600 },
{ do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 },
/* the playlist-FILE door, the fifth: the phone's own document picker,
and the picked document's NAME deciding both the format and the
draft's title — then the two answers a picker can give that are not a
file, armed through the mock's fail door */
{ do: `__click('.newpl', 'Import')`, name: "import-file-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-review", wait: 2200 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-file-created", wait: 1500 },
{ do: `history.back()`, wait: 900 },
{ do: `__ssMock.fail('plugin:storage|pick_file', 'cancelled')`, wait: 200 },
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-cancelled", wait: 1400 },
{ do: `__ssMock.fail('plugin:storage|pick_file', 'that file is not on this phone')`, wait: 200 },
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-refused", wait: 1400 },
/* disarmed and the red dismissed, or every step after this one inherits
both — the toast stays until it is tapped */
{ do: `__ssMock.fail('plugin:storage|pick_file', '')`, wait: 200 },
{ do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, name: "import-file-cleared", wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
{ do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 700 },
@@ -1182,6 +1201,18 @@ const MOCK_EXPECT = {
"import-folder-created": (i) => i.title.startsWith("Music") && i.plrows === 4,
"share-import": (i) => i.title.startsWith("Import") && /Apple Music/.test(i.impFmt) && i.imp === 5,
"share-cancel": (i) => i.title.startsWith("Your library"),
"import-file-sheet": (i) => i.half.includes("up") && /Playlist file/.test(i.halfText),
/* impSrc is the round's own assertion, and the "#" is the whole of it:
the cache copy on disk is "Warehouse set _3.m3u8", so a draft read
off the PATH says that and a draft read off the picked document's
display name says this. Both this and the title below go wrong the
moment the name stops travelling beside the path. */
"import-file-review": (i) => i.title.startsWith("Import") && /M3U/.test(i.impFmt) && i.impSrc === "Warehouse set #3.m3u8"
&& i.imp === 3 && /1 in library/.test(i.impCounts) && /Create playlist \(3 tracks\)/.test(i.impBtn),
"import-file-created": (i) => i.title.startsWith("Warehouse set #3") && i.plrows === 3,
"import-file-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"import-file-refused": (i) => !i.half.includes("up") && /not on this phone/.test(i.toast),
"import-file-cleared": (i) => !i.toast,
"cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
"cat-album-next": (i) => i.playing.includes("Digital Love") && /streaming from/.test(i.live),
"cat-album-disc": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
@@ -1341,6 +1372,11 @@ const PHONE_STEPS = [
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-picker", wait: 2600 },
{ adb: ["shell", "input", "keyevent", "4"], name: "import-folder-cancelled", wait: 2000 },
/* the file door's picker, the same way: opened, dismissed with BACK.
Nothing is read, nothing is copied into the cache, nothing is made */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-picker", wait: 2600 },
{ adb: ["shell", "input", "keyevent", "4"], name: "import-file-dismissed", wait: 2000 },
/* the share sheet: a Spotify playlist link sent by "another app" (an
intent on the plugin's activity) lands in the import — the review is
left without creating anything */
@@ -1492,6 +1528,8 @@ const PHONE_EXPECT = {
"import-folder-real": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp > 0,
"import-folder-picker": (i) => !i.half.includes("up") && !i.toast,
"import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"import-file-picker": (i) => !i.half.includes("up") && !i.toast,
"import-file-dismissed": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"share-cancel": (i) => i.title.startsWith("Your library"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1