Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
133 lines
5.5 KiB
Plaintext
133 lines
5.5 KiB
Plaintext
/// 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,
|
|
lib: State<'_, LibraryState>,
|
|
id: i64,
|
|
format: String,
|
|
) -> Result<Option<String>, String> {
|
|
let l = lib.library.clone();
|
|
let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or("Playlist not found")?;
|
|
let l = lib.library.clone();
|
|
let items = tauri::async_runtime::spawn_blocking(move || l.playlist_items(id))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
// 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
|
|
.name
|
|
.chars()
|
|
.map(|c| match c {
|
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
|
c if (c as u32) < 0x20 => '_',
|
|
c => c,
|
|
})
|
|
.collect();
|
|
|
|
// 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,
|
|
),
|
|
}
|
|
};
|
|
|
|
#[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())
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())??;
|
|
Ok(Some(dest))
|
|
}
|
|
}
|