Repository navigation
feat(trashbin): add trashed songs list with live search #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cebc379
c997b07
93f4759
c3287db
606c137
221013e
85ed32b
5b3f212
a236b39
32bea41
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -38,10 +38,47 @@ export function targetMatchesCurrent( | |||||||||
| return current.artistUris.includes(targetUri); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export function toggleEntry( | ||||||||||
| list: Record<string, boolean>, | ||||||||||
| uri: string, | ||||||||||
| ): { next: Record<string, boolean>; added: boolean } { | ||||||||||
| export type TrashList = Record<string, boolean>; | ||||||||||
| 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)); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Search the URI even when an entry has a cached name. When Proposed fix--- "a/modules/trashbin/logic.ts"
+++ "b/modules/trashbin/logic.ts"
@@ -75,7 +75,9 @@
): TrashedEntry[] {
const needle = query.trim().toLowerCase();
if (!needle) return entries;
- return entries.filter(({ uri }) => (labelOf(uri) ?? uri).toLowerCase().includes(needle));
+ return entries.filter(({ uri }) =>
+ uri.toLowerCase().includes(needle) || (labelOf(uri)?.toLowerCase().includes(needle) ?? false),
+ );
}
export function toggleEntry(list: TrashList, uri: string): { next: TrashList; added: boolean } {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| } | ||||||||||
|
|
||||||||||
| export function toggleEntry(list: TrashList, uri: string): { next: TrashList; added: boolean } { | ||||||||||
| if (!list[uri]) { | ||||||||||
| return { next: { ...list, [uri]: true }, added: true }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = | ||
| '<path d="M9.083.583a2.08 2.08 0 0 1 1.474.61 2.08 2.08 0 0 1 .61 1.473v.583h2.583a.75.75 0 0 1 0 1.5h-.583v8.582a2.09 2.09 0 0 1-1.286 1.927 2.1 2.1 0 0 1-.798.158H4.417v-.001a2.1 2.1 0 0 1-1.474-.61 2.08 2.08 0 0 1-.61-1.474V4.75H1.75a.75.75 0 1 1 0-1.5h2.583v-.583A2.085 2.085 0 0 1 5.619.741c.253-.104.524-.157.798-.157V.583zm-5.25 12.751a.583.583 0 0 0 .583.583h6.668a.58.58 0 0 0 .583-.583V4.75H3.833zm1.834-2.001v-4a.75.75 0 0 1 1.5 0v4a.75.75 0 0 1-1.5 0m2.666 0v-4a.75.75 0 1 1 1.5 0v4a.75.75 0 0 1-1.5 0m-1.917-9.25a.583.583 0 0 0-.583.583v.584h3.834v-.584a.583.583 0 0 0-.583-.583z"/>'; | ||
|
|
@@ -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<string, boolean> = initValue("TrashSongList", {}); | ||
| let trashArtistList: Record<string, boolean> = 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<string, string>( | ||
| Object.entries(initValue<Record<string, unknown>>("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<string | null> => { | ||
| 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 ( | ||
| <SettingsSection title="Trashbin"> | ||
|
|
@@ -246,6 +359,38 @@ export default async function (ctx: ModuleRuntimeContext) { | |
| Clear | ||
| </Button> | ||
| </SettingsRow> | ||
|
|
||
| {entries.length === 0 ? ( | ||
| <SettingsRow label={<SettingsLabel label="The trashbin is empty" />}>{null}</SettingsRow> | ||
| ) : ( | ||
| <> | ||
| <SettingsTextInputRow | ||
| label="Search the trashbin" | ||
| description={`${entries.length} trashed ${entries.length === 1 ? "item" : "items"}`} | ||
| value={query} | ||
| placeholder="Song or artist" | ||
| ariaLabel="Search the trashbin" | ||
| onInput={setQuery} | ||
| /> | ||
| <div className="trashbin-list"> | ||
| {visible.length === 0 ? ( | ||
| <SettingsRow label={<SettingsLabel label="Nothing in the trashbin matches" />}> | ||
| {null} | ||
| </SettingsRow> | ||
| ) : ( | ||
| visible.map((entry) => ( | ||
| <SettingsButtonRow | ||
| key={entry.uri} | ||
| label={trashNames.get(entry.uri) ?? entry.uri} | ||
| description={entry.kind === "song" ? "Song" : "Artist"} | ||
| buttonLabel="Remove" | ||
| onClick={() => remove(entry)} | ||
| /> | ||
| )) | ||
| )} | ||
| </div> | ||
| </> | ||
| )} | ||
| </SettingsSection> | ||
| ); | ||
| } | ||
|
|
@@ -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); | ||
|
Comment on lines
+425
to
+426
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '55,145p' modules/trashbin/mod.tsx
sed -n '265,305p' modules/trashbin/mod.tsx
sed -n '405,440p' modules/trashbin/mod.tsxRepository: spicetify/modules Length of output: 6543 🏁 Script executed: rg -n -F --glob 'modules/trashbin/mod.tsx' -- 'function normalizeList' modules/trashbin/mod.tsx
rg -n -F --glob 'modules/trashbin/mod.tsx' -- 'TrashbinNames' modules/trashbin/mod.tsx
rg -n -F --glob 'modules/trashbin/mod.tsx' -- 'putDataLocal' modules/trashbin/mod.tsx
rg -n -F --glob 'modules/trashbin/mod.tsx' -- 'export' modules/trashbin/mod.tsx
rg -n -F --glob 'modules/trashbin/mod.tsx' -- 'importItems' modules/trashbin/mod.tsx
rg -n -F --glob 'modules/trashbin/mod.tsx' -- 'toggleEntry' modules/trashbin/mod.tsx
sed -n '1,60p' modules/trashbin/mod.tsx
sed -n '145,275p' modules/trashbin/mod.tsx
sed -n '275,410p' modules/trashbin/mod.tsx
rg -n -F --glob '*.{ts,tsx,md,json}' -- 'TrashbinNames' .
rg -n -F --glob '*.{ts,tsx,md,json}' -- 'File Import Successful' .Repository: spicetify/modules Length of output: 12166 🏁 Script executed: # The prior inspection request is pending; no additional independent capability is needed.
trueRepository: spicetify/modules Length of output: 155 🏁 Script executed: sed -n '1,260p' modules/trashbin/logic.ts
rg -n -F --glob 'modules/trashbin/logic.ts' -- 'normalizeList' modules/trashbin/logic.ts
rg -n -F --glob 'modules/trashbin/logic.ts' -- 'trashedEntries' modules/trashbin/logic.tsRepository: spicetify/modules Length of output: 3115 Invalidate pending name lookups when importing a replacement list. If an imported file moves a URI from Clear the cache and invalidate pending lookups when the import replaces both lists. Suggested fix const trashNames = new Map<string, string>(
Object.entries(initValue<Record<string, unknown>>("TrashbinNames", {})).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
+ let nameLookupGeneration = 0;
let trashbinStatus: boolean = initValue("trashbin-enabled", true);
@@
const resolveNames = async (entries: TrashedEntry[], onName?: () => void) => {
+ const generation = nameLookupGeneration;
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;
+ if (
+ generation !== nameLookupGeneration ||
+ !name ||
+ (!trashSongList[entry.uri] && !trashArtistList[entry.uri])
+ )
+ continue;
trashNames.set(entry.uri, name);
@@
try {
const data = JSON.parse(ev.target?.result as string);
+ nameLookupGeneration++;
+ trashNames.clear();
trashSongList = normalizeList(data?.songs);🤖 Prompt for AI Agents |
||
| putDataLocal(); | ||
| refreshButtons(); | ||
| client.notify("File Import Successful!"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reserve the scrollbar gutter before overflow.
When the list exceeds 360px,
overflow-y: autocan reduce the available row width on systems with non-overlay scrollbars. Addscrollbar-gutter: stableto prevent that layout shift.Proposed change
📝 Committable suggestion
🤖 Prompt for AI Agents