Files
SoulStream/handoff/rounds/ins-r35-export-rs.txt
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
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.
2026-09-14 07:57:41 -05:00

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))
}
}