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
+390
-72
No files matched your search
+98
-57
@@ -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,56 +1656,85 @@ 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 {
|
||||
title: &i.title,
|
||||
artist: &i.artist,
|
||||
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
|
||||
.iter()
|
||||
.map(|i| ExportItem {
|
||||
title: &i.title,
|
||||
artist: &i.artist,
|
||||
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(),
|
||||
#[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
|
||||
// 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();
|
||||
let body = 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,
|
||||
),
|
||||
};
|
||||
// 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))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
Ok(Some(dest))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user