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:
1 parent
95c2bfeeba
commit
04c87e0db1
9 files changed
+356
-38
No files matched your search
+34
-12
@@ -52,7 +52,10 @@ What the phone does differently at runtime:
|
||||
- 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
|
||||
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 aligner and the separator report "not on this platform";
|
||||
- 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
|
||||
(the desktop's pass, verbatim), and Create swaps the review for the
|
||||
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`):
|
||||
Share → SoulStream from any app. The plugin's `ShareActivity` (an
|
||||
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
|
||||
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
|
||||
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 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.
|
||||
|
||||
## 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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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",
|
||||
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
|
||||
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
|
||||
`__ss.say` on the device, every output asserted free of Rust). A mock step can
|
||||
also set the
|
||||
@@ -1203,12 +1228,9 @@ 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 Rust, not on a picker.** Playlist EXPORT: `tauri-plugin-dialog`
|
||||
does open Android's create-document sheet from Rust, but it answers with a
|
||||
`content://` URI and `playlist_export` writes with `std::fs::write`, which
|
||||
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,
|
||||
- **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
|
||||
|
||||
+80
@@ -6,15 +6,29 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.ActivityResult
|
||||
import app.tauri.annotation.ActivityCallback
|
||||
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 `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
|
||||
* 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()
|
||||
}
|
||||
|
||||
/** 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
|
||||
* picked content copied into the app's cache so the Rust side gets a
|
||||
* plain path (a content URI is not one). */
|
||||
|
||||
@@ -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> {
|
||||
Builder::new("storage")
|
||||
.invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image])
|
||||
|
||||
+64
-23
@@ -1605,8 +1605,18 @@ pub async fn library_track_search(
|
||||
|
||||
// ---- export ----------------------------------------------------------------
|
||||
|
||||
/// Export a playlist through a save dialog. `format` ∈ m3u8 | xspf | csv |
|
||||
/// json. Returns the written path, or None when the dialog was cancelled.
|
||||
/// Export a playlist to a file the user picks. `format` ∈ m3u8 | xspf |
|
||||
/// 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]
|
||||
pub async fn playlist_export(
|
||||
app: tauri::AppHandle,
|
||||
@@ -1614,7 +1624,6 @@ pub async fn playlist_export(
|
||||
id: i64,
|
||||
format: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let l = lib.library.clone();
|
||||
let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id))
|
||||
.await
|
||||
@@ -1627,12 +1636,15 @@ pub async fn playlist_export(
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (ext, filter_name): (&str, &str) = match format.as_str() {
|
||||
"m3u8" => ("m3u8", "M3U8 playlist"),
|
||||
"xspf" => ("xspf", "XSPF playlist"),
|
||||
"pls" => ("pls", "PLS playlist"),
|
||||
"csv" => ("csv", "CSV"),
|
||||
"json" => ("json", "SoulStream playlist JSON"),
|
||||
// The MIME is the phone's half of the pair: Android's Documents UI files
|
||||
// the document by it, and staples an extension of its own on anything it
|
||||
// does not recognise. All five are in its bundled table.
|
||||
let (ext, filter_name, mime): (&str, &str, &str) = match format.as_str() {
|
||||
"m3u8" => ("m3u8", "M3U8 playlist", "audio/x-mpegurl"),
|
||||
"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}")),
|
||||
};
|
||||
let safe_name: String = meta
|
||||
@@ -1644,19 +1656,11 @@ pub async fn playlist_export(
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
|
||||
// save_file exists on mobile too (the system's create-document sheet)
|
||||
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);
|
||||
};
|
||||
|
||||
// Its own block: `export_items` borrows `items` and `meta`, and the
|
||||
// desktop branch below awaits a dialog — nothing borrowed may be held
|
||||
// across that await. The block ends the borrows with the file in hand.
|
||||
let body = {
|
||||
let export_items: Vec<ExportItem<'_>> = items
|
||||
.iter()
|
||||
.map(|i| ExportItem {
|
||||
@@ -1671,9 +1675,11 @@ pub async fn playlist_export(
|
||||
note: i.note.as_deref(),
|
||||
})
|
||||
.collect();
|
||||
let body = match format.as_str() {
|
||||
match format.as_str() {
|
||||
"m3u8" => playlist_formats::write_m3u8(&export_items),
|
||||
"xspf" => playlist_formats::write_xspf(&meta.name, meta.description.as_deref(), &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(
|
||||
@@ -1682,6 +1688,40 @@ pub async fn playlist_export(
|
||||
&meta.kind,
|
||||
&export_items,
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
// No filter and no temp file here: the sheet does the choosing, and
|
||||
// the ContentResolver's "wt" truncates in place, so there is no torn
|
||||
// file to move out of the way of.
|
||||
let _ = filter_name;
|
||||
let doc = tauri_plugin_storage::create_document(
|
||||
&app,
|
||||
&format!("{safe_name}.{ext}"),
|
||||
mime,
|
||||
&body,
|
||||
)?;
|
||||
// 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
|
||||
@@ -1695,5 +1735,6 @@ pub async fn playlist_export(
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
Ok(Some(dest))
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -315,8 +315,11 @@ export const playlistImportCommit = (name, description, kind, entries, coverUrl
|
||||
entries,
|
||||
coverUrl,
|
||||
});
|
||||
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
|
||||
// the written path, or null when the dialog was cancelled.
|
||||
// Export through whichever save door the platform has; format ∈ m3u8 |
|
||||
// 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 });
|
||||
|
||||
// ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ----------
|
||||
|
||||
@@ -136,6 +136,22 @@ function mockLyrics(title) {
|
||||
}
|
||||
function mockPlaylists(cmd, args) {
|
||||
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_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")); }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
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 { 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 { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.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"); }))}
|
||||
{/if}
|
||||
<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))}
|
||||
</div>
|
||||
{: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="sec-note" style="padding-bottom:16px">Nothing above is required. You → Set up this phone again opens it back up.</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"}
|
||||
<div class="sheet-title">Sleep timer</div>
|
||||
<div class="sheet-sec">Pause after</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, n
|
||||
import { slsk } from "./slsk.svelte.js";
|
||||
import { downloadsAllowed } from "./network.svelte.js";
|
||||
import { note } from "./notice.svelte.js";
|
||||
import { basename } from "./util.js";
|
||||
|
||||
export const pls = $state({
|
||||
/** "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}`);
|
||||
}
|
||||
|
||||
/* ── 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
|
||||
in one call, as the desktop's drop does ── */
|
||||
export async function moveItem(p, items, item, where) {
|
||||
|
||||
@@ -897,6 +897,35 @@ const MOCK_STEPS = [
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-fixed", wait: 1100 },
|
||||
{ do: `localStorage.removeItem('mockFresh'); location.hash = ''; true`, wait: 400 },
|
||||
{ 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 = {
|
||||
tracks: (i) => i.title.startsWith("Your library"),
|
||||
@@ -1190,6 +1219,18 @@ const MOCK_EXPECT = {
|
||||
&& i.setupNoteText === "Sign in on Soulseek" && i.setupWrites === 1,
|
||||
/* 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,
|
||||
/* ── 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 */
|
||||
@@ -1255,6 +1296,16 @@ const PHONE_STEPS = [
|
||||
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
|
||||
{ do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 },
|
||||
{ do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 },
|
||||
/* 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: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
|
||||
{ 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
|
||||
&& i.setupCards === "Where your music lives|Soulseek" && !i.a11yAnon && !i.a11yGhost,
|
||||
"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),
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user