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:
1 parent
d95c3f48d1
commit
b50577fc81
15 files changed
+188
-12
No files matched your search
+6
-1
@@ -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
|
||||
|
||||
+33
@@ -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,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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user