diff --git a/modules/trashbin/index.scss b/modules/trashbin/index.scss index 53b6ddd..1261bbe 100644 --- a/modules/trashbin/index.scss +++ b/modules/trashbin/index.scss @@ -61,3 +61,10 @@ } } } + +/* The trashed items list on the Spicetify Settings page: the rows are stdlib + primitives, so only the scroll bound lives here. */ +.trashbin-list { + max-height: 360px; + overflow-y: auto; +} diff --git a/modules/trashbin/logic.test.mts b/modules/trashbin/logic.test.mts index f4915f7..e10b107 100644 --- a/modules/trashbin/logic.test.mts +++ b/modules/trashbin/logic.test.mts @@ -6,7 +6,16 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { collectArtistUris, shouldSkipTrack, targetMatchesCurrent, toggleEntry } from "./logic.ts"; +import { + collectArtistUris, + matchingEntries, + normalizeList, + shouldSkipTrack, + targetMatchesCurrent, + toggleEntry, + trackLabel, + trashedEntries, +} from "./logic.ts"; describe("collectArtistUris", () => { it("walks artist_uri then artist_uri:1, artist_uri:2 until a gap", () => { @@ -44,6 +53,57 @@ describe("targetMatchesCurrent", () => { }); }); +describe("normalizeList", () => { + it("keeps every truthy entry as true and drops everything else", () => { + assert.deepEqual(normalizeList({ a: true, b: "Artist - Song", c: false, d: null, e: "" }), { + a: true, + b: true, + }); + }); + + it("treats anything that is not a plain object as an empty list", () => { + for (const value of [null, undefined, "text", 3, ["spotify:track:t"]]) { + assert.deepEqual(normalizeList(value), {}); + } + }); +}); + +describe("trackLabel", () => { + it("puts the artists before the title", () => { + assert.equal(trackLabel("Song", ["A", "B"]), "A, B - Song"); + assert.equal(trackLabel("Song", []), "Song"); + }); +}); + +describe("trashedEntries", () => { + it("lists songs then artists, each newest first", () => { + assert.deepEqual(trashedEntries({ s1: true, s2: true }, { a1: true }), [ + { uri: "s2", kind: "song" }, + { uri: "s1", kind: "song" }, + { uri: "a1", kind: "artist" }, + ]); + }); +}); + +describe("matchingEntries", () => { + const entries = trashedEntries({ "spotify:track:1": true, "spotify:track:2": true }, { "spotify:artist:3": true }); + const labels = new Map([ + ["spotify:track:1", "Rick Astley - Never Gonna Give You Up"], + ["spotify:artist:3", "Rick Astley"], + ]); + const uris = (query: string) => matchingEntries(entries, (uri) => labels.get(uri), query).map((e) => e.uri); + + it("matches names case-insensitively and ignores surrounding spaces", () => { + assert.deepEqual(uris(" rick "), ["spotify:track:1", "spotify:artist:3"]); + assert.deepEqual(uris("never gonna"), ["spotify:track:1"]); + }); + + it("matches an unnamed entry by its uri, and an empty query keeps everything", () => { + assert.deepEqual(uris("track:2"), ["spotify:track:2"]); + assert.equal(uris("").length, 3); + }); +}); + describe("toggleEntry", () => { it("adds when absent, removes when present, never mutates the input", () => { const list = { existing: true }; diff --git a/modules/trashbin/logic.ts b/modules/trashbin/logic.ts index e8d44c7..efebd4b 100644 --- a/modules/trashbin/logic.ts +++ b/modules/trashbin/logic.ts @@ -38,10 +38,47 @@ export function targetMatchesCurrent( return current.artistUris.includes(targetUri); } -export function toggleEntry( - list: Record, - uri: string, -): { next: Record; added: boolean } { +export type TrashList = Record; +export type TrashedEntry = { uri: string; kind: "song" | "artist" }; + +// A stored list is uri -> true. Anything else truthy (an older build stored +// display names here) still means trashed; malformed storage means empty. +export function normalizeList(value: unknown): TrashList { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .filter(([, trashed]) => !!trashed) + .map(([uri]) => [uri, true]), + ); +} + +export function trackLabel(title: string, artists: string[]): string { + return artists.length ? `${artists.join(", ")} - ${title}` : title; +} + +// Insertion order is trash order, so reversing a list puts the newest first. +export function trashedEntries(songs: TrashList, artists: TrashList): TrashedEntry[] { + return [ + ...Object.keys(songs) + .reverse() + .map((uri) => ({ uri, kind: "song" as const })), + ...Object.keys(artists) + .reverse() + .map((uri) => ({ uri, kind: "artist" as const })), + ]; +} + +export function matchingEntries( + entries: TrashedEntry[], + labelOf: (uri: string) => string | undefined, + query: string, +): TrashedEntry[] { + const needle = query.trim().toLowerCase(); + if (!needle) return entries; + return entries.filter(({ uri }) => (labelOf(uri) ?? uri).toLowerCase().includes(needle)); +} + +export function toggleEntry(list: TrashList, uri: string): { next: TrashList; added: boolean } { if (!list[uri]) { return { next: { ...list, [uri]: true }, added: true }; } diff --git a/modules/trashbin/metadata.json b/modules/trashbin/metadata.json index c0e43fd..299a2e7 100644 --- a/modules/trashbin/metadata.json +++ b/modules/trashbin/metadata.json @@ -1,7 +1,7 @@ { "name": "trashbin", "kind": "extension", - "version": "0.2.6", + "version": "0.3.0", "authors": ["spicetify"], "description": "Throw songs and artists in the trashbin and never hear them again.", "entries": { diff --git a/modules/trashbin/mod.tsx b/modules/trashbin/mod.tsx index 3e36110..41b7714 100644 --- a/modules/trashbin/mod.tsx +++ b/modules/trashbin/mod.tsx @@ -10,8 +10,27 @@ */ import { client, createRegistrar, PlaybarButton, React, type ModuleRuntimeContext } from "/modules/stdlib/mod.ts"; -import { Button, SettingsRow, SettingsSection, Toggle } from "/modules/stdlib/lib/primitives.tsx"; -import { collectArtistUris, shouldSkipTrack, targetMatchesCurrent, toggleEntry } from "./logic.ts"; +import { + Button, + SettingsButtonRow, + SettingsLabel, + SettingsRow, + SettingsSection, + SettingsTextInputRow, + Toggle, +} from "/modules/stdlib/lib/primitives.tsx"; +import { + collectArtistUris, + matchingEntries, + normalizeList, + shouldSkipTrack, + targetMatchesCurrent, + toggleEntry, + trackLabel, + trashedEntries, + type TrashedEntry, + type TrashList, +} from "./logic.ts"; const ICON_PATH = ''; @@ -37,11 +56,18 @@ const findSkipBackButton = (): HTMLElement | null => document.querySelector(".player-controls__left > button[data-encore-id='buttonTertiary']"); export default async function (ctx: ModuleRuntimeContext) { - const { useState } = React; + const { useState, useEffect } = React; const registrar = createRegistrar(ctx); - let trashSongList: Record = initValue("TrashSongList", {}); - let trashArtistList: Record = initValue("TrashArtistList", {}); + let trashSongList: TrashList = normalizeList(initValue("TrashSongList", {})); + let trashArtistList: TrashList = normalizeList(initValue("TrashArtistList", {})); + // Display names by uri, kept apart from the lists so the lists stay the + // uri -> true shape the skip logic and older trashbin data use. + const trashNames = new Map( + Object.entries(initValue>("TrashbinNames", {})).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); let trashbinStatus: boolean = initValue("trashbin-enabled", true); let enableWidget: boolean = initValue("TrashbinWidgetIcon", true); let userHitBack = false; @@ -52,8 +78,65 @@ export default async function (ctx: ModuleRuntimeContext) { const refreshButtons = () => refreshers.forEach((f) => f()); const putDataLocal = () => { + for (const uri of trashNames.keys()) { + if (!trashSongList[uri] && !trashArtistList[uri]) trashNames.delete(uri); + } client.storage.set("TrashSongList", JSON.stringify(trashSongList)); client.storage.set("TrashArtistList", JSON.stringify(trashArtistList)); + client.storage.set("TrashbinNames", JSON.stringify(Object.fromEntries(trashNames))); + }; + + const lookUpName = async ({ uri, kind }: TrashedEntry): Promise => { + const { Request, Definitions } = client.graphQL; + if (kind === "artist") { + const { data } = await Request(Definitions.queryArtistOverview, { + uri, + locale: client.locale.getLocale(), + includePrerelease: false, + }); + return data?.artistUnion?.profile?.name ?? null; + } + const [name, credits] = await Promise.all([ + Request(Definitions.getTrackName, { uri }), + Request(Definitions.queryTrackArtists, { trackUri: uri }), + ]); + const title: string | undefined = name.data?.trackUnion?.name; + if (!title) return null; + const artists: string[] = (credits.data?.trackUnion?.artists?.items ?? []) + .map((artist: { profile?: { name?: string } }) => artist.profile?.name) + .filter((artistName: string | undefined): artistName is string => !!artistName); + return trackLabel(title, artists); + }; + + // Resolves names the cache does not have yet, a few requests at a time. + const resolveNames = async (entries: TrashedEntry[], onName?: () => void) => { + const queue = entries.filter(({ uri }) => !trashNames.has(uri)); + const worker = async () => { + for (let entry = queue.shift(); entry; entry = queue.shift()) { + try { + const name = await lookUpName(entry); + if (!name || (!trashSongList[entry.uri] && !trashArtistList[entry.uri])) continue; + trashNames.set(entry.uri, name); + onName?.(); + } catch (error) { + console.warn("[trashbin] could not look up", entry.uri, error); + } + } + }; + await Promise.all(Array.from({ length: 4 }, worker)); + putDataLocal(); + }; + + // The playing track's name is already known, so it costs no request. + const rememberName = (uri: string) => { + const item = client.player.data?.item; + if (item?.uri === uri && item.metadata?.title) { + const artists = item.metadata.artist_name ? [item.metadata.artist_name] : []; + trashNames.set(uri, trackLabel(item.metadata.title, artists)); + return; + } + const kind = isTrackUri(uri) ? "song" : "artist"; + void resolveNames([{ uri, kind }], refreshButtons); }; const isTrackUri = (uri: string) => client.uri.fromString(uri).type === client.uri.Type.TRACK; @@ -107,6 +190,7 @@ export default async function (ctx: ModuleRuntimeContext) { const { next, added } = toggleEntry(trashSongList, uri); trashSongList = next; if (added) { + rememberName(uri); client.player.next(); client.notify("Song added to trashbin"); } else { @@ -119,7 +203,7 @@ export default async function (ctx: ModuleRuntimeContext) { // ----- playbar button (v3 register) ----- const TrashButton = () => { const [, force] = React.useReducer((n: number) => n + 1, 0); - React.useEffect(() => { + useEffect(() => { const on = () => force(); refreshers.add(force); client.player.addEventListener("songchange", on); @@ -151,6 +235,7 @@ export default async function (ctx: ModuleRuntimeContext) { if (isTrack) trashSongList = next; else trashArtistList = next; if (added) { + rememberName(uri); if (shouldSkipCurrentTrack(uri, type)) client.player.next(); client.notify(isTrack ? "Song added to trashbin" : "Artist added to trashbin"); } else { @@ -183,6 +268,34 @@ export default async function (ctx: ModuleRuntimeContext) { function Settings() { const [enabled, setEnabled] = useState(trashbinStatus); const [widget, setWidget] = useState(enableWidget); + const [entries, setEntries] = useState(() => trashedEntries(trashSongList, trashArtistList)); + const [, rerender] = React.useReducer((n: number) => n + 1, 0); + const [query, setQuery] = useState(""); + + useEffect(() => { + const sync = () => { + setEntries(trashedEntries(trashSongList, trashArtistList)); + rerender(); + }; + refreshers.add(sync); + return () => { + refreshers.delete(sync); + }; + }, []); + + useEffect(() => { + void resolveNames(entries, rerender); + }, [entries]); + + const visible = matchingEntries(entries, (uri) => trashNames.get(uri), query); + + const remove = ({ uri, kind }: TrashedEntry) => { + if (kind === "song") trashSongList = toggleEntry(trashSongList, uri).next; + else trashArtistList = toggleEntry(trashArtistList, uri).next; + putDataLocal(); + refreshButtons(); + client.notify(kind === "song" ? "Song removed from trashbin" : "Artist removed from trashbin"); + }; return ( @@ -246,6 +359,38 @@ export default async function (ctx: ModuleRuntimeContext) { Clear + + {entries.length === 0 ? ( + }>{null} + ) : ( + <> + +
+ {visible.length === 0 ? ( + }> + {null} + + ) : ( + visible.map((entry) => ( + remove(entry)} + /> + )) + )} +
+ + )}
); } @@ -277,8 +422,8 @@ export default async function (ctx: ModuleRuntimeContext) { reader.onload = (ev) => { try { const data = JSON.parse(ev.target?.result as string); - trashSongList = data.songs; - trashArtistList = data.artists; + trashSongList = normalizeList(data?.songs); + trashArtistList = normalizeList(data?.artists); putDataLocal(); refreshButtons(); client.notify("File Import Successful!");