/// 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, 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> = 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::>(); 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)) } }