From b50577fc81939913bb00454c71b4c229a62ffde1 Mon Sep 17 00:00:00 2001
From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com>
Date: Sun, 13 Sep 2026 21:20:11 -0500
Subject: [PATCH] android: a playlist's cover from a photo on the phone
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
ANDROID.md | 7 +++-
.../com/soulstream/storage/StoragePlugin.kt | 33 +++++++++++++++++++
crates/tauri-plugin-storage/build.rs | 2 +-
.../autogenerated/commands/pick_image.toml | 13 ++++++++
.../permissions/autogenerated/reference.md | 27 +++++++++++++++
.../permissions/default.toml | 2 +-
.../permissions/schemas/schema.json | 16 +++++++--
crates/tauri-plugin-storage/src/lib.rs | 29 +++++++++++++++-
src-tauri/src/lib.rs | 1 +
src-tauri/src/playlists.rs | 29 ++++++++++++++--
src/lib/api.js | 3 ++
src/lib/mockLibrary.js | 7 ++--
src/mobile/HalfSheet.svelte | 7 +++-
src/mobile/playlists.svelte.js | 16 +++++++++
tests/mobile/smoke.mjs | 8 +++++
15 files changed, 188 insertions(+), 12 deletions(-)
create mode 100644 crates/tauri-plugin-storage/permissions/autogenerated/commands/pick_image.toml
diff --git a/ANDROID.md b/ANDROID.md
index e43f747..732c8e9 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -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
diff --git a/crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt b/crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt
index 0218d7e..77f39cd 100644
--- a/crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt
+++ b/crates/tauri-plugin-storage/android/src/main/java/com/soulstream/storage/StoragePlugin.kt
@@ -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")
+ }
+ }
}
diff --git a/crates/tauri-plugin-storage/build.rs b/crates/tauri-plugin-storage/build.rs
index 72b7c1b..9b7d201 100644
--- a/crates/tauri-plugin-storage/build.rs
+++ b/crates/tauri-plugin-storage/build.rs
@@ -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
diff --git a/crates/tauri-plugin-storage/permissions/autogenerated/commands/pick_image.toml b/crates/tauri-plugin-storage/permissions/autogenerated/commands/pick_image.toml
new file mode 100644
index 0000000..1dd8ed6
--- /dev/null
+++ b/crates/tauri-plugin-storage/permissions/autogenerated/commands/pick_image.toml
@@ -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"]
diff --git a/crates/tauri-plugin-storage/permissions/autogenerated/reference.md b/crates/tauri-plugin-storage/permissions/autogenerated/reference.md
index 9f4810f..fd7e06d 100644
--- a/crates/tauri-plugin-storage/permissions/autogenerated/reference.md
+++ b/crates/tauri-plugin-storage/permissions/autogenerated/reference.md
@@ -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.
|
+`storage:allow-pick-image`
+
+ |
+
+
+Enables the pick_image command without any pre-configured scope.
+
+ |
+
+
+
+|
+
+`storage:deny-pick-image`
+
+ |
+
+
+Denies the pick_image command without any pre-configured scope.
+
+ |
+
+
+
+|
+
`storage:allow-request-all-files-access`
|
diff --git a/crates/tauri-plugin-storage/permissions/default.toml b/crates/tauri-plugin-storage/permissions/default.toml
index 482705d..18120d1 100644
--- a/crates/tauri-plugin-storage/permissions/default.toml
+++ b/crates/tauri-plugin-storage/permissions/default.toml
@@ -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"]
diff --git a/crates/tauri-plugin-storage/permissions/schemas/schema.json b/crates/tauri-plugin-storage/permissions/schemas/schema.json
index 352d125..9f46963 100644
--- a/crates/tauri-plugin-storage/permissions/schemas/schema.json
+++ b/crates/tauri-plugin-storage/permissions/schemas/schema.json
@@ -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`"
}
]
}
diff --git a/crates/tauri-plugin-storage/src/lib.rs b/crates/tauri-plugin-storage/src/lib.rs
index e9cfe8d..4041640 100644
--- a/crates/tauri-plugin-storage/src/lib.rs
+++ b/crates/tauri-plugin-storage/src/lib.rs
@@ -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,
+}
+
#[cfg(mobile)]
pub struct Storage(tauri::plugin::PluginHandle);
@@ -91,9 +97,30 @@ fn request_all_files_access(app: tauri::AppHandle) -> 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(app: tauri::AppHandle) -> Result {
+ #[cfg(mobile)]
+ {
+ use tauri::Manager;
+ let storage = app.state::>();
+ storage
+ .0
+ .run_mobile_plugin::("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() -> TauriPlugin {
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")]
{
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index e16e1fc..663e569 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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,
diff --git a/src-tauri/src/playlists.rs b/src-tauri/src/playlists.rs
index 767d30b..34baa44 100644
--- a/src-tauri/src/playlists.rs
+++ b/src-tauri/src/playlists.rs
@@ -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 {
+ let dir = covers_dir(app)?;
let target = dir.join(format!("{id}.jpg"));
let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result, 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 {
+ install_cover(&app, &lib, id, path).await
}
#[tauri::command]
diff --git a/src/lib/api.js b/src/lib/api.js
index 241df4a..c5f674d 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -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\\ 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".
diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js
index b832d82..c2ea1b0 100644
--- a/src/lib/mockLibrary.js
+++ b/src/lib/mockLibrary.js
@@ -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;
diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte
index 54ff839..eff238a 100644
--- a/src/mobile/HalfSheet.svelte
+++ b/src/mobile/HalfSheet.svelte
@@ -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)))}
+
+ {@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}
diff --git a/src/mobile/playlists.svelte.js b/src/mobile/playlists.svelte.js
index ecdc681..5200ace 100644
--- a/src/mobile/playlists.svelte.js
+++ b/src/mobile/playlists.svelte.js
@@ -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) {
diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs
index bfba972..51b8461 100644
--- a/tests/mobile/smoke.mjs
+++ b/tests/mobile/smoke.mjs
@@ -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),