@spicetify/kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/spicetify-kit.js +4 -0
- package/dist/build.js +335 -0
- package/dist/check.js +284 -0
- package/dist/classmap.js +187 -0
- package/dist/cli.js +53 -0
- package/dist/create.js +429 -0
- package/dist/dev.js +100 -0
- package/dist/from-theme.js +96 -0
- package/dist/install.js +60 -0
- package/dist/launch.js +122 -0
- package/dist/pack.js +31 -0
- package/dist/push.js +211 -0
- package/dist/vault.js +108 -0
- package/package.json +49 -0
- package/vendor/classmaps/1020096/classmap-19f856aefd5.json +109 -0
- package/vendor/shims/chunks.d.ts +10 -0
- package/vendor/shims/remote-modules.d.ts +6 -0
- package/vendor/shims/spicetify.d.ts +2437 -0
- package/vendor/stdlib/deps.ts +24 -0
- package/vendor/stdlib/index.ts +24 -0
- package/vendor/stdlib/lib/modal.tsx +128 -0
- package/vendor/stdlib/lib/popover.ts +84 -0
- package/vendor/stdlib/lib/primitives-classes.ts +66 -0
- package/vendor/stdlib/lib/primitives-vanilla.ts +277 -0
- package/vendor/stdlib/lib/primitives.tsx +314 -0
- package/vendor/stdlib/load.ts +17 -0
- package/vendor/stdlib/metadata.json +18 -0
- package/vendor/stdlib/mixin.ts +32 -0
- package/vendor/stdlib/mod.ts +26 -0
- package/vendor/stdlib/src/chunks.d.ts +10 -0
- package/vendor/stdlib/src/client.ts +114 -0
- package/vendor/stdlib/src/createIconComponent.tsx +29 -0
- package/vendor/stdlib/src/events.mix.ts +55 -0
- package/vendor/stdlib/src/events.ts +58 -0
- package/vendor/stdlib/src/expose/GraphQL.ts +40 -0
- package/vendor/stdlib/src/expose/Platform.ts +48 -0
- package/vendor/stdlib/src/expose/React.ts +95 -0
- package/vendor/stdlib/src/expose/ReactFlipToolkitSpring.ts +27 -0
- package/vendor/stdlib/src/expose/ReduxStore.ts +39 -0
- package/vendor/stdlib/src/expose/SettingsSection.ts +60 -0
- package/vendor/stdlib/src/expose/Snackbar.ts +34 -0
- package/vendor/stdlib/src/expose/Tippy.ts +26 -0
- package/vendor/stdlib/src/expose/enqueueImageSnackbar.ts +25 -0
- package/vendor/stdlib/src/expose/index.ts +14 -0
- package/vendor/stdlib/src/expose/jsx-runtime.ts +46 -0
- package/vendor/stdlib/src/expose/react-dom-shim.ts +50 -0
- package/vendor/stdlib/src/expose/react-shim.ts +116 -0
- package/vendor/stdlib/src/logger.ts +35 -0
- package/vendor/stdlib/src/playbar-compat.tsx +136 -0
- package/vendor/stdlib/src/registers/index.ts +176 -0
- package/vendor/stdlib/src/registers/menu.ts +160 -0
- package/vendor/stdlib/src/registers/mount.ts +307 -0
- package/vendor/stdlib/src/registers/nativeAnchors.ts +49 -0
- package/vendor/stdlib/src/registers/navlink.tsx +165 -0
- package/vendor/stdlib/src/registers/order.ts +22 -0
- package/vendor/stdlib/src/registers/panel.ts +168 -0
- package/vendor/stdlib/src/registers/playbarButton.tsx +93 -0
- package/vendor/stdlib/src/registers/playbarWidget.tsx +71 -0
- package/vendor/stdlib/src/registers/registry.ts +12 -0
- package/vendor/stdlib/src/registers/root.ts +101 -0
- package/vendor/stdlib/src/registers/route.ts +123 -0
- package/vendor/stdlib/src/registers/settingsRow.tsx +56 -0
- package/vendor/stdlib/src/registers/settingsSection.ts +59 -0
- package/vendor/stdlib/src/registers/topbarLeftButton.tsx +86 -0
- package/vendor/stdlib/src/registers/topbarRightButton.tsx +87 -0
- package/vendor/stdlib/src/storage.ts +163 -0
- package/vendor/stdlib/src/util.ts +62 -0
- package/vendor/stdlib/src/utils/index.ts +16 -0
- package/vendor/stdlib/src/webpack/ClassNames.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/ClassNames.ts +6 -0
- package/vendor/stdlib/src/webpack/ClassNames.xpui.ts +16 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.ts +6 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.xpui.ts +18 -0
- package/vendor/stdlib/src/webpack/FilterContext.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/FilterContext.ts +6 -0
- package/vendor/stdlib/src/webpack/FilterContext.xpui.ts +10 -0
- package/vendor/stdlib/src/webpack/Mousetrap.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/Mousetrap.ts +6 -0
- package/vendor/stdlib/src/webpack/Mousetrap.xpui.ts +12 -0
- package/vendor/stdlib/src/webpack/React.gen.ts +14 -0
- package/vendor/stdlib/src/webpack/React.ts +6 -0
- package/vendor/stdlib/src/webpack/React.xpui.ts +15 -0
- package/vendor/stdlib/src/webpack/ReactComponents.desktop.ts +17 -0
- package/vendor/stdlib/src/webpack/ReactComponents.gen.ts +74 -0
- package/vendor/stdlib/src/webpack/ReactComponents.panel.ts +19 -0
- package/vendor/stdlib/src/webpack/ReactComponents.ts +50 -0
- package/vendor/stdlib/src/webpack/ReactComponents.xpui.ts +164 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.xpui.ts +14 -0
- package/vendor/stdlib/src/webpack/ReactHooks.fullscreen.ts +15 -0
- package/vendor/stdlib/src/webpack/ReactHooks.gen.ts +32 -0
- package/vendor/stdlib/src/webpack/ReactHooks.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactHooks.xpui.ts +38 -0
- package/vendor/stdlib/src/webpack/ReactQuery.gen.ts +26 -0
- package/vendor/stdlib/src/webpack/ReactQuery.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactQuery.xpui.ts +46 -0
- package/vendor/stdlib/src/webpack/ReactRouter.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/ReactRouter.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactRouter.xpui.ts +22 -0
- package/vendor/stdlib/src/webpack/Snackbar.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/Snackbar.ts +6 -0
- package/vendor/stdlib/src/webpack/Snackbar.xpui.ts +27 -0
- package/vendor/stdlib/src/webpack/URI.gen.ts +26 -0
- package/vendor/stdlib/src/webpack/URI.ts +82 -0
- package/vendor/stdlib/src/webpack/URI.xpui.ts +111 -0
- package/vendor/stdlib/src/webpack/capture-readiness.ts +49 -0
- package/vendor/stdlib/src/webpack/index.ts +186 -0
- package/vendor/stdlib/src/webpack/misc.gen.ts +16 -0
- package/vendor/stdlib/src/webpack/misc.ts +6 -0
- package/vendor/stdlib/src/webpack/misc.xpui.ts +27 -0
- package/vendor/stdlib/src/wpunpk.mix.ts +104 -0
- package/vendor/stdlib/src/wpunpk.ts +36 -0
- package/vendor/stdlib/vendor/rxjs.d.ts +5 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// v3 registers mount without source transforms: instead of patching xpui to
|
|
7
|
+
// call __renderX(), a registry renders into a DOM anchor. The exposed React
|
|
8
|
+
// forwards to the client's own instance, so client components, stdlib
|
|
9
|
+
// chrome, and module components all share one hook dispatcher.
|
|
10
|
+
|
|
11
|
+
import { React, ReactDOM } from "../expose/React.ts";
|
|
12
|
+
import { warn } from "../logger.ts";
|
|
13
|
+
import { byOrder } from "./order.ts";
|
|
14
|
+
import type { Registry } from "./registry.ts";
|
|
15
|
+
|
|
16
|
+
export interface AnchorSpec {
|
|
17
|
+
className: string;
|
|
18
|
+
registry: Registry<any>;
|
|
19
|
+
setRefresh: (cb: (() => void) | undefined) => void;
|
|
20
|
+
// Position for the anchor; null means the client has not rendered the
|
|
21
|
+
// slot yet and the mount should wait for it.
|
|
22
|
+
findSlot: () => { parent: Element; before?: Node | null } | null;
|
|
23
|
+
// Custom rendering over the registry items (e.g. route matching);
|
|
24
|
+
// defaults to rendering every item.
|
|
25
|
+
renderItems?: (items: any[]) => unknown;
|
|
26
|
+
// The anchor host's display. Defaults to "contents" so the host is
|
|
27
|
+
// layout-transparent and its items participate directly in the client
|
|
28
|
+
// slot's layout. Set to a box value (e.g. "flex") when the items need
|
|
29
|
+
// their own layout context instead of inheriting the slot's — so the
|
|
30
|
+
// anchor counts as a single item in the slot and spaces its own children.
|
|
31
|
+
hostDisplay?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// One broken registered node must not take down the others — and a crash
|
|
35
|
+
// must be visible, never a silent blank (a session's worth of “Spotify looks
|
|
36
|
+
// broken” reports traced back to exactly that). The fallback uses host
|
|
37
|
+
// elements only: when the crash IS a broken component surface, any component
|
|
38
|
+
// rendered here would die the same way. Route-overlay items get a full card;
|
|
39
|
+
// bar and menu items get a compact marker that keeps their layout.
|
|
40
|
+
export const createItemBoundary = (R: any, label: string) => {
|
|
41
|
+
const isPage = label.includes("route");
|
|
42
|
+
const describe = (error: unknown) => {
|
|
43
|
+
// Never throws: this runs inside getDerivedStateFromError, and a throw
|
|
44
|
+
// here unmounts the whole anchor root — the exact blank this boundary
|
|
45
|
+
// exists to prevent. Exotic errors (null-prototype throws, throwing
|
|
46
|
+
// toString/getters) get the fixed string instead.
|
|
47
|
+
try {
|
|
48
|
+
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
49
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
50
|
+
} catch {
|
|
51
|
+
return "(unprintable error)";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
return class ItemBoundary extends R.Component {
|
|
55
|
+
state = { failed: false, message: "" };
|
|
56
|
+
static getDerivedStateFromError(error: unknown) {
|
|
57
|
+
return { failed: true, message: describe(error) };
|
|
58
|
+
}
|
|
59
|
+
componentDidCatch(error: unknown) {
|
|
60
|
+
console.error(`[stdlib] registered ${label} item crashed:`, error);
|
|
61
|
+
}
|
|
62
|
+
render() {
|
|
63
|
+
const { failed, message } = this.state as { failed: boolean; message: string };
|
|
64
|
+
if (!failed) return (this.props as { children?: unknown }).children;
|
|
65
|
+
if (!isPage) {
|
|
66
|
+
return R.createElement(
|
|
67
|
+
"span",
|
|
68
|
+
{
|
|
69
|
+
title: `A Spicetify module item crashed here: ${message}`,
|
|
70
|
+
style: { opacity: 0.6, padding: "0 4px", cursor: "help" },
|
|
71
|
+
},
|
|
72
|
+
"⚠",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return R.createElement(
|
|
76
|
+
"div",
|
|
77
|
+
{
|
|
78
|
+
style: {
|
|
79
|
+
maxWidth: "560px",
|
|
80
|
+
margin: "48px auto",
|
|
81
|
+
padding: "24px",
|
|
82
|
+
borderRadius: "8px",
|
|
83
|
+
background: "var(--spice-card, #282828)",
|
|
84
|
+
color: "var(--spice-text, #fff)",
|
|
85
|
+
fontSize: "14px",
|
|
86
|
+
lineHeight: 1.5,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
R.createElement("h2", { style: { margin: "0 0 8px", fontSize: "18px" } }, "This module page crashed"),
|
|
90
|
+
R.createElement(
|
|
91
|
+
"p",
|
|
92
|
+
{ style: { margin: "0 0 12px", opacity: 0.8 } },
|
|
93
|
+
"The rest of the client keeps working. Reloading usually recovers it; if it keeps crashing, disable the module from the Spicetify Manager.",
|
|
94
|
+
),
|
|
95
|
+
R.createElement(
|
|
96
|
+
"code",
|
|
97
|
+
{
|
|
98
|
+
style: {
|
|
99
|
+
display: "block",
|
|
100
|
+
padding: "8px",
|
|
101
|
+
background: "rgba(0,0,0,.3)",
|
|
102
|
+
borderRadius: "4px",
|
|
103
|
+
marginBottom: "16px",
|
|
104
|
+
userSelect: "text",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
message,
|
|
108
|
+
),
|
|
109
|
+
R.createElement(
|
|
110
|
+
"button",
|
|
111
|
+
{
|
|
112
|
+
onClick: () => location.reload(),
|
|
113
|
+
style: {
|
|
114
|
+
padding: "8px 16px",
|
|
115
|
+
borderRadius: "500px",
|
|
116
|
+
border: "none",
|
|
117
|
+
cursor: "pointer",
|
|
118
|
+
background: "var(--spice-button, #1ed760)",
|
|
119
|
+
color: "var(--spice-main, #121212)",
|
|
120
|
+
fontWeight: 700,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
"Reload Spotify",
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// The top bar doubles as the window drag region; like the client's own
|
|
131
|
+
// buttons, anchor children must opt out or physical clicks drag the window
|
|
132
|
+
// instead of reaching them (synthetic and CDP clicks bypass this, so only
|
|
133
|
+
// real mice notice).
|
|
134
|
+
const ensureAnchorStyle = () => {
|
|
135
|
+
if (document.getElementById("spicetify-anchor-style")) return;
|
|
136
|
+
const style = document.createElement("style");
|
|
137
|
+
style.id = "spicetify-anchor-style";
|
|
138
|
+
style.textContent = "[data-spicetify-anchor] > * { -webkit-app-region: no-drag; app-region: no-drag; }";
|
|
139
|
+
document.head.appendChild(style);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// One shared observer serves every anchor: the client mutates the DOM
|
|
143
|
+
// constantly, and per-anchor subtree observers multiply that cost. Each
|
|
144
|
+
// tick is one isConnected check per pending or placed anchor.
|
|
145
|
+
type WatchedAnchor = { host: HTMLElement; place: () => boolean; started: boolean; start: () => void };
|
|
146
|
+
const watched: WatchedAnchor[] = [];
|
|
147
|
+
let bodyObserver: MutationObserver | undefined;
|
|
148
|
+
const watchAnchor = (anchor: WatchedAnchor) => {
|
|
149
|
+
watched.push(anchor);
|
|
150
|
+
bodyObserver ??= (() => {
|
|
151
|
+
const observer = new MutationObserver(() => {
|
|
152
|
+
for (const a of watched) {
|
|
153
|
+
if (a.host.isConnected) continue;
|
|
154
|
+
if (a.place() && !a.started) {
|
|
155
|
+
a.started = true;
|
|
156
|
+
a.start();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
161
|
+
return observer;
|
|
162
|
+
})();
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export function mountRegistryAnchor(spec: AnchorSpec): void {
|
|
166
|
+
// Transform experiments render through the injected __renderX() calls;
|
|
167
|
+
// an anchor would double-render.
|
|
168
|
+
if ((globalThis as never as Record<string, unknown>).__SPICETIFY_APPLY_TRANSFORMS__) return;
|
|
169
|
+
if (typeof document === "undefined") return;
|
|
170
|
+
|
|
171
|
+
void CHUNKS.xpui.promise.then(() => {
|
|
172
|
+
const R = React as any;
|
|
173
|
+
const createRoot = (ReactDOM as any).createRoot;
|
|
174
|
+
if (typeof createRoot !== "function") {
|
|
175
|
+
warn("[stdlib] cannot mount register anchor (no createRoot):", spec.className);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const host = document.createElement("span");
|
|
180
|
+
host.className = spec.className;
|
|
181
|
+
host.style.display = spec.hostDisplay ?? "contents";
|
|
182
|
+
host.dataset.spicetifyAnchor = "";
|
|
183
|
+
ensureAnchorStyle();
|
|
184
|
+
|
|
185
|
+
const place = () => {
|
|
186
|
+
const slot = spec.findSlot();
|
|
187
|
+
if (!slot) return false;
|
|
188
|
+
slot.parent.insertBefore(host, slot.before ?? null);
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const start = () => {
|
|
193
|
+
const root = createRoot(host);
|
|
194
|
+
const ItemBoundary = createItemBoundary(R, spec.className);
|
|
195
|
+
const Wrapper = () => {
|
|
196
|
+
const [, refresh] = R.useReducer((n: number) => n + 1, 0);
|
|
197
|
+
R.useEffect(() => {
|
|
198
|
+
spec.setRefresh(refresh);
|
|
199
|
+
return () => spec.setRefresh(undefined);
|
|
200
|
+
}, []);
|
|
201
|
+
if (spec.renderItems) {
|
|
202
|
+
return R.createElement(ItemBoundary, null, spec.renderItems(spec.registry.all()));
|
|
203
|
+
}
|
|
204
|
+
return R.createElement(
|
|
205
|
+
R.Fragment,
|
|
206
|
+
null,
|
|
207
|
+
...byOrder(spec.registry.all()).map((item: unknown, i: number) =>
|
|
208
|
+
R.createElement(ItemBoundary, { key: i }, item),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
root.render(R.createElement(Wrapper));
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const anchor: WatchedAnchor = { host, place, started: false, start };
|
|
216
|
+
if (place()) {
|
|
217
|
+
anchor.started = true;
|
|
218
|
+
start();
|
|
219
|
+
}
|
|
220
|
+
// The shared observer both waits for a not-yet-rendered slot and
|
|
221
|
+
// re-places the host whenever client reconciliation drops it; the
|
|
222
|
+
// root stays attached to the node, so rendering survives.
|
|
223
|
+
watchAnchor(anchor);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Mount a single element next to a specific client element (rather than into a
|
|
228
|
+
// fixed slot). Used by placeButton's `near` anchoring: the host is inserted
|
|
229
|
+
// before/after the resolved target and re-placed on client re-renders. If the
|
|
230
|
+
// target never appears within giveUpMs, onGiveUp() runs so the caller can fall
|
|
231
|
+
// back to ordinary placement — the button is never silently lost.
|
|
232
|
+
export interface AdjacentSpec {
|
|
233
|
+
className: string;
|
|
234
|
+
element: React.ReactNode;
|
|
235
|
+
findTarget: () => Element | null;
|
|
236
|
+
side: "before" | "after";
|
|
237
|
+
giveUpMs: number;
|
|
238
|
+
onGiveUp: () => void;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function mountAdjacent(spec: AdjacentSpec): { remove: () => void } {
|
|
242
|
+
let removed = false;
|
|
243
|
+
let root: { render?: (node: unknown) => void; unmount?: () => void } | undefined;
|
|
244
|
+
const host = document.createElement("span");
|
|
245
|
+
host.className = spec.className;
|
|
246
|
+
host.style.display = "contents";
|
|
247
|
+
host.dataset.spicetifyAnchor = "";
|
|
248
|
+
|
|
249
|
+
const place = (): boolean => {
|
|
250
|
+
const target = spec.findTarget();
|
|
251
|
+
if (!target?.parentElement) return false;
|
|
252
|
+
const before = spec.side === "before" ? target : target.nextSibling;
|
|
253
|
+
target.parentElement.insertBefore(host, before);
|
|
254
|
+
return true;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const remove = () => {
|
|
258
|
+
removed = true;
|
|
259
|
+
root?.unmount?.();
|
|
260
|
+
host.remove();
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Transform experiments render through injected __renderX() calls, and there
|
|
264
|
+
// is no DOM off the client; in both cases fall back to ordinary placement.
|
|
265
|
+
if (
|
|
266
|
+
(globalThis as never as Record<string, unknown>).__SPICETIFY_APPLY_TRANSFORMS__ ||
|
|
267
|
+
typeof document === "undefined"
|
|
268
|
+
) {
|
|
269
|
+
spec.onGiveUp();
|
|
270
|
+
return { remove: () => {} };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
void CHUNKS.xpui.promise.then(() => {
|
|
274
|
+
if (removed) return;
|
|
275
|
+
const R = React as any;
|
|
276
|
+
const createRoot = (ReactDOM as any).createRoot;
|
|
277
|
+
if (typeof createRoot !== "function") {
|
|
278
|
+
spec.onGiveUp();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const start = () => {
|
|
282
|
+
ensureAnchorStyle();
|
|
283
|
+
root = createRoot(host);
|
|
284
|
+
const ItemBoundary = createItemBoundary(R, spec.className);
|
|
285
|
+
root!.render?.(R.createElement(ItemBoundary, null, spec.element));
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const deadline = Date.now() + spec.giveUpMs;
|
|
289
|
+
const attempt = () => {
|
|
290
|
+
if (removed) return;
|
|
291
|
+
if (place()) {
|
|
292
|
+
start();
|
|
293
|
+
// Reuse the shared observer for re-placement only (already started).
|
|
294
|
+
watchAnchor({ host, place, started: true, start: () => {} });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (Date.now() > deadline) {
|
|
298
|
+
spec.onGiveUp();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
setTimeout(attempt, 150);
|
|
302
|
+
};
|
|
303
|
+
attempt();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return { remove };
|
|
307
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Semantic names for the client's own buttons, owned and maintained here so
|
|
7
|
+
// modules never hard-code fragile selectors. A module anchors to a stable
|
|
8
|
+
// name (e.g. "playbar:queue"); stdlib maps it to the current client's
|
|
9
|
+
// selectors. When Spotify moves a button, only this map changes — modules keep
|
|
10
|
+
// working, and placeButton falls back to order-placement if a name can't be
|
|
11
|
+
// resolved (so a stale map degrades, it never hides the button).
|
|
12
|
+
//
|
|
13
|
+
// Only locale-independent selectors are offered. The playbar controls carry
|
|
14
|
+
// stable data-testid attributes; the top-bar actions only expose localized
|
|
15
|
+
// aria-labels, so no top-bar anchors are published here.
|
|
16
|
+
|
|
17
|
+
export type NativeAnchor =
|
|
18
|
+
| "playbar:lyrics"
|
|
19
|
+
| "playbar:queue"
|
|
20
|
+
| "playbar:mute"
|
|
21
|
+
| "playbar:miniplayer"
|
|
22
|
+
| "playbar:fullscreen";
|
|
23
|
+
|
|
24
|
+
// Candidate selectors per anchor, newest first; the first match in the DOM
|
|
25
|
+
// wins. Add older-client fallbacks to the array rather than branching.
|
|
26
|
+
export const NATIVE_ANCHOR_SELECTORS: Record<NativeAnchor, string[]> = {
|
|
27
|
+
"playbar:lyrics": ['[data-testid="lyrics-button"]'],
|
|
28
|
+
"playbar:queue": ['[data-testid="control-button-queue"]'],
|
|
29
|
+
"playbar:mute": ['[data-testid="volume-bar-toggle-mute-button"]'],
|
|
30
|
+
"playbar:miniplayer": ['[data-testid="pip-toggle-button"]'],
|
|
31
|
+
"playbar:fullscreen": ['[data-testid="fullscreen-mode-button"]'],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const isNativeAnchor = (name: string): name is NativeAnchor => Object.hasOwn(NATIVE_ANCHOR_SELECTORS, name);
|
|
35
|
+
|
|
36
|
+
// Resolve an anchor name to the live client element, or null if none of its
|
|
37
|
+
// selectors match (unknown name, or the client no longer renders it).
|
|
38
|
+
export const resolveNativeAnchor = (
|
|
39
|
+
name: NativeAnchor,
|
|
40
|
+
root: Pick<Document, "querySelector"> = document,
|
|
41
|
+
): Element | null => {
|
|
42
|
+
const selectors = NATIVE_ANCHOR_SELECTORS[name];
|
|
43
|
+
if (!selectors) return null;
|
|
44
|
+
for (const selector of selectors) {
|
|
45
|
+
const el = root.querySelector(selector);
|
|
46
|
+
if (el) return el;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2024 Delusoire
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { React } from "../expose/React.ts";
|
|
7
|
+
import { createIconComponent } from "../createIconComponent.tsx";
|
|
8
|
+
import { transformer } from "../../mixin.ts";
|
|
9
|
+
import { Platform } from "../expose/Platform.ts";
|
|
10
|
+
import { warn } from "../logger.ts";
|
|
11
|
+
import { classnames } from "../webpack/ClassNames.ts";
|
|
12
|
+
import { ScrollableContainer, Tooltip } from "../webpack/ReactComponents.ts";
|
|
13
|
+
import { UI } from "../webpack/ComponentLibrary.ts";
|
|
14
|
+
import { mountRegistryAnchor } from "./mount.ts";
|
|
15
|
+
import { Registry } from "./registry.ts";
|
|
16
|
+
|
|
17
|
+
const registry = new (class extends Registry<React.ReactNode> {
|
|
18
|
+
override add(value: React.ReactNode): this {
|
|
19
|
+
refresh?.();
|
|
20
|
+
return super.add(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override delete(value: React.ReactNode): boolean {
|
|
24
|
+
refresh?.();
|
|
25
|
+
return super.delete(value);
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
export default registry;
|
|
29
|
+
|
|
30
|
+
let refresh: React.DispatchWithoutAction | undefined;
|
|
31
|
+
|
|
32
|
+
declare global {
|
|
33
|
+
var __renderNavLinks: () => React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
globalThis.__renderNavLinks = () =>
|
|
37
|
+
React.createElement(() => {
|
|
38
|
+
[, refresh] = React.useReducer((n) => n + 1, 0);
|
|
39
|
+
|
|
40
|
+
if (!ScrollableContainer) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<ScrollableContainer className="custom-navlinks-scrollable_container" onlyHorizontalWheel>
|
|
46
|
+
{registry.all()}
|
|
47
|
+
</ScrollableContainer>
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
transformer(
|
|
51
|
+
(emit) => (str) => {
|
|
52
|
+
emit();
|
|
53
|
+
|
|
54
|
+
str = str.replace(
|
|
55
|
+
/{(?=[^{}]*(?:{[^{}]*(?:{[^{}]*(?:{[^{}]*}[^{}]*)*}[^{}]*)*}[^{}]*)*(?<=[,{])"data-testid":"global-nav-bar")([^{}]*(?:{[^{}]*(?:{[^{}]*(?:{[^{}]*}[^{}]*)*}[^{}]*)*}[^{}]*)*(?<=[,{]))children:([^{}]*(?:{[^{}]*(?:{[^{}]*(?:{[^{}]*}[^{}]*)*}[^{}]*)*}[^{}]*)*)(,[^{}]*(?:{[^{}]*(?:{[^{}]*(?:{[^{}]*}[^{}]*)*}[^{}]*)*}[^{}]*)*|)}/,
|
|
56
|
+
"{$1children:(function(children){const p=children[0].props;p.children=[p.children,__renderNavLinks()].flat();return children})($2)$3}",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return str;
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
glob: /^\/xpui\.js/,
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
transformer(
|
|
66
|
+
(emit) => (str) => {
|
|
67
|
+
emit();
|
|
68
|
+
|
|
69
|
+
str = str.replace('["","/","/home/",', '["","/","/home/","/bespoke/*",');
|
|
70
|
+
|
|
71
|
+
return str;
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
glob: /^\/dwp\-top\-bar\.js/,
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
mountRegistryAnchor({
|
|
79
|
+
className: "spicetify-navlinks-anchor",
|
|
80
|
+
registry,
|
|
81
|
+
setRefresh: (cb) => {
|
|
82
|
+
refresh = cb;
|
|
83
|
+
},
|
|
84
|
+
findSlot: () => {
|
|
85
|
+
const wrapper = document.querySelector(".main-globalNav-historyButtonsWrapper");
|
|
86
|
+
return wrapper ? { parent: wrapper } : null;
|
|
87
|
+
},
|
|
88
|
+
// A real flex box, not the default display:contents. The history-buttons
|
|
89
|
+
// wrapper spaces its children with a wide gap meant to separate button
|
|
90
|
+
// groups; as one box the anchor takes that gap once (separating our links
|
|
91
|
+
// from the back/forward group), while the nav links inside space
|
|
92
|
+
// themselves with the client's own 8px navlink margins.
|
|
93
|
+
hostDisplay: "flex",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Registered elements are frozen in the registry, so parent refreshes
|
|
97
|
+
// bail out on identical children; any registered component that renders
|
|
98
|
+
// route-dependent state must subscribe to history itself.
|
|
99
|
+
export const useHistoryRefresh = (): void => {
|
|
100
|
+
const [, force] = React.useReducer((n: number) => n + 1, 0);
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
try {
|
|
103
|
+
return Platform.getHistory().listen(() => force()) as (() => void) | undefined;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
warn("[stdlib] cannot follow history:", e);
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}, []);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type NavLinkProps = {
|
|
112
|
+
localizedApp: string;
|
|
113
|
+
appRoutePath: string;
|
|
114
|
+
/** Inner SVG markup drawn on the stdlib 16-grid (upscaled to the client's 24px nav glyph). */
|
|
115
|
+
icon: string;
|
|
116
|
+
/** Filled variant shown while the route is active; same 16-grid contract. */
|
|
117
|
+
activeIcon: string;
|
|
118
|
+
};
|
|
119
|
+
export const NavLink: React.FC<NavLinkProps> = (props) => {
|
|
120
|
+
useHistoryRefresh();
|
|
121
|
+
const isActive = Platform.getHistory().location.pathname?.startsWith(props.appRoutePath);
|
|
122
|
+
const createIcon = () =>
|
|
123
|
+
// Icons are authored on the stdlib 16-grid; upscale to the client's
|
|
124
|
+
// 24px nav glyph size so they fill the circle like Home's.
|
|
125
|
+
createIconComponent({ icon: isActive ? props.activeIcon : props.icon, iconSize: 16, realIconSize: 24 });
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<_NavLinkGlobal
|
|
129
|
+
localizedApp={props.localizedApp}
|
|
130
|
+
appRoutePath={props.appRoutePath}
|
|
131
|
+
createIcon={createIcon}
|
|
132
|
+
isActive={isActive}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
interface NavLinkFactoryProps {
|
|
138
|
+
localizedApp: string;
|
|
139
|
+
appRoutePath: string;
|
|
140
|
+
createIcon: () => React.ReactNode;
|
|
141
|
+
isActive: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const _NavLinkGlobal: React.FC<NavLinkFactoryProps> = (props) => {
|
|
145
|
+
return (
|
|
146
|
+
<div className="inline-flex">
|
|
147
|
+
<Tooltip label={props.localizedApp}>
|
|
148
|
+
<UI.ButtonTertiary
|
|
149
|
+
iconOnly={props.createIcon}
|
|
150
|
+
className={classnames(
|
|
151
|
+
// The circular chrome the client paints on its own nav links
|
|
152
|
+
// (Home); without it the button renders bare on the bar.
|
|
153
|
+
"main-globalNav-navLink",
|
|
154
|
+
"main-globalNav-link-icon link-subtle",
|
|
155
|
+
{
|
|
156
|
+
"main-globalNav-navLinkActive": props.isActive,
|
|
157
|
+
},
|
|
158
|
+
)}
|
|
159
|
+
aria-label={props.localizedApp}
|
|
160
|
+
onClick={() => Platform.getHistory().push(props.appRoutePath)}
|
|
161
|
+
/>
|
|
162
|
+
</Tooltip>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Pure ordering used by the register mount render. Kept free of any client
|
|
7
|
+
// (React/DOM) imports so it can be unit-tested in Node.
|
|
8
|
+
|
|
9
|
+
interface Orderable {
|
|
10
|
+
props?: { order?: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Stable ascending sort by an optional numeric `order` prop carried on a
|
|
14
|
+
// registered element (lower renders earlier). Items without one are treated
|
|
15
|
+
// as order 0 and keep their registration order; equal orders fall back to it
|
|
16
|
+
// too. This backs registrar.placeButton(..., { order }); plain register()
|
|
17
|
+
// items are all order-0 and so keep insertion order, unchanged.
|
|
18
|
+
export const byOrder = <T extends Orderable>(items: T[]): T[] =>
|
|
19
|
+
items
|
|
20
|
+
.map((item, i) => [item, i] as const)
|
|
21
|
+
.sort(([a, ai], [b, bi]) => (a?.props?.order ?? 0) - (b?.props?.order ?? 0) || ai - bi)
|
|
22
|
+
.map(([item]) => item);
|