android: a playlist's cover from a photo on the phone

The storage plugin gains pick_image — the document picker limited to
images, the picked content copied into the app's cache so the Rust side
gets a plain path. playlist_cover_set(id, path) is the desktop pick's
install step split out and shared (squared, the master cover, the row's
revision moved). A playlist's sheet offers Choose cover photo and Remove
custom cover; the mock walk exercises both.
This commit is contained in:
KeelrLeek committed 2026-09-13 21:20:11 -05:00
1 parent d95c3f48d1
commit b50577fc81
15 files changed
+188 -12

No files matched your search

+6 -1
View File
@@ -268,6 +268,11 @@ describe / pin / duplicate / delete, `playlist_add_items`,
byte-complete, the second finds a source for every stream row (the
remembered one, else the ladder) and hands it to the download pipeline;
the page shows the run's line with Stop, then its summary.
- **The cover**: Choose cover photo on a playlist's sheet opens the phone's
image picker (the storage plugin's `pick_image`: the picked content is
copied into the app's cache, and `playlist_cover_set` installs it the way
the desktop's dialog pick does — squared, the master cover, the row's
revision moved); Remove custom cover brings the mosaic back.
- **Add to playlist** is on every track and record sheet: a pick-a-playlist
sheet (New playlist at the top). A file on the phone becomes a local row;
a streamed or catalog track keeps its identity (`track_id`) and cover URL
@@ -545,7 +550,7 @@ paused) without the walk.
is Kyle's to join.
- The desktop-only doors and tools: the file and folder playlist imports,
the listener door and the lyric alignment models (a desktop runtime),
playlist export and the cover picker, drag reorder, catalog track
playlist export, drag reorder (Move up / down / to the top stand in), catalog track
previews, the full discography page, the artist-artwork upscaler,
updates and the account section (sign-in lives on Community).
- Follows and the play history are per library folder, so the phone's own
@@ -91,4 +91,37 @@ class StoragePlugin(private val activity: Activity) : Plugin(activity) {
}
invoke.resolve()
}
/** 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). */
@Command
fun pickImage(invoke: Invoke) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "image/*"
startActivityForResult(invoke, intent, "imagePicked")
}
@ActivityCallback
fun imagePicked(invoke: Invoke, result: ActivityResult) {
when (result.resultCode) {
Activity.RESULT_OK -> {
val uri = result.data?.data
if (uri == null) { invoke.reject("no image came back"); return }
try {
val target = java.io.File(activity.cacheDir, "picked-cover-" + System.currentTimeMillis() + ".img")
val input = activity.contentResolver.openInputStream(uri)
if (input == null) { invoke.reject("the image could not be read"); return }
input.use { i -> target.outputStream().use { o -> i.copyTo(o) } }
val out = JSObject()
out.put("path", target.absolutePath)
invoke.resolve(out)
} catch (e: Exception) {
invoke.reject("the image could not be read: " + e.message)
}
}
Activity.RESULT_CANCELED -> invoke.reject("cancelled")
else -> invoke.reject("the picker failed")
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// The three commands, as the Rust functions are named; the ACL slugs them
// (`allow-pick-folder`, …) into permissions/autogenerated.
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access"];
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image"];
fn main() {
// Registers the command permissions and, on Android, hands the Gradle
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pick-image"
description = "Enables the pick_image command without any pre-configured scope."
commands.allow = ["pick_image"]
[[permission]]
identifier = "deny-pick-image"
description = "Denies the pick_image command without any pre-configured scope."
commands.deny = ["pick_image"]
@@ -9,6 +9,7 @@ whether it is granted, and the system screen that grants it.
- `allow-pick-folder`
- `allow-all-files-access`
- `allow-request-all-files-access`
- `allow-pick-image`
## Permission Table
@@ -74,6 +75,32 @@ Denies the pick_folder command without any pre-configured scope.
<tr>
<td>
`storage:allow-pick-image`
</td>
<td>
Enables the pick_image command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:deny-pick-image`
</td>
<td>
Denies the pick_image command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`storage:allow-request-all-files-access`
</td>
@@ -6,4 +6,4 @@ The phone's storage: pick a library folder (a Storage Access Framework tree,
resolved to a path when the volume allows), and Android's all-files access —
whether it is granted, and the system screen that grants it.
"""
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access"]
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image"]
@@ -318,6 +318,18 @@
"const": "deny-pick-folder",
"markdownDescription": "Denies the pick_folder command without any pre-configured scope."
},
{
"description": "Enables the pick_image command without any pre-configured scope.",
"type": "string",
"const": "allow-pick-image",
"markdownDescription": "Enables the pick_image command without any pre-configured scope."
},
{
"description": "Denies the pick_image command without any pre-configured scope.",
"type": "string",
"const": "deny-pick-image",
"markdownDescription": "Denies the pick_image command without any pre-configured scope."
},
{
"description": "Enables the request_all_files_access command without any pre-configured scope.",
"type": "string",
@@ -331,10 +343,10 @@
"markdownDescription": "Denies the request_all_files_access command without any pre-configured scope."
},
{
"description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`",
"description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`",
"type": "string",
"const": "default",
"markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`"
"markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`"
}
]
}
+28 -1
View File
@@ -33,6 +33,12 @@ pub struct AccessState {
pub granted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PickedImage {
pub path: Option<String>,
}
#[cfg(mobile)]
pub struct Storage<R: Runtime>(tauri::plugin::PluginHandle<R>);
@@ -91,9 +97,30 @@ fn request_all_files_access<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(),
}
}
/// A photo from the phone: the document picker limited to images, the
/// picked content copied into the app's cache — a plain path for the Rust
/// side (a playlist's cover, for one).
#[tauri::command]
async fn pick_image<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedImage, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<PickedImage>("pickImage", ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Err("the image picker here is the desktop's dialog".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])
.invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
+1
View File
@@ -259,6 +259,7 @@ pub fn run() {
playlists::playlist_relink_item,
playlists::playlist_cover,
playlists::playlist_cover_pick,
playlists::playlist_cover_set,
playlists::playlist_cover_clear,
playlists::playlist_import_probe,
playlists::playlist_import_folder,
+26 -3
View File
@@ -399,7 +399,18 @@ pub async fn playlist_cover_pick(
let Some(source) = rx.await.unwrap_or(None) else {
return Ok(None);
};
let dir = covers_dir(&app)?;
install_cover(&app, &lib, id, source).await.map(Some)
}
/// A picked image — the desktop's dialog, or a photo the phone copied into
/// its cache — squared, written as the playlist's master cover, recorded.
async fn install_cover(
app: &tauri::AppHandle,
lib: &LibraryState,
id: i64,
source: String,
) -> Result<String, String> {
let dir = covers_dir(app)?;
let target = dir.join(format!("{id}.jpg"));
let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, String> {
let bytes = std::fs::read(&source).map_err(|e| e.to_string())?;
@@ -422,8 +433,20 @@ pub async fn playlist_cover_pick(
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(Some(data_url_jpeg(&jpeg)))
changed(app);
Ok(data_url_jpeg(&jpeg))
}
/// The phone's door: a photo already on disk (the storage plugin copied it
/// into the app's cache) becomes the playlist's cover.
#[tauri::command]
pub async fn playlist_cover_set(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
path: String,
) -> Result<String, String> {
install_cover(&app, &lib, id, path).await
}
#[tauri::command]
+3
View File
@@ -274,6 +274,7 @@ export const playlistCover = (id, maxEdge = null) =>
invoke("playlist_cover", { id, maxEdge });
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
export const playlistCoverSet = (id, path) => invoke("playlist_cover_set", { id, path });
// Import: probe opens a file dialog and stages a reviewed draft; text stages
// a pasted tracklist; commit writes the accepted draft in one transaction.
export const playlistImportProbe = () => invoke("playlist_import_probe");
@@ -500,6 +501,8 @@ export const requestAllFilesAccess = () => invoke("plugin:storage|request_all_fi
// The phone's share plugin (crates/tauri-plugin-share): what another app
// shared to SoulStream, { text } — read once.
export const pendingShare = () => invoke("plugin:share|pending_share");
// A photo from the phone (the storage plugin): { path } in the app's cache.
export const pickImage = () => invoke("plugin:storage|pick_image");
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
// second batch's indices continuing, since index 0 means "track 1".
+5 -2
View File
@@ -93,7 +93,7 @@ let plSeq = 3;
let plItemSeq = 100;
const PL_T0 = 1_725_000_000;
const PL = [
{ id: 1, name: "Late night drive", track_count: 0, description: "Slow, wide, mostly synths.", kind: "manual", pinned: true, cover_path: null, created_at: PL_T0, updated_at: PL_T0, revision: 1, duration_secs: 0, unresolved_count: 0 },
{ id: 1, name: "Late night drive", track_count: 0, description: "Slow, wide, mostly synths.", kind: "manual", pinned: true, cover_path: "mock://cover1", created_at: PL_T0, updated_at: PL_T0, revision: 1, duration_secs: 0, unresolved_count: 0 },
{ id: 2, name: "Boiler Room 2019", track_count: 0, description: null, kind: "setlist", pinned: false, cover_path: null, created_at: PL_T0 + 10, updated_at: PL_T0 + 10, revision: 1, duration_secs: 0, unresolved_count: 0 },
{ id: 3, name: "Empty one", track_count: 0, description: null, kind: "manual", pinned: false, cover_path: null, created_at: PL_T0 + 20, updated_at: PL_T0 + 20, revision: 1, duration_secs: 0, unresolved_count: 0 },
];
@@ -135,7 +135,9 @@ function mockLyrics(title) {
function mockPlaylists(cmd, args) {
if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p })));
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.id === 1 || p.cover_path) ? svgArt(p.id === 1 ? 330 : 280, p.id === 1 ? "L" : "M") : null); }
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")); }
if (cmd === "playlist_cover_clear") { const p = plOf(args); if (p) p.cover_path = null; plTouch(p); return Promise.resolve(); }
if (cmd === "create_playlist") {
const p = { id: ++plSeq, name: args?.name || "New Playlist", track_count: 0, description: null, kind: args?.kind || "manual", pinned: false, cover_path: null, created_at: Math.floor(Date.now() / 1000), updated_at: 0, revision: 0, duration_secs: 0, unresolved_count: 0 };
PL.push(p);
@@ -516,6 +518,7 @@ function mockCatalog(cmd, args) {
if (cmd === "plugin:storage|pick_folder") return Promise.resolve({ uri: "content://mock/tree/primary%3AMusic", path: "C:\\mock\\Music" });
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true });
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\mock\\photo.jpg" });
if (cmd === "plugin:share|pending_share") {
/* the harness shares by setting window.__ssMock.share and waking the page */
const t = (typeof window !== "undefined" && window.__ssMock?.share) || null;
+6 -1
View File
@@ -12,7 +12,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 } from "./catalog.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem } 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 } from "./playlists.svelte.js";
import { DOORS, start as startImport } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
@@ -374,6 +374,11 @@
{@render row("file-text", p.description ? "Edit description" : "Add a description", p.description || "", () => describePrompt(p))}
{/if}
{@render row("layers", "Duplicate", "a copy, “" + p.name + " copy”", act(() => duplicatePlaylist(p).then((id) => push("playlist", { id })).catch(oops)))}
<div class="sheet-hair"></div>
{@render row("image", "Choose cover photo…", "a picture from this phone", act(() => coverFromPhoto(p).catch(oops)))}
{#if p.cover_path}
{@render row("x", "Remove custom cover", "the mosaic comes back", act(() => coverClear(p).catch(oops)))}
{/if}
{#if kind === "playlist-more" && unres.length}
{@render row("x", "Remove unresolved entries", unres.length + " that never matched a track", act(() => removeItems(p, rows, unres).catch(oops)))}
{/if}
+16
View File
@@ -184,6 +184,22 @@ export async function deletePlaylist(p) {
await refreshPlaylists();
}
/* ── the cover: a photo from the phone, or the mosaic again ── */
export async function coverFromPhoto(p) {
let r;
try { r = await api.pickImage(); } catch (e) { if (/cancelled/.test(String(e))) return false; throw e; }
if (!r?.path) return false;
await api.playlistCoverSet(p.id, r.path);
await refreshPlaylists();
note(`Cover set for ${p.name}`);
return true;
}
export async function coverClear(p) {
await api.playlistCoverClear(p.id);
await refreshPlaylists();
note(`Back to the mosaic for ${p.name}`);
}
/* ── 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) {
+8
View File
@@ -111,6 +111,7 @@ const PROBE = `(function () {
plkind: (document.querySelector('.rec-head.pl .kind') || {}).textContent || '',
plfilter: !!document.querySelector('.plfilter input'),
plfirst: (document.querySelector('.pl-rows .trk .t') || {}).textContent || '',
plcover: !!document.querySelector('.rec-head.pl .rec-cover img'),
qfirst: (document.querySelector('.q-face .row .t') || {}).textContent || '',
rooms: document.querySelectorAll('.screen button.row .art.hash').length,
plthumbs: document.querySelectorAll('.pl-rows .thumb img').length,
@@ -307,6 +308,11 @@ const MOCK_STEPS = [
{ do: `__click('.halfsheet .sheet-rows .row', 'Track 4')`, name: "playlist-matched", wait: 1000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Unpin')`, name: "playlist-unpinned", wait: 900 },
/* the cover: the custom one removed (the mosaic), then a photo chosen */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Remove custom cover')`, name: "cover-removed", wait: 1000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Choose cover photo')`, name: "cover-set", wait: 1000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Rename')`, name: "playlist-rename", wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Late night drive II'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
@@ -476,6 +482,8 @@ const MOCK_EXPECT = {
"playlist-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice),
"playlist-more": (i) => i.half.includes("up") && /Unpin/.test(i.halfText) && /Rename/.test(i.halfText) && /Delete playlist/.test(i.halfText),
"playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind),
"cover-removed": (i) => !i.plcover && /mosaic/.test(i.notice),
"cover-set": (i) => i.plcover && /Cover set/.test(i.notice),
"playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive",
"playlist-renamed": (i) => i.title.startsWith("Late night drive II"),
"playlists-again": (i) => i.plcards === 3 && !i.plpinned && /Late night drive II/.test(i.plnames),