android: a playlist, out of the phone

The desktop has had Export since playlists v2 and the phone had no door onto
it for one honest reason: `tauri-plugin-dialog` does open Android's
create-document sheet, but it answers with a `content://` URI and
`playlist_export` wrote with `std::fs::write`, which cannot take one. The
storage plugin grew a `create_document` whose Kotlin writes the bytes through
the ContentResolver — a plugin function called from Rust, not a command, so
the file never passes through the WebView and nothing was added to the ACL —
and `playlist_export` now builds the file above the platform fork with the
same `playlist_formats` writers, so the phone rebuilds no format and the two
exports cannot drift. The door is the playlist's own sheet, on the page and
on the shelf card, with the desktop's five profiles in the desktop's order; a
dismissed sheet says nothing, because nothing happened. The mock walk drives
it three ways — a file written where the sheet pointed, a sheet dismissed, a
refusal read back as a sentence — and the phone walk opens the real sheet and
dismisses it with BACK, writing nothing to the phone. The Kotlin is unbuilt:
nothing in this tree compiles it, though it is written against
tauri-android's own `PluginHandle`, `Invoke` and `JSObject` rather than
against a guess at them.
This commit is contained in:
KeelrLeek committed 2026-09-14 02:38:44 -05:00
1 parent 95c2bfeeba
commit 04c87e0db1
9 files changed
+390 -72

No files matched your search

+34 -12
View File
@@ -52,7 +52,10 @@ What the phone does differently at runtime:
- the audio output stream asks for an 8192-frame buffer (`open_output_stream`); - the audio output stream asks for an 8192-frame buffer (`open_output_stream`);
AAudio's default put it on the fast path with a 10 ms burst, where the mixer's AAudio's default put it on the fast path with a 10 ms burst, where the mixer's
inline decode and file reads underran audibly; inline decode and file reads underran audibly;
- `pick_folder` answers "nothing picked" (desktop-only dialog); `save_file` works; - `pick_folder` answers "nothing picked" (desktop-only dialog); `save_file`
opens Android's create-document sheet, but answers a `content://` URI that
`std::fs` cannot open — which is why the playlist export goes through the
storage plugin's `create_document` instead;
- the window-glass command is a no-op (the ground is the artwork); - the window-glass command is a no-op (the ground is the artwork);
- the aligner and the separator report "not on this platform"; - the aligner and the separator report "not on this platform";
- the desktop's Win32 perf counters read 0 / None in the debug overlay. - the desktop's Win32 perf counters read 0 / None in the debug overlay.
@@ -459,6 +462,25 @@ describe / pin / duplicate / delete, `playlist_add_items`,
verdict, the stream rows are verified against the catalog while it is up verdict, the stream rows are verified against the catalog while it is up
(the desktop's pass, verbatim), and Create swaps the review for the (the desktop's pass, verbatim), and Create swaps the review for the
playlist it made (`nav.replace`). playlist it made (`nav.replace`).
- **Exporting** (`playlist_export`, the playlist's own sheet and the shelf
card's): the desktop's five profiles, in the desktop's order and its own
words — M3U8, XSPF, PLS, CSV and the backup JSON. The file is built in
Rust by `playlist_formats`, ABOVE the platform fork, so the phone never
rebuilds a format and the two exports cannot drift. The doors differ
because the answers do: `tauri-plugin-dialog` does open Android's
create-document sheet, but it answers with a `content://` URI and
`std::fs::write` cannot take one, so on the phone the bytes go to
`tauri-plugin-storage`'s `create_document` and its Kotlin writes them
through the ContentResolver (`openOutputStream(uri, "wt")`), wherever the
sheet pointed. That is a plugin function called from Rust, not a command:
the file never passes through the WebView, and nothing was added to the
ACL. A dismissed sheet resolves nothing and the phone says nothing —
which is the desktop's `Ok(None)`. A row with no local file exports
exactly as it does on the desktop: named, with a comment where the
location would be (M3U8), carried without a location (XSPF, CSV, the
backup), or left out (PLS, which holds only real files). The locations
that ARE written are this phone's own paths, inside the app's folder —
the format behaving correctly, not a bug to rewrite.
- **The share sheet** (`crates/tauri-plugin-share`, `share.svelte.js`): - **The share sheet** (`crates/tauri-plugin-share`, `share.svelte.js`):
Share → SoulStream from any app. The plugin's `ShareActivity` (an Share → SoulStream from any app. The plugin's `ShareActivity` (an
ACTION_SEND text/plain target, merged into the app's manifest from the ACTION_SEND text/plain target, merged into the app's manifest from the
@@ -469,9 +491,9 @@ describe / pin / duplicate / delete, `playlist_add_items`,
anything else the pasted-tracklist door. The phone walk sends the intent anything else the pasted-tracklist door. The phone walk sends the intent
itself (`adb shell am start -a android.intent.action.SEND …`). itself (`adb shell am start -a android.intent.action.SEND …`).
Not on the phone yet: the playlist-file door and export — both want a file Not on the phone yet: the playlist-file door — it wants a file picker the
picker the storage plugin has still to grow — the listener door (it needs storage plugin has still to grow — the listener door (it needs yt-dlp, a
yt-dlp, a desktop tool), drag reorder, and Share on SoulStream. desktop tool), drag reorder, and Share on SoulStream.
## The first run ## The first run
@@ -1111,7 +1133,10 @@ all at 915×411) and back → and last, on a reload of its own, a phone that
has just been installed (the first-run sheet and its two cards, its has just been installed (the first-run sheet and its two cards, its
Soulseek door, the notes on Home, on both Library tabs and on Search, the Soulseek door, the notes on Home, on both Library tabs and on Search, the
preview from You, a folder picked from the tour and the note losing the row preview from You, a folder picked from the tour and the note losing the row
it answered), then back to the provisioned device, asserts what each step should show — the Home it answered), then back to the provisioned device, and after that the
playlist export door three ways (a file written where the sheet pointed, a
dismissed sheet that says nothing at all, and a refusal read back as a
sentence), asserts what each step should show — the Home
step holds the launch to a budget as well, reading off `__ss.boot` how many step holds the launch to a budget as well, reading off `__ss.boot` how many
commands the app issued before its first frame, how many of those left in the commands the app issued before its first frame, how many of those left in the
shell's own turn, how long it took, and requiring round 12's art prewarm to shell's own turn, how long it took, and requiring round 12's art prewarm to
@@ -1134,7 +1159,7 @@ list, a null page), `offline(on)` takes the client down — everything the
backend funnels through `with_client()` answers "not connected to Soulseek", backend funnels through `with_client()` answers "not connected to Soulseek",
the two connects answer the server string, and `soulseek:state` goes out — and the two connects answer the server string, and `soulseek:state` goes out — and
`clear()` puts all of it back. They reject with a plain string because that is `clear()` puts all of it back. They reject with a plain string because that is
what Tauri rejects with. Nine mock steps drive them, and the phone walk carries what Tauri rejects with. Twelve mock steps drive them, and the phone walk carries
one safe step of its own (`trouble-words`: eleven real backend strings through one safe step of its own (`trouble-words`: eleven real backend strings through
`__ss.say` on the device, every output asserted free of Rust). A mock step can `__ss.say` on the device, every output asserted free of Rust). A mock step can
also set the also set the
@@ -1203,12 +1228,9 @@ symptom.
- **Off by design — external runtimes.** yt-dlp and deno, the vocal - **Off by design — external runtimes.** yt-dlp and deno, the vocal
separator, the lyric alignment models, and the listener door that needs separator, the lyric alignment models, and the listener door that needs
them. them.
- **Waiting on Rust, not on a picker.** Playlist EXPORT: `tauri-plugin-dialog` - **Waiting on a picker.** The storage plugin can WRITE a document now
does open Android's create-document sheet from Rust, but it answers with a (`create_document`, which the playlist export goes through) but it cannot
`content://` URI and `playlist_export` writes with `std::fs::write`, which read one. Playlist-FILE import wants a `pick_file` in the storage plugin,
cannot take one. It needs a ContentResolver write, or the bytes handed to
an outbound share intent — and `crates/tauri-plugin-share` is inbound-only
today. Playlist-FILE import wants a `pick_file` in the storage plugin,
beside the `pick_folder` and `pick_image` it already has. beside the `pick_folder` and `pick_image` it already has.
- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top - **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top
stand in), catalog track previews, the full discography page, and the stand in), catalog track previews, the full discography page, and the
@@ -6,15 +6,29 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.provider.Settings import android.provider.Settings
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import app.tauri.annotation.ActivityCallback import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.Command import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin import app.tauri.plugin.Plugin
/** What `createDocument` writes and where it offers to put it — the Rust
* `CreateDocumentArgs`, camelCase. */
@InvokeArg
class CreateDocumentArgs {
/** the name the sheet opens with, its extension included */
var name: String? = null
/** the type Android files it under, and picks the sheet's apps by */
var mime: String? = null
/** the whole file, built in Rust — the WebView never sees it */
var text: String? = null
}
/** /**
* The phone's storage for the library: a folder picked through Android's * 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 * document tree picker (its content URI kept with a persistable grant, and
@@ -91,6 +105,72 @@ class StoragePlugin(private val activity: Activity) : Plugin(activity) {
} }
invoke.resolve() invoke.resolve()
} }
/** A file written wherever the user points it: Android's create-document
* sheet, and the bytes through the ContentResolver — a content URI is
* not a path, so `std::fs` on the Rust side could never have written
* this one. The file arrives whole (the playlist export builds it with
* the desktop's own writers), so nothing here knows a format. */
@Command
fun createDocument(invoke: Invoke) {
val args = invoke.parseArgs(CreateDocumentArgs::class.java)
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = args.mime ?: "application/octet-stream"
intent.putExtra(Intent.EXTRA_TITLE, args.name ?: "playlist")
startActivityForResult(invoke, intent, "documentCreated")
}
@ActivityCallback
fun documentCreated(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 }
try {
/* The file comes off the SAME invoke, read a second time
rather than parked in a field of ours: PluginHandle's
activity-result closure holds the Invoke, so its
arguments are still here when the sheet comes back, and
no call of this can hand its bytes to another's file. */
val text = invoke.parseArgs(CreateDocumentArgs::class.java).text ?: ""
/* "wt" truncates: without it, writing a short file over a
longer one leaves the old tail behind the new bytes. */
val out = activity.contentResolver.openOutputStream(uri, "wt")
if (out == null) { invoke.reject("the file could not be opened for writing"); return }
out.use { o -> o.write(text.toByteArray(Charsets.UTF_8)) }
val res = JSObject()
res.put("uri", uri.toString())
res.put("name", displayName(uri))
invoke.resolve(res)
} catch (e: Exception) {
invoke.reject("the file could not be written: " + e.message)
}
}
/* Deliberately NOT a rejection, where pickFolder and pickImage
both reject "cancelled": a dismissed save is `Ok(None)` on the
desktop and the export says nothing at all for it. An error
here would paint a failure over a choice. */
Activity.RESULT_CANCELED -> invoke.resolve(JSObject())
else -> invoke.reject("the file could not be created")
}
}
/** The name the document actually got — the sheet lets it be changed on
* the way past. The URI's last segment when the provider will not say. */
private fun displayName(uri: Uri): String? {
try {
activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (i >= 0 && c.moveToFirst()) {
val n = c.getString(i)
if (!n.isNullOrEmpty()) return n
}
}
} catch (_: Exception) { /* the name is a courtesy; the bytes are written */ }
return uri.lastPathSegment
}
/** A photo from the phone: the document picker limited to images, the /** A photo from the phone: the document picker limited to images, the
* picked content copied into the app's cache so the Rust side gets a * picked content copied into the app's cache so the Rust side gets a
* plain path (a content URI is not one). */ * plain path (a content URI is not one). */
+60
View File
@@ -118,6 +118,66 @@ async fn pick_image<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedImage,
} }
} }
/// What Android's create-document sheet answered: where the file went, and
/// the name it actually got (the sheet lets the user change it on the way
/// past). Both absent when the sheet was dismissed — a cancel is not an
/// error here, because the export it serves answers a cancel with None.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct CreatedDocument {
pub uri: Option<String>,
pub name: Option<String>,
}
/// What `createDocument` is handed, as the Kotlin `@InvokeArg` names it.
/// Borrowed: the file can be long and nothing here keeps a copy.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] // only the phone ever sends one; the desktop has a dialog
struct CreateDocumentArgs<'a> {
name: &'a str,
mime: &'a str,
text: &'a str,
}
/// A text file written wherever the user points: Android's create-document
/// sheet (ACTION_CREATE_DOCUMENT), and the Kotlin side writing the bytes
/// through the ContentResolver. That last part is the whole reason this
/// exists — the sheet answers with a `content://` URI, and `std::fs` has
/// never been able to open one, which is why the playlist export had no
/// door on the phone at all.
///
/// Deliberately NOT a `#[tauri::command]`: the caller is Rust, so the file
/// is built where the desktop builds it and the bytes never pass through
/// the WebView. Nothing is added to the ACL, and there is no JS wrapper to
/// keep in step with.
///
/// A dismissed sheet is not an error — it answers with both fields empty.
pub fn create_document<R: Runtime>(
app: &tauri::AppHandle<R>,
name: &str,
mime: &str,
text: &str,
) -> Result<CreatedDocument, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<CreatedDocument>(
"createDocument",
CreateDocumentArgs { name, mime, text },
)
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, name, mime, text);
Err("the create-document sheet is the phone's; here a save dialog picks the path".into())
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> { pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("storage") 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])
+98 -57
View File
@@ -1605,8 +1605,18 @@ pub async fn library_track_search(
// ---- export ---------------------------------------------------------------- // ---- export ----------------------------------------------------------------
/// Export a playlist through a save dialog. `format` ∈ m3u8 | xspf | csv | /// Export a playlist to a file the user picks. `format` ∈ m3u8 | xspf |
/// json. Returns the written path, or None when the dialog was cancelled. /// pls | csv | json. Returns where it went — the path on the desktop, the
/// document's name on the phone — or None when the dialog was cancelled.
///
/// The file is built ABOVE the fork, by `playlist_formats`, so both doors
/// write the same bytes and neither platform owns a format. The doors
/// differ because the answers do: the desktop's save dialog hands back a
/// path and `std::fs` writes it; Android's create-document sheet hands back
/// a `content://` URI, which no path can open, so the bytes go to the
/// storage plugin and its ContentResolver writes them where the user
/// pointed. A row with no local file is the writers' business, not this
/// one's — each has its own answer, and both platforms get it.
#[tauri::command] #[tauri::command]
pub async fn playlist_export( pub async fn playlist_export(
app: tauri::AppHandle, app: tauri::AppHandle,
@@ -1614,7 +1624,6 @@ pub async fn playlist_export(
id: i64, id: i64,
format: String, format: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let l = lib.library.clone(); let l = lib.library.clone();
let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id)) let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id))
.await .await
@@ -1627,12 +1636,15 @@ pub async fn playlist_export(
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let (ext, filter_name): (&str, &str) = match format.as_str() { // The MIME is the phone's half of the pair: Android's Documents UI files
"m3u8" => ("m3u8", "M3U8 playlist"), // the document by it, and staples an extension of its own on anything it
"xspf" => ("xspf", "XSPF playlist"), // does not recognise. All five are in its bundled table.
"pls" => ("pls", "PLS playlist"), let (ext, filter_name, mime): (&str, &str, &str) = match format.as_str() {
"csv" => ("csv", "CSV"), "m3u8" => ("m3u8", "M3U8 playlist", "audio/x-mpegurl"),
"json" => ("json", "SoulStream playlist JSON"), "xspf" => ("xspf", "XSPF playlist", "application/xspf+xml"),
"pls" => ("pls", "PLS playlist", "audio/x-scpls"),
"csv" => ("csv", "CSV", "text/csv"),
"json" => ("json", "SoulStream playlist JSON", "application/json"),
other => return Err(format!("Unknown export format {other}")), other => return Err(format!("Unknown export format {other}")),
}; };
let safe_name: String = meta let safe_name: String = meta
@@ -1644,56 +1656,85 @@ pub async fn playlist_export(
c => c, c => c,
}) })
.collect(); .collect();
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
// save_file exists on mobile too (the system's create-document sheet) // Its own block: `export_items` borrows `items` and `meta`, and the
app.dialog() // desktop branch below awaits a dialog — nothing borrowed may be held
.file() // across that await. The block ends the borrows with the file in hand.
.set_file_name(format!("{safe_name}.{ext}")) let body = {
.add_filter(filter_name, &[ext]) let export_items: Vec<ExportItem<'_>> = items
.save_file(move |path| { .iter()
let _ = tx.send(path.map(|p| p.to_string())); .map(|i| ExportItem {
}); title: &i.title,
let Some(dest) = rx.await.unwrap_or(None) else { artist: &i.artist,
return Ok(None); album: i.album.as_deref(),
duration_secs: i.duration_secs,
path: i.path.as_deref(),
start_ms: i.start_ms,
source_line: i.source_line.as_deref(),
kind: &i.kind,
note: i.note.as_deref(),
})
.collect();
match format.as_str() {
"m3u8" => playlist_formats::write_m3u8(&export_items),
"xspf" => {
playlist_formats::write_xspf(&meta.name, meta.description.as_deref(), &export_items)
}
"pls" => playlist_formats::write_pls(&export_items),
"csv" => playlist_formats::write_csv(&meta.name, &export_items),
_ => playlist_formats::write_soulstream_json(
&meta.name,
meta.description.as_deref(),
&meta.kind,
&export_items,
),
}
}; };
let export_items: Vec<ExportItem<'_>> = items #[cfg(mobile)]
.iter() {
.map(|i| ExportItem { // No filter and no temp file here: the sheet does the choosing, and
title: &i.title, // the ContentResolver's "wt" truncates in place, so there is no torn
artist: &i.artist, // file to move out of the way of.
album: i.album.as_deref(), let _ = filter_name;
duration_secs: i.duration_secs, let doc = tauri_plugin_storage::create_document(
path: i.path.as_deref(), &app,
start_ms: i.start_ms, &format!("{safe_name}.{ext}"),
source_line: i.source_line.as_deref(), mime,
kind: &i.kind, &body,
note: i.note.as_deref(), )?;
// Nothing picked is both fields empty — a dismissed sheet, which is
// this command's None on either platform. The name is what a person
// can find again; the URI is the fallback when the provider is quiet.
Ok(doc.name.or(doc.uri))
}
#[cfg(desktop)]
{
let _ = mime;
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
app.dialog()
.file()
.set_file_name(format!("{safe_name}.{ext}"))
.add_filter(filter_name, &[ext])
.save_file(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let Some(dest) = rx.await.unwrap_or(None) else {
return Ok(None);
};
// Write beside the destination, then move into place — a failed write
// never leaves a torn file under the chosen name. rename on Windows
// replaces an existing destination, so there is no unlink window.
let dest_path = PathBuf::from(&dest);
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let temp = dest_path.with_extension(format!("{ext}.writing"));
std::fs::write(&temp, body.as_bytes()).map_err(|e| e.to_string())?;
std::fs::rename(&temp, &dest_path).map_err(|e| e.to_string())
}) })
.collect(); .await
let body = match format.as_str() { .map_err(|e| e.to_string())??;
"m3u8" => playlist_formats::write_m3u8(&export_items), Ok(Some(dest))
"xspf" => playlist_formats::write_xspf(&meta.name, meta.description.as_deref(), &export_items), }
"pls" => playlist_formats::write_pls(&export_items),
"csv" => playlist_formats::write_csv(&meta.name, &export_items),
_ => playlist_formats::write_soulstream_json(
&meta.name,
meta.description.as_deref(),
&meta.kind,
&export_items,
),
};
// Write beside the destination, then move into place — a failed write
// never leaves a torn file under the chosen name. rename on Windows
// replaces an existing destination, so there is no unlink window.
let dest_path = PathBuf::from(&dest);
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let temp = dest_path.with_extension(format!("{ext}.writing"));
std::fs::write(&temp, body.as_bytes()).map_err(|e| e.to_string())?;
std::fs::rename(&temp, &dest_path).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
Ok(Some(dest))
} }
+5 -2
View File
@@ -315,8 +315,11 @@ export const playlistImportCommit = (name, description, kind, entries, coverUrl
entries, entries,
coverUrl, coverUrl,
}); });
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to // Export through whichever save door the platform has; format ∈ m3u8 |
// the written path, or null when the dialog was cancelled. // xspf | pls | csv | json. Resolves to where the file went — the written
// path on the desktop, the document's name on the phone (Android's
// create-document sheet answers a content:// URI, not a path) — or null
// when that sheet was dismissed.
export const playlistExport = (id, format) => invoke("playlist_export", { id, format }); export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
// ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ---------- // ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ----------
+16
View File
@@ -136,6 +136,22 @@ function mockLyrics(title) {
} }
function mockPlaylists(cmd, args) { function mockPlaylists(cmd, args) {
if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p }))); if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p })));
/* Export. The whole file is built in Rust and written by whichever door
the platform has — the desktop's save dialog, the phone's
create-document sheet — so all the command ever answers is where it
went: a path there, the document's name here. The sanitiser is the
Rust one, character for character (and written as a code-point test
rather than a range, so nothing in this file is a control byte). A
dismissed sheet (null) and a refusal are armed from the walk with
`__ssMock.answer` / `.fail`, which `weather()` answers first. */
if (cmd === "playlist_export") {
const p = plOf(args);
if (!p) return Promise.reject(new Error("Playlist not found"));
const fmt = String(args?.format ?? "");
if (!["m3u8", "xspf", "pls", "csv", "json"].includes(fmt)) return Promise.reject(new Error("Unknown export format " + fmt));
const safe = [...String(p.name)].map((c) => (/[<>:"\/\\|?*]/.test(c) || c.codePointAt(0) < 0x20 ? "_" : c)).join("");
return new Promise((r) => setTimeout(() => r(safe + "." + fmt), 120));
}
if (cmd === "playlist_items") return new Promise((r) => setTimeout(() => r((PL_ITEMS[Number(args?.id)] || []).map((x) => ({ ...x }))), 80)); if (cmd === "playlist_items") return new Promise((r) => setTimeout(() => r((PL_ITEMS[Number(args?.id)] || []).map((x) => ({ ...x }))), 80));
if (cmd === "playlist_cover") { const p = plOf(args); return Promise.resolve(p && p.cover_path ? svgArt(p.id === 1 ? 330 : 280, p.id === 1 ? "L" : "M") : null); } if (cmd === "playlist_cover") { const p = plOf(args); return Promise.resolve(p && p.cover_path ? svgArt(p.id === 1 ? 330 : 280, p.id === 1 ? "L" : "M") : null); }
if (cmd === "playlist_cover_set") { const p = plOf(args); if (!p) return Promise.reject(new Error("no such playlist")); p.cover_path = String(args?.path || "mock://photo"); plTouch(p); return Promise.resolve(svgArt(300, "P")); } if (cmd === "playlist_cover_set") { const p = plOf(args); if (!p) return Promise.reject(new Error("no such playlist")); p.cover_path = String(args?.path || "mock://photo"); plTouch(p); return Promise.resolve(svgArt(300, "P")); }
+12 -1
View File
@@ -13,7 +13,7 @@
import { player, playNext, enqueue, playEntries, playQueueAt, removeFromQueue, moveToNext, moveInQueue, setSleep, sleepLabel } from "./player.svelte.js"; import { player, playNext, enqueue, playEntries, playQueueAt, removeFromQueue, moveToNext, moveInQueue, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js"; import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
import { catalog, yearOf, artistKey, picture, heroFromPhoto, heroClear } from "./catalog.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 } from "./playlists.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 } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.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 { lib, albumById, tracksOf, loadLibrary, refreshPlaylists } from "./lib.svelte.js";
@@ -475,6 +475,8 @@
{@render row("download", "Download all tracks", bulkable(rows) + " not in the library yet, by your download rules", act(() => { void runBulk(p, rows, "download"); }))} {@render row("download", "Download all tracks", bulkable(rows) + " not in the library yet, by your download rules", act(() => { void runBulk(p, rows, "download"); }))}
{/if} {/if}
<div class="sheet-hair"></div> <div class="sheet-hair"></div>
{@render row("export", "Export…", p.track_count + " track" + (p.track_count === 1 ? "" : "s") + ", as a playlist file", () => openHalf("plexport", { playlist: p }))}
<div class="sheet-hair"></div>
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))} {@render row("trash", "Delete playlist", "", () => confirmDelete(p))}
</div> </div>
{:else if kind === "pltrack" && shown?.arg?.item} {:else if kind === "pltrack" && shown?.arg?.item}
@@ -812,6 +814,15 @@
<div class="setup-foot"><button class="btn btn-fill wide" onclick={() => void finishSetup()}>Start listening</button></div> <div class="setup-foot"><button class="btn btn-fill wide" onclick={() => void finishSetup()}>Start listening</button></div>
<div class="sec-note" style="padding-bottom:16px">Nothing above is required. You → Set up this phone again opens it back up.</div> <div class="sec-note" style="padding-bottom:16px">Nothing above is required. You → Set up this phone again opens it back up.</div>
</div> </div>
{:else if kind === "plexport" && shown?.arg?.playlist}
{@const p = shown.arg.playlist}
<div class="sheet-title">Export <span class="t-sub">· {p.name}</span></div>
<div class="sheet-rows">
{#each EXPORTS as [format, label, m] (format)}
{@render row("export", label, m, act(() => exportPlaylist(p, format).catch(oops)))}
{/each}
</div>
<div class="sec-note" style="padding-bottom:16px">The file goes wherever the sheet points it — Downloads, Drive, anywhere the phone offers. A row with no file of its own keeps its name: M3U8 leaves a comment where the location would be, XSPF, CSV and the backup carry the row without one, and PLS holds only real files — and the locations that are there are paths on this phone.</div>
{:else if kind === "sleep"} {:else if kind === "sleep"}
<div class="sheet-title">Sleep timer</div> <div class="sheet-title">Sleep timer</div>
<div class="sheet-sec">Pause after</div> <div class="sheet-sec">Pause after</div>
+24
View File
@@ -14,6 +14,7 @@ import { resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, n
import { slsk } from "./slsk.svelte.js"; import { slsk } from "./slsk.svelte.js";
import { downloadsAllowed } from "./network.svelte.js"; import { downloadsAllowed } from "./network.svelte.js";
import { note } from "./notice.svelte.js"; import { note } from "./notice.svelte.js";
import { basename } from "./util.js";
export const pls = $state({ export const pls = $state({
/** "id:revision" → data URL, or null once the backend said there is none */ /** "id:revision" → data URL, or null once the backend said there is none */
@@ -201,6 +202,29 @@ export async function coverClear(p) {
note(`Back to the mosaic for ${p.name}`); note(`Back to the mosaic for ${p.name}`);
} }
/* ── export: the desktop's five profiles, in the desktop's order and its
own words. The file itself is built in Rust by the writers the desktop
uses (playlist_formats), so nothing here knows what an M3U8 looks like —
two exports that drift are worse than one that is missing. What comes
back is only where it went: a path on the desktop, the document's name
on the phone, and null when the sheet was dismissed. ── */
export const EXPORTS = [
["m3u8", "M3U8", "one line a track — most players read it"],
["xspf", "XSPF", "an open format, titles and times carried"],
["pls", "PLS", "older players want this one"],
["csv", "CSV", "a row a track, for a spreadsheet"],
["json", "Backup (JSON)", "everything about it, this app's own"],
];
/** Export `p` as `format` — the system's own sheet says where it goes.
* False, and nothing said, when that sheet was dismissed: nothing
* happened, so there is nothing to report. */
export async function exportPlaylist(p, format) {
const where = await api.playlistExport(p.id, format);
if (!where) return false;
note(`Exported as ${basename(where)}`);
return true;
}
/* ── reorder: a row up, down or to the top; the complete id order goes /* ── reorder: a row up, down or to the top; the complete id order goes
in one call, as the desktop's drop does ── */ in one call, as the desktop's drop does ── */
export async function moveItem(p, items, item, where) { export async function moveItem(p, items, item, where) {
+61
View File
@@ -897,6 +897,35 @@ const MOCK_STEPS = [
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-fixed", wait: 1100 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-fixed", wait: 1100 },
{ do: `localStorage.removeItem('mockFresh'); location.hash = ''; true`, wait: 400 }, { do: `localStorage.removeItem('mockFresh'); location.hash = ''; true`, wait: 400 },
{ do: `location.reload()`, name: "fresh-off", wait: 4500 }, { do: `location.reload()`, name: "fresh-off", wait: 4500 },
/* ── the playlist export, three ways ──────────────────────────────
On the provisioned device the block above deliberately put back, and
on the pristine fixture its reload restored. The file is built in
Rust by the desktop's own writers and written by whichever door the
platform has, so the only thing that comes back — and the only thing
the phone can show — is where it went. */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
{ do: `__click('.plgrid .release', 'Late night drive')`, name: "export-open", wait: 1200 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Export')`, name: "export-sheet", wait: 800 },
{ do: `__click('.halfsheet .row', 'CSV')`, name: "export-written", wait: 1000 },
/* a sheet dismissed: the command answers null and the phone says
nothing, because nothing happened. The notice from the step above is
cleared by hand rather than waited out — note() lives 3.2 s, longer
than any wait here, and a wait that had to outlast it would be the
fragile kind. */
{ do: `__ssMock.answer('playlist_export', null); __ss.notice.text = ''; true`, wait: 300 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Export')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'M3U8')`, name: "export-cancelled", wait: 1000 },
/* and a refusal — what Android answers when the document is somewhere
the app cannot write — read back as a sentence rather than as Rust */
{ do: `__ssMock.forget('playlist_export'); __ssMock.fail('playlist_export', 'permission denied (os error 13)'); true`, wait: 300 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Export')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Backup (JSON)')`, name: "export-refused", wait: 1200 },
{ do: `__ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; history.back(); true`, wait: 900 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "export-clear", wait: 900 },
]; ];
const MOCK_EXPECT = { const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"), tracks: (i) => i.title.startsWith("Your library"),
@@ -1190,6 +1219,18 @@ const MOCK_EXPECT = {
&& i.setupNoteText === "Sign in on Soulseek" && i.setupWrites === 1, && i.setupNoteText === "Sign in on Soulseek" && i.setupWrites === 1,
/* the provisioned device is back, and it is NOT offered a first run */ /* the provisioned device is back, and it is NOT offered a first run */
"fresh-off": (i) => !i.setupUp && !i.setupNotes && i.setupState === "chosen:in:done" && !i.toast, "fresh-off": (i) => !i.setupUp && !i.setupNotes && i.setupState === "chosen:in:done" && !i.toast,
/* ── the playlist export ──────────────────────────────────────────
Where the file went is the only thing the command answers, so the
notice naming it IS the feature; a dismissed sheet has to leave the
screen exactly as quiet as it found it; and a refusal has to arrive
as a sentence, because "permission denied (os error 13)" is the one
string Android hands back when the document is out of reach. */
"export-open": (i) => i.title.startsWith("Late night drive") && i.plrows >= 1 && !i.toast,
"export-sheet": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText),
"export-written": (i) => !i.half.includes("up") && /Exported as .+\.csv/.test(i.notice) && !i.toast,
"export-cancelled": (i) => !i.half.includes("up") && !i.notice && !i.toast,
"export-refused": (i) => /refused access/.test(i.toast) && !/os error|permission denied/i.test(i.toast),
"export-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"),
}; };
/* the phone walk: a record by name, played for real */ /* the phone walk: a record by name, played for real */
@@ -1255,6 +1296,16 @@ const PHONE_STEPS = [
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 }, { do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
{ do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 }, { do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 },
{ do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 }, { do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 },
/* export, on the real core: the file is built by the desktop's own
writers and handed to Android's create-document sheet. That sheet is
a system activity the walk cannot tap, so BACK dismisses it — the
cancel path is what gets walked, and NOTHING is written to this
phone's storage. The playlist is the "Walk test" one the walk itself
makes, and deletes three steps below. */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
{ do: `__click('.halfsheet .row', 'Export')`, name: "export-door", wait: 800 },
{ do: `__click('.halfsheet .row', 'M3U8')`, name: "export-picker", wait: 2600 },
{ adb: ["shell", "input", "keyevent", "4"], name: "export-dismissed", wait: 2000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 800 }, { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 }, { do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 },
@@ -1459,6 +1510,16 @@ const PHONE_EXPECT = {
"setup-preview": (i) => i.setupUp && i.setupWrites === 0 "setup-preview": (i) => i.setupUp && i.setupWrites === 0
&& i.setupCards === "Where your music lives|Soulseek" && !i.a11yAnon && !i.a11yGhost, && i.setupCards === "Where your music lives|Soulseek" && !i.a11yAnon && !i.a11yGhost,
"setup-closed": (i) => !i.setupUp && i.setupWrites === 0 && !i.toast, "setup-closed": (i) => !i.setupUp && i.setupWrites === 0 && !i.toast,
/* the export door on the real core. The sheet the second step opens is
Android's own, so nothing here asserts that it is UP: the core can
build a ten-row M3U8 and hand off before the probe lands, and a step
that demanded the picker would be the one that failed for being too
fast. What is asserted is the app's own half sheet closing, nothing
going red either side of the system sheet, and — after BACK — no
notice, because a dismissed sheet wrote nothing to name. */
"export-door": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText),
"export-picker": (i) => !i.half.includes("up") && !i.toast,
"export-dismissed": (i) => i.title.startsWith("Walk test") && !i.half.includes("up") && !i.toast && !i.notice,
end: (i) => /^(Good |Still)/.test(i.title), end: (i) => /^(Good |Still)/.test(i.title),
}; };