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