From cebc3799dd2f2f8835f9bb7f7c81ef5387d48d26 Mon Sep 17 00:00:00 2001 From: Mralimoh Date: Sat, 3 Oct 2026 15:08:17 +0330 Subject: [PATCH 01/10] Add trashed songs list with live search --- modules/trashbin/logic.ts | 7 ++-- modules/trashbin/mod.tsx | 83 +++++++++++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/modules/trashbin/logic.ts b/modules/trashbin/logic.ts index e8d44c79..9fc0f0f3 100644 --- a/modules/trashbin/logic.ts +++ b/modules/trashbin/logic.ts @@ -39,11 +39,12 @@ export function targetMatchesCurrent( } export function toggleEntry( - list: Record, + list: Record, uri: string, -): { next: Record; added: boolean } { + displayName: string = "Unknown", +): { next: Record; added: boolean } { if (!list[uri]) { - return { next: { ...list, [uri]: true }, added: true }; + return { next: { ...list, [uri]: displayName }, added: true }; } const next = { ...list }; delete next[uri]; diff --git a/modules/trashbin/mod.tsx b/modules/trashbin/mod.tsx index 3e36110d..4468550b 100644 --- a/modules/trashbin/mod.tsx +++ b/modules/trashbin/mod.tsx @@ -37,11 +37,11 @@ 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: Record = initValue("TrashSongList", {}); + let trashArtistList: Record = initValue("TrashArtistList", {}); let trashbinStatus: boolean = initValue("trashbin-enabled", true); let enableWidget: boolean = initValue("TrashbinWidgetIcon", true); let userHitBack = false; @@ -102,9 +102,14 @@ export default async function (ctx: ModuleRuntimeContext) { }; const toggleCurrent = () => { - const uri = client.player.data?.item?.uri; - if (!uri) return; - const { next, added } = toggleEntry(trashSongList, uri); + const item = client.player.data?.item; + if (!item?.uri) return; + + const trackName = item.name || item.metadata?.title || "Unknown Track"; + const artistName = item.metadata?.artist_name || ""; + const displayName = artistName ? `${trackName} - ${artistName}` : trackName; + + const { next, added } = toggleEntry(trashSongList, item.uri, displayName); trashSongList = next; if (added) { client.player.next(); @@ -119,7 +124,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); @@ -183,6 +188,31 @@ export default async function (ctx: ModuleRuntimeContext) { function Settings() { const [enabled, setEnabled] = useState(trashbinStatus); const [widget, setWidget] = useState(enableWidget); + const [songs, setSongs] = useState(trashSongList); + const [searchQuery, setSearchQuery] = useState(""); + + useEffect(() => { + const sync = () => setSongs(trashSongList); + refreshers.add(sync); + return () => { + refreshers.delete(sync); + }; + }, []); + + const filteredSongs = Object.entries(songs).filter(([uri, name]) => { + const displayName = typeof name === "string" ? name : uri; + return displayName.toLowerCase().includes(searchQuery.toLowerCase()); + }); + + const removeSong = (uri: string) => { + const next = { ...trashSongList }; + delete next[uri]; + trashSongList = next; + setSongs(next); + putDataLocal(); + refreshButtons(); + client.notify("Song removed from trashbin"); + }; return ( @@ -238,6 +268,7 @@ export default async function (ctx: ModuleRuntimeContext) { onClick={() => { trashSongList = {}; trashArtistList = {}; + setSongs({}); putDataLocal(); refreshButtons(); client.notify("Trashbin cleared!"); @@ -246,6 +277,42 @@ export default async function (ctx: ModuleRuntimeContext) { Clear + +
+ setSearchQuery(e.target.value)} + style={{ + width: "100%", + padding: "8px 12px", + marginBottom: "10px", + borderRadius: "4px", + border: "1px solid rgba(255,255,255,0.2)", + background: "rgba(255,255,255,0.05)", + color: "var(--spice-text, #fff)", + boxSizing: "border-box", + outline: "none", + }} + /> + +
+ {filteredSongs.length === 0 ? ( +
+ {searchQuery ? "No matching songs found" : "Trashbin is empty"} +
+ ) : ( + filteredSongs.map(([uri, name]) => ( + + + + )) + )} +
+
); } @@ -304,4 +371,4 @@ export default async function (ctx: ModuleRuntimeContext) { client.player.removeEventListener("songchange", watchChange); cntxMenu.deregister(); }); -} +} \ No newline at end of file From c997b074c73c4ae03c28fee59c03ad43e5833c14 Mon Sep 17 00:00:00 2001 From: Mralimoh Date: Sat, 3 Oct 2026 15:12:09 +0330 Subject: [PATCH 02/10] style: format code --- modules/trashbin/mod.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/trashbin/mod.tsx b/modules/trashbin/mod.tsx index 4468550b..212bbb6e 100644 --- a/modules/trashbin/mod.tsx +++ b/modules/trashbin/mod.tsx @@ -371,4 +371,4 @@ export default async function (ctx: ModuleRuntimeContext) { client.player.removeEventListener("songchange", watchChange); cntxMenu.deregister(); }); -} \ No newline at end of file +} From 93f4759cbef7a0dce2fc3a4288fc8920f77573fe Mon Sep 17 00:00:00 2001 From: Mralimoh Date: Sat, 3 Oct 2026 15:15:36 +0330 Subject: [PATCH 03/10] fix: default entry value to true and format code --- modules/trashbin/logic.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/trashbin/logic.ts b/modules/trashbin/logic.ts index 9fc0f0f3..8112925e 100644 --- a/modules/trashbin/logic.ts +++ b/modules/trashbin/logic.ts @@ -41,7 +41,7 @@ export function targetMatchesCurrent( export function toggleEntry( list: Record, uri: string, - displayName: string = "Unknown", + displayName: any = true, ): { next: Record; added: boolean } { if (!list[uri]) { return { next: { ...list, [uri]: displayName }, added: true }; @@ -49,4 +49,4 @@ export function toggleEntry( const next = { ...list }; delete next[uri]; return { next, added: false }; -} +} \ No newline at end of file From c3287db2619ce674ebf45eaf2d5445c15a1103d2 Mon Sep 17 00:00:00 2001 From: Mralimoh Date: Sat, 3 Oct 2026 15:20:06 +0330 Subject: [PATCH 04/10] fix: format logic.ts --- modules/trashbin/logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/trashbin/logic.ts b/modules/trashbin/logic.ts index 8112925e..a8dc2481 100644 --- a/modules/trashbin/logic.ts +++ b/modules/trashbin/logic.ts @@ -49,4 +49,4 @@ export function toggleEntry( const next = { ...list }; delete next[uri]; return { next, added: false }; -} \ No newline at end of file +} From 606c13723c7f99bb6b426e85353c668cee4e4022 Mon Sep 17 00:00:00 2001 From: Mralimoh Date: Sat, 3 Oct 2026 15:22:26 +0330 Subject: [PATCH 05/10] chore: bump trashbin version to 0.2.7 --- modules/trashbin/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/trashbin/metadata.json b/modules/trashbin/metadata.json index c0e43fd2..06804015 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.2.7", "authors": ["spicetify"], "description": "Throw songs and artists in the trashbin and never hear them again.", "entries": { From 221013e543e610711836a94d0fd48246f7de15a6 Mon Sep 17 00:00:00 2001 From: Mralimoh Date: Sat, 3 Oct 2026 15:47:24 +0330 Subject: [PATCH 06/10] feat(trashbin): format song as Artist - Title and tune scroll height --- modules/trashbin/mod.tsx | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/modules/trashbin/mod.tsx b/modules/trashbin/mod.tsx index 212bbb6e..819bbe36 100644 --- a/modules/trashbin/mod.tsx +++ b/modules/trashbin/mod.tsx @@ -107,7 +107,7 @@ export default async function (ctx: ModuleRuntimeContext) { const trackName = item.name || item.metadata?.title || "Unknown Track"; const artistName = item.metadata?.artist_name || ""; - const displayName = artistName ? `${trackName} - ${artistName}` : trackName; + const displayName = artistName ? `${artistName} - ${trackName}` : trackName; const { next, added } = toggleEntry(trashSongList, item.uri, displayName); trashSongList = next; @@ -152,7 +152,19 @@ export default async function (ctx: ModuleRuntimeContext) { const uri = uris[0]; const type = client.uri.fromString(uri).type; const isTrack = type === client.uri.Type.TRACK; - const { next, added } = toggleEntry(isTrack ? trashSongList : trashArtistList, uri); + + const curItem = client.player.data?.item; + let displayName = isTrack ? "Track" : "Artist"; + + if (curItem && curItem.uri === uri) { + const trackName = curItem.name || curItem.metadata?.title || "Unknown Track"; + const artistName = curItem.metadata?.artist_name || ""; + displayName = isTrack ? (artistName ? `${artistName} - ${trackName}` : trackName) : artistName; + } else { + displayName = `${isTrack ? "Track" : "Artist"} (${uri.split(":").pop()})`; + } + + const { next, added } = toggleEntry(isTrack ? trashSongList : trashArtistList, uri, displayName); if (isTrack) trashSongList = next; else trashArtistList = next; if (added) { @@ -297,7 +309,15 @@ export default async function (ctx: ModuleRuntimeContext) { }} /> -
+
5 ? "auto" : "hidden", + overflowX: "hidden", + scrollbarGutter: "stable", + paddingRight: "6px", + }} + > {filteredSongs.length === 0 ? (
{searchQuery ? "No matching songs found" : "Trashbin is empty"} From 85ed32bf24e4ac5d22b2cdf7cc18b9e8ca83ca97 Mon Sep 17 00:00:00 2001 From: Mralimoh Date: Sat, 3 Oct 2026 16:00:15 +0330 Subject: [PATCH 07/10] fix(trashbin): show newest trashed songs first --- modules/trashbin/mod.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/trashbin/mod.tsx b/modules/trashbin/mod.tsx index 819bbe36..ce9852d4 100644 --- a/modules/trashbin/mod.tsx +++ b/modules/trashbin/mod.tsx @@ -211,10 +211,12 @@ export default async function (ctx: ModuleRuntimeContext) { }; }, []); - const filteredSongs = Object.entries(songs).filter(([uri, name]) => { - const displayName = typeof name === "string" ? name : uri; - return displayName.toLowerCase().includes(searchQuery.toLowerCase()); - }); + const filteredSongs = Object.entries(songs) + .reverse() + .filter(([uri, name]) => { + const displayName = typeof name === "string" ? name : uri; + return displayName.toLowerCase().includes(searchQuery.toLowerCase()); + }); const removeSong = (uri: string) => { const next = { ...trashSongList }; From 5b3f212c107a7bc9a2c22ea9647e6d3413f3ace9 Mon Sep 17 00:00:00 2001 From: Mralimoh Date: Sat, 3 Oct 2026 16:37:25 +0330 Subject: [PATCH 08/10] fix(trashbin): hide search bar when trashbin is empty --- modules/trashbin/mod.tsx | 90 ++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/modules/trashbin/mod.tsx b/modules/trashbin/mod.tsx index ce9852d4..0df84e2e 100644 --- a/modules/trashbin/mod.tsx +++ b/modules/trashbin/mod.tsx @@ -293,47 +293,57 @@ export default async function (ctx: ModuleRuntimeContext) {
- setSearchQuery(e.target.value)} - style={{ - width: "100%", - padding: "8px 12px", - marginBottom: "10px", - borderRadius: "4px", - border: "1px solid rgba(255,255,255,0.2)", - background: "rgba(255,255,255,0.05)", - color: "var(--spice-text, #fff)", - boxSizing: "border-box", - outline: "none", - }} - /> - -
5 ? "auto" : "hidden", - overflowX: "hidden", - scrollbarGutter: "stable", - paddingRight: "6px", - }} - > - {filteredSongs.length === 0 ? ( -
- {searchQuery ? "No matching songs found" : "Trashbin is empty"} + {Object.keys(songs).length === 0 ? ( +
+ Trashbin is empty +
+ ) : ( + <> + setSearchQuery(e.target.value)} + style={{ + width: "100%", + padding: "8px 12px", + marginBottom: "10px", + borderRadius: "4px", + border: "1px solid rgba(255,255,255,0.2)", + background: "rgba(255,255,255,0.05)", + color: "var(--spice-text, #fff)", + boxSizing: "border-box", + outline: "none", + }} + /> + +
5 ? "auto" : "hidden", + overflowX: "hidden", + scrollbarGutter: "stable", + paddingRight: "6px", + }} + > + {filteredSongs.length === 0 ? ( +
+ No matching songs found +
+ ) : ( + filteredSongs.map(([uri, name]) => ( + + + + )) + )}
- ) : ( - filteredSongs.map(([uri, name]) => ( - - - - )) - )} -
+ + )}
); From a236b396a551921bf04a5aef36f87af4378df250 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Sat, 10 Oct 2026 02:01:11 +0200 Subject: [PATCH 09/10] fix(trashbin): name every trashed item and list them with settings primitives --- modules/trashbin/index.scss | 7 + modules/trashbin/logic.test.mts | 62 ++++++++- modules/trashbin/logic.ts | 48 ++++++- modules/trashbin/mod.tsx | 236 +++++++++++++++++++------------- 4 files changed, 251 insertions(+), 102 deletions(-) diff --git a/modules/trashbin/index.scss b/modules/trashbin/index.scss index 53b6ddd8..1261bbe2 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 f4915f76..e10b107a 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 a8dc2481..efebd4b9 100644 --- a/modules/trashbin/logic.ts +++ b/modules/trashbin/logic.ts @@ -38,13 +38,49 @@ export function targetMatchesCurrent( return current.artistUris.includes(targetUri); } -export function toggleEntry( - list: Record, - uri: string, - displayName: any = true, -): { 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]: displayName }, added: true }; + return { next: { ...list, [uri]: true }, added: true }; } const next = { ...list }; delete next[uri]; diff --git a/modules/trashbin/mod.tsx b/modules/trashbin/mod.tsx index 0df84e2e..41b77147 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 = ''; @@ -40,8 +59,15 @@ export default async function (ctx: ModuleRuntimeContext) { 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; @@ -102,16 +185,12 @@ export default async function (ctx: ModuleRuntimeContext) { }; const toggleCurrent = () => { - const item = client.player.data?.item; - if (!item?.uri) return; - - const trackName = item.name || item.metadata?.title || "Unknown Track"; - const artistName = item.metadata?.artist_name || ""; - const displayName = artistName ? `${artistName} - ${trackName}` : trackName; - - const { next, added } = toggleEntry(trashSongList, item.uri, displayName); + const uri = client.player.data?.item?.uri; + if (!uri) return; + const { next, added } = toggleEntry(trashSongList, uri); trashSongList = next; if (added) { + rememberName(uri); client.player.next(); client.notify("Song added to trashbin"); } else { @@ -152,22 +231,11 @@ export default async function (ctx: ModuleRuntimeContext) { const uri = uris[0]; const type = client.uri.fromString(uri).type; const isTrack = type === client.uri.Type.TRACK; - - const curItem = client.player.data?.item; - let displayName = isTrack ? "Track" : "Artist"; - - if (curItem && curItem.uri === uri) { - const trackName = curItem.name || curItem.metadata?.title || "Unknown Track"; - const artistName = curItem.metadata?.artist_name || ""; - displayName = isTrack ? (artistName ? `${artistName} - ${trackName}` : trackName) : artistName; - } else { - displayName = `${isTrack ? "Track" : "Artist"} (${uri.split(":").pop()})`; - } - - const { next, added } = toggleEntry(isTrack ? trashSongList : trashArtistList, uri, displayName); + const { next, added } = toggleEntry(isTrack ? trashSongList : trashArtistList, uri); 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 { @@ -200,32 +268,33 @@ export default async function (ctx: ModuleRuntimeContext) { function Settings() { const [enabled, setEnabled] = useState(trashbinStatus); const [widget, setWidget] = useState(enableWidget); - const [songs, setSongs] = useState(trashSongList); - const [searchQuery, setSearchQuery] = useState(""); + const [entries, setEntries] = useState(() => trashedEntries(trashSongList, trashArtistList)); + const [, rerender] = React.useReducer((n: number) => n + 1, 0); + const [query, setQuery] = useState(""); useEffect(() => { - const sync = () => setSongs(trashSongList); + const sync = () => { + setEntries(trashedEntries(trashSongList, trashArtistList)); + rerender(); + }; refreshers.add(sync); return () => { refreshers.delete(sync); }; }, []); - const filteredSongs = Object.entries(songs) - .reverse() - .filter(([uri, name]) => { - const displayName = typeof name === "string" ? name : uri; - return displayName.toLowerCase().includes(searchQuery.toLowerCase()); - }); + useEffect(() => { + void resolveNames(entries, rerender); + }, [entries]); + + const visible = matchingEntries(entries, (uri) => trashNames.get(uri), query); - const removeSong = (uri: string) => { - const next = { ...trashSongList }; - delete next[uri]; - trashSongList = next; - setSongs(next); + const remove = ({ uri, kind }: TrashedEntry) => { + if (kind === "song") trashSongList = toggleEntry(trashSongList, uri).next; + else trashArtistList = toggleEntry(trashArtistList, uri).next; putDataLocal(); refreshButtons(); - client.notify("Song removed from trashbin"); + client.notify(kind === "song" ? "Song removed from trashbin" : "Artist removed from trashbin"); }; return ( @@ -282,7 +351,6 @@ export default async function (ctx: ModuleRuntimeContext) { onClick={() => { trashSongList = {}; trashArtistList = {}; - setSongs({}); putDataLocal(); refreshButtons(); client.notify("Trashbin cleared!"); @@ -292,59 +360,37 @@ export default async function (ctx: ModuleRuntimeContext) { -
- {Object.keys(songs).length === 0 ? ( -
- Trashbin is empty + {entries.length === 0 ? ( + }>{null} + ) : ( + <> + +
+ {visible.length === 0 ? ( + }> + {null} + + ) : ( + visible.map((entry) => ( + remove(entry)} + /> + )) + )}
- ) : ( - <> - setSearchQuery(e.target.value)} - style={{ - width: "100%", - padding: "8px 12px", - marginBottom: "10px", - borderRadius: "4px", - border: "1px solid rgba(255,255,255,0.2)", - background: "rgba(255,255,255,0.05)", - color: "var(--spice-text, #fff)", - boxSizing: "border-box", - outline: "none", - }} - /> - -
5 ? "auto" : "hidden", - overflowX: "hidden", - scrollbarGutter: "stable", - paddingRight: "6px", - }} - > - {filteredSongs.length === 0 ? ( -
- No matching songs found -
- ) : ( - filteredSongs.map(([uri, name]) => ( - - - - )) - )} -
- - )} -
+ + )} ); } @@ -376,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!"); From 32bea4148a154a07a2f1ae150a5366fee886792f Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Sat, 10 Oct 2026 02:01:12 +0200 Subject: [PATCH 10/10] chore(trashbin): bump to 0.3.0 --- modules/trashbin/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/trashbin/metadata.json b/modules/trashbin/metadata.json index 06804015..299a2e7b 100644 --- a/modules/trashbin/metadata.json +++ b/modules/trashbin/metadata.json @@ -1,7 +1,7 @@ { "name": "trashbin", "kind": "extension", - "version": "0.2.7", + "version": "0.3.0", "authors": ["spicetify"], "description": "Throw songs and artists in the trashbin and never hear them again.", "entries": {