@spicetify/kit 0.1.0 → 0.3.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.
@@ -10,6 +10,7 @@
10
10
  // modules can use it without touching React.
11
11
 
12
12
  import { React, ReactDOM } from "../src/expose/React.ts";
13
+ import { calculateFloatingPosition } from "./floating-position.ts";
13
14
 
14
15
  export interface PopoverHandle {
15
16
  close: () => void;
@@ -26,40 +27,40 @@ export interface PopoverOptions {
26
27
  export function openPopover({ anchor, content, onClose, gap = 8 }: PopoverOptions): PopoverHandle {
27
28
  const host = document.createElement("div");
28
29
  host.className = "spicetify-popover";
29
- Object.assign(host.style, {
30
- position: "fixed",
31
- zIndex: "1000",
32
- background: "var(--spice-card, var(--background-elevated-base, #282828))",
33
- color: "var(--spice-text, var(--text-base, #fff))",
34
- borderRadius: "8px",
35
- boxShadow: "0 16px 24px rgba(0, 0, 0, 0.3), 0 6px 8px rgba(0, 0, 0, 0.2)",
36
- maxHeight: "80vh",
37
- overflow: "auto",
38
- });
30
+ host.style.visibility = "hidden";
39
31
 
40
32
  const place = () => {
41
- const rect = anchor.getBoundingClientRect();
42
- host.style.top = `${Math.round(Math.min(rect.bottom + gap, window.innerHeight - 64))}px`;
43
- const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - host.offsetWidth - 8));
44
- host.style.left = `${Math.round(left)}px`;
33
+ if (!anchor.isConnected || !host.isConnected) return;
34
+ const position = calculateFloatingPosition(
35
+ anchor.getBoundingClientRect(),
36
+ host.getBoundingClientRect(),
37
+ { width: window.innerWidth, height: window.innerHeight },
38
+ { align: "start", gap },
39
+ );
40
+ host.style.left = `${position.left}px`;
41
+ host.style.top = `${position.top}px`;
42
+ host.style.visibility = "visible";
43
+ host.dataset.placement = position.placement;
45
44
  };
46
45
 
47
- let root: { unmount: () => void } | undefined;
46
+ let root: ReturnType<typeof ReactDOM.createRoot> | undefined;
48
47
  if (content instanceof HTMLElement) {
49
48
  host.appendChild(content);
50
49
  } else {
51
- const createRoot = (ReactDOM as any).createRoot;
52
- root = createRoot(host);
53
- (root as any).render(React.createElement(React.Fragment, null, content as any));
50
+ root = ReactDOM.createRoot(host);
51
+ root.render(React.createElement(React.Fragment, null, content));
54
52
  }
53
+ const resizeObserver = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(place);
55
54
 
56
55
  let closed = false;
57
56
  const close = () => {
58
57
  if (closed) return;
59
58
  closed = true;
60
59
  window.removeEventListener("resize", place);
60
+ document.removeEventListener("scroll", place, true);
61
61
  document.removeEventListener("mousedown", onDocDown, true);
62
62
  document.removeEventListener("keydown", onKey, true);
63
+ resizeObserver?.disconnect();
63
64
  root?.unmount();
64
65
  host.remove();
65
66
  onClose?.();
@@ -69,7 +70,10 @@ export function openPopover({ anchor, content, onClose, gap = 8 }: PopoverOption
69
70
  if (!host.contains(target) && !anchor.contains(target)) close();
70
71
  };
71
72
  const onKey = (e: KeyboardEvent) => {
72
- if (e.key === "Escape") close();
73
+ if (e.key !== "Escape") return;
74
+ e.preventDefault();
75
+ e.stopPropagation();
76
+ close();
73
77
  };
74
78
 
75
79
  document.body.appendChild(host);
@@ -77,8 +81,11 @@ export function openPopover({ anchor, content, onClose, gap = 8 }: PopoverOption
77
81
  // A second pass once layout has produced the real width, for clamping.
78
82
  requestAnimationFrame(place);
79
83
  window.addEventListener("resize", place);
84
+ document.addEventListener("scroll", place, true);
80
85
  document.addEventListener("mousedown", onDocDown, true);
81
86
  document.addEventListener("keydown", onKey, true);
87
+ resizeObserver?.observe(anchor);
88
+ resizeObserver?.observe(host);
82
89
 
83
90
  return { close, host };
84
91
  }
@@ -33,6 +33,7 @@ export const SEARCHBAR_CLASS = "spicetify-searchbar";
33
33
  export const CARD_CLASS = "spicetify-card";
34
34
  export const SCRIM_CLASS = "spicetify-scrim";
35
35
  export const DIALOG_CLASS = "spicetify-dialog";
36
+ export const DIALOG_LARGE_CLASS = "spicetify-dialog--large";
36
37
  export const DIALOG_HEADER_CLASS = "spicetify-dialog-header";
37
38
  export const DIALOG_BODY_CLASS = "spicetify-dialog-body";
38
39
 
@@ -43,6 +44,17 @@ export const SETTINGS_SECTION_CLASS = "x-settings-section";
43
44
  export const SETTINGS_ROW_CLASS = "x-settings-row";
44
45
  export const SETTINGS_ROW_LABEL_CLASS = "x-settings-firstColumn";
45
46
  export const SETTINGS_ROW_CONTROL_CLASS = "x-settings-secondColumn";
47
+ export const SETTINGS_ACTION_GROUP_CLASS = "spicetify-settings-actions";
48
+ export const SETTINGS_LABEL_COPY_CLASS = "spicetify-settings-label-copy";
49
+ export const SETTINGS_HEADER_CONTAINER_CLASS = "x-settings-headerContainer";
50
+ export const SETTINGS_HEADER_CLASS =
51
+ "encore-text encore-text-title-medium encore-internal-color-text-base x-settings-header";
52
+ export const SETTINGS_SECTION_HEADING_CLASS =
53
+ "encore-text encore-text-body-medium-bold encore-internal-color-text-base spicetify-settings-section-heading";
54
+ export const SETTINGS_SECTION_SUBHEADING_CLASS =
55
+ "encore-text encore-text-body-medium-bold encore-internal-color-text-base spicetify-settings-section-subheading";
56
+ export const SETTINGS_ROW_TEXT_CLASS = "encore-text encore-text-body-small encore-internal-color-text-subdued";
57
+ export const SETTINGS_HELP_TEXT_CLASS = "encore-text encore-text-marginal encore-internal-color-text-subdued";
46
58
  export const TOGGLE_CLASSES = {
47
59
  wrapper: "x-toggle-wrapper",
48
60
  input: "x-toggle-input",
@@ -27,11 +27,13 @@ import {
27
27
  DIALOG_BODY_CLASS,
28
28
  DIALOG_CLASS,
29
29
  DIALOG_HEADER_CLASS,
30
+ DIALOG_LARGE_CLASS,
30
31
  ICON_BUTTON_CLASS,
31
32
  SCRIM_CLASS,
32
33
  SEARCHBAR_CLASS,
33
34
  SELECT_CLASS,
34
35
  } from "./primitives-classes.ts";
36
+ import { activateDialog } from "./dialog-lifecycle.ts";
35
37
 
36
38
  export type { BadgeTone, ButtonVariant } from "./primitives-classes.ts";
37
39
 
@@ -246,22 +248,43 @@ export interface DialogHandle {
246
248
  // backdrop, close button, and programmatic close() all run one teardown,
247
249
  // so onClose fires exactly once on any dismissal path (consumers use it
248
250
  // to drop their own bookkeeping, e.g. a dispose registry).
249
- export function openDialog(props: { title: string; children: Child; onClose?: () => void }): DialogHandle {
251
+ let nextDialogTitleId = 0;
252
+
253
+ export function openDialog(props: {
254
+ title: string;
255
+ children: Child;
256
+ size?: "normal" | "large";
257
+ onClose?: () => void;
258
+ }): DialogHandle {
250
259
  const body = h("div", { className: DIALOG_BODY_CLASS }, props.children);
251
260
  let closed = false;
261
+ let deactivate = () => {};
252
262
  const close = () => {
253
263
  if (closed) return;
254
264
  closed = true;
265
+ deactivate();
255
266
  scrim.remove();
256
267
  props.onClose?.();
257
268
  };
269
+ const titleId = `spicetify-dialog-title-${++nextDialogTitleId}`;
258
270
  const header = h(
259
271
  "div",
260
272
  { className: DIALOG_HEADER_CLASS },
261
- h("h2", { textContent: props.title }),
273
+ h("h2", { id: titleId, textContent: props.title }),
262
274
  IconButton({ glyph: "×", ariaLabel: "Close", onClick: close }),
263
275
  );
264
- const dialog = h("div", { className: DIALOG_CLASS }, header, body);
276
+ const dialog = h(
277
+ "div",
278
+ {
279
+ className: props.size === "large" ? `${DIALOG_CLASS} ${DIALOG_LARGE_CLASS}` : DIALOG_CLASS,
280
+ tabIndex: -1,
281
+ role: "dialog",
282
+ "aria-modal": "true",
283
+ "aria-labelledby": titleId,
284
+ },
285
+ header,
286
+ body,
287
+ );
265
288
  const scrim = h(
266
289
  "div",
267
290
  {
@@ -273,5 +296,6 @@ export function openDialog(props: { title: string; children: Child; onClose?: ()
273
296
  dialog,
274
297
  );
275
298
  document.body.append(scrim);
299
+ deactivate = activateDialog(dialog, close);
276
300
  return { body, close };
277
301
  }
@@ -25,7 +25,12 @@ import {
25
25
  SETTINGS_ROW_CLASS,
26
26
  SETTINGS_ROW_CONTROL_CLASS,
27
27
  SETTINGS_ROW_LABEL_CLASS,
28
+ SETTINGS_ROW_TEXT_CLASS,
29
+ SETTINGS_ACTION_GROUP_CLASS,
30
+ SETTINGS_HELP_TEXT_CLASS,
31
+ SETTINGS_LABEL_COPY_CLASS,
28
32
  SETTINGS_SECTION_CLASS,
33
+ SETTINGS_SECTION_HEADING_CLASS,
29
34
  badgeClass,
30
35
  buttonClass,
31
36
  type BadgeTone,
@@ -34,6 +39,7 @@ import {
34
39
  chipClass,
35
40
  DIALOG_BODY_CLASS,
36
41
  DIALOG_CLASS,
42
+ DIALOG_LARGE_CLASS,
37
43
  DIALOG_HEADER_CLASS,
38
44
  ICON_BUTTON_CLASS,
39
45
  MENU_ITEM_CLASS,
@@ -41,8 +47,11 @@ import {
41
47
  SEARCHBAR_CLASS,
42
48
  SELECT_CLASS,
43
49
  } from "./primitives-classes.ts";
50
+ import { activateDialog } from "./dialog-lifecycle.ts";
51
+ import { Tooltip } from "./floating.tsx";
44
52
 
45
53
  export type { BadgeTone, ButtonVariant } from "./primitives-classes.ts";
54
+ export { Popover, PopoverMenu, PopoverMenuItem, Tooltip } from "./floating.tsx";
46
55
 
47
56
  // ---------- buttons ----------
48
57
 
@@ -75,15 +84,40 @@ export const IconButton: React.FC<{
75
84
  </button>
76
85
  );
77
86
 
87
+ // One shared ordering affordance for provider lists and other short ordered
88
+ // settings. Keeping the arrows here prevents each module from inventing a
89
+ // different size, icon, disabled state, or accessible label.
90
+ export const ReorderButtons: React.FC<{
91
+ label: string;
92
+ disableUp: boolean;
93
+ disableDown: boolean;
94
+ onMove: (direction: -1 | 1) => void;
95
+ }> = (props) => (
96
+ <span className={SETTINGS_ACTION_GROUP_CLASS}>
97
+ <IconButton ariaLabel={`Move ${props.label} up`} disabled={props.disableUp} onClick={() => props.onMove(-1)}>
98
+ ↑
99
+ </IconButton>
100
+ <IconButton ariaLabel={`Move ${props.label} down`} disabled={props.disableDown} onClick={() => props.onMove(1)}>
101
+ ↓
102
+ </IconButton>
103
+ </span>
104
+ );
105
+
78
106
  // ---------- inputs ----------
79
107
 
80
108
  export function Select<T extends string>(props: {
81
109
  options: ReadonlyArray<{ value: T; label: string }>;
82
110
  value: T;
83
111
  onChange: (value: T) => void;
112
+ ariaLabel?: string;
84
113
  }): React.ReactElement {
85
114
  return (
86
- <select className={SELECT_CLASS} value={props.value} onChange={(e) => props.onChange(e.target.value as T)}>
115
+ <select
116
+ className={SELECT_CLASS}
117
+ value={props.value}
118
+ aria-label={props.ariaLabel}
119
+ onChange={(e) => props.onChange(e.target.value as T)}
120
+ >
87
121
  {props.options.map((option) => (
88
122
  <option key={option.value} value={option.value}>
89
123
  {option.label}
@@ -97,7 +131,11 @@ export const TextInput: React.FC<{
97
131
  placeholder?: string;
98
132
  value?: string;
99
133
  disabled?: boolean;
134
+ readOnly?: boolean;
100
135
  onInput?: (value: string) => void;
136
+ onFocus?: React.FocusEventHandler<HTMLInputElement>;
137
+ onBlur?: React.FocusEventHandler<HTMLInputElement>;
138
+ ariaLabel?: string;
101
139
  }> = (props) => (
102
140
  <input
103
141
  type="text"
@@ -105,6 +143,10 @@ export const TextInput: React.FC<{
105
143
  placeholder={props.placeholder}
106
144
  value={props.value}
107
145
  disabled={props.disabled}
146
+ readOnly={props.readOnly}
147
+ aria-label={props.ariaLabel}
148
+ onFocus={props.onFocus}
149
+ onBlur={props.onBlur}
108
150
  onChange={(e) => props.onInput?.(e.target.value)}
109
151
  />
110
152
  );
@@ -136,13 +178,12 @@ export const Card: React.FC<{ children: React.ReactNode }> = (props) => (
136
178
 
137
179
  // ---------- settings ----------
138
180
 
139
- // Native-looking settings chrome for the `settingsSection` register. These
140
- // render the client's own section/row structure rather than capturing its
141
- // components: the settings route is a lazily loaded chunk, and the loader
142
- // does not source-transform those, so a capture there never resolves.
181
+ // Native-looking settings chrome for the `settingsSection` register on the
182
+ // standalone Spicetify Settings page. These render the client's own section
183
+ // and row structure rather than capturing its route-local components.
143
184
  export const SettingsSection: React.FC<{ title?: React.ReactNode; children: React.ReactNode }> = (props) => (
144
185
  <div className={SETTINGS_SECTION_CLASS}>
145
- {props.title === undefined ? null : <h2>{props.title}</h2>}
186
+ {props.title === undefined ? null : <h2 className={SETTINGS_SECTION_HEADING_CLASS}>{props.title}</h2>}
146
187
  {props.children}
147
188
  </div>
148
189
  );
@@ -155,18 +196,79 @@ export const SettingsRow: React.FC<{ label: React.ReactNode; htmlFor?: string; c
155
196
  ) => (
156
197
  <div className={SETTINGS_ROW_CLASS}>
157
198
  <div className={SETTINGS_ROW_LABEL_CLASS}>
158
- {props.htmlFor === undefined ? props.label : <label htmlFor={props.htmlFor}>{props.label}</label>}
199
+ {props.htmlFor === undefined ? (
200
+ <span className={SETTINGS_ROW_TEXT_CLASS}>{props.label}</span>
201
+ ) : (
202
+ <label className={SETTINGS_ROW_TEXT_CLASS} htmlFor={props.htmlFor}>
203
+ {props.label}
204
+ </label>
205
+ )}
159
206
  </div>
160
207
  <div className={SETTINGS_ROW_CONTROL_CLASS}>{props.children}</div>
161
208
  </div>
162
209
  );
163
210
 
211
+ export const SettingsLabel: React.FC<{ label: React.ReactNode; description?: React.ReactNode }> = (props) => (
212
+ <span className={SETTINGS_LABEL_COPY_CLASS}>
213
+ <span>{props.label}</span>
214
+ {props.description === undefined ? null : <span className={SETTINGS_HELP_TEXT_CLASS}>{props.description}</span>}
215
+ </span>
216
+ );
217
+
218
+ export const SettingsActions: React.FC<{ children: React.ReactNode }> = (props) => (
219
+ <span className={SETTINGS_ACTION_GROUP_CLASS}>{props.children}</span>
220
+ );
221
+
222
+ export const SettingsButtonRow: React.FC<{
223
+ label: React.ReactNode;
224
+ description?: React.ReactNode;
225
+ buttonLabel: React.ReactNode;
226
+ disabled?: boolean;
227
+ variant?: ButtonVariant;
228
+ onClick: () => void;
229
+ }> = (props) => (
230
+ <SettingsRow label={<SettingsLabel label={props.label} description={props.description} />}>
231
+ <Button variant={props.variant ?? "secondary"} disabled={props.disabled} onClick={props.onClick}>
232
+ {props.buttonLabel}
233
+ </Button>
234
+ </SettingsRow>
235
+ );
236
+
237
+ export const SettingsTextInputRow: React.FC<{
238
+ label: React.ReactNode;
239
+ description?: React.ReactNode;
240
+ value: string;
241
+ placeholder?: string;
242
+ ariaLabel?: string;
243
+ onInput: (value: string) => void;
244
+ actionLabel?: React.ReactNode;
245
+ actionDisabled?: boolean;
246
+ onAction?: () => void;
247
+ }> = (props) => (
248
+ <SettingsRow label={<SettingsLabel label={props.label} description={props.description} />}>
249
+ <SettingsActions>
250
+ <TextInput
251
+ value={props.value}
252
+ placeholder={props.placeholder}
253
+ ariaLabel={props.ariaLabel}
254
+ onInput={props.onInput}
255
+ />
256
+ {props.actionLabel === undefined || props.onAction === undefined ? null : (
257
+ <Button variant="secondary" disabled={props.actionDisabled} onClick={props.onAction}>
258
+ {props.actionLabel}
259
+ </Button>
260
+ )}
261
+ </SettingsActions>
262
+ </SettingsRow>
263
+ );
264
+
164
265
  // The client's own switch structure around a native checkbox. Its semantic
165
266
  // classes are recreated by the CLI css-map, so Spotify and themes style this
166
267
  // exactly like the adjacent settings controls.
167
268
  export const Toggle: React.FC<{
168
269
  id?: string;
169
270
  ariaLabel?: string;
271
+ disabled?: boolean;
170
272
  value: boolean;
171
273
  onChange: (value: boolean) => void;
172
274
  }> = (props) => (
@@ -177,6 +279,7 @@ export const Toggle: React.FC<{
177
279
  id={props.id}
178
280
  aria-label={props.ariaLabel}
179
281
  checked={props.value}
282
+ disabled={props.disabled}
180
283
  onKeyDown={activateToggleOnKeyDown}
181
284
  onChange={(e) => props.onChange((e.target as HTMLInputElement).checked)}
182
285
  />
@@ -186,6 +289,46 @@ export const Toggle: React.FC<{
186
289
  </label>
187
290
  );
188
291
 
292
+ export const SettingsProviderRow: React.FC<{
293
+ label: string;
294
+ description?: React.ReactNode;
295
+ value: boolean;
296
+ disabled?: boolean;
297
+ disabledReason?: string;
298
+ index: number;
299
+ total: number;
300
+ onChange: (value: boolean) => void;
301
+ onMove: (direction: -1 | 1) => void;
302
+ }> = (props) => {
303
+ const id = React.useId();
304
+ const toggle = (
305
+ <Toggle
306
+ id={id}
307
+ ariaLabel={`${props.label} provider`}
308
+ value={props.value}
309
+ disabled={props.disabled}
310
+ onChange={props.onChange}
311
+ />
312
+ );
313
+ return (
314
+ <SettingsRow label={<SettingsLabel label={props.label} description={props.description} />} htmlFor={id}>
315
+ <SettingsActions>
316
+ <ReorderButtons
317
+ label={props.label}
318
+ disableUp={props.index === 0}
319
+ disableDown={props.index === props.total - 1}
320
+ onMove={props.onMove}
321
+ />
322
+ {props.disabled && props.disabledReason ? (
323
+ <Tooltip label={props.disabledReason}>{toggle}</Tooltip>
324
+ ) : (
325
+ toggle
326
+ )}
327
+ </SettingsActions>
328
+ </SettingsRow>
329
+ );
330
+ };
331
+
189
332
  // The "module with one boolean setting" pattern as a single row for the
190
333
  // `settingsRow` register. `getValue` is read lazily on every mount so
191
334
  // reopening settings always reflects the current state; the element itself
@@ -275,34 +418,41 @@ export const ConfirmButton: React.FC<{
275
418
  // transformed ancestor (the norm for Spotify's scroll containers) would
276
419
  // otherwise be clipped/offset to that ancestor. The backdrop and the ×
277
420
  // button both call onClose.
278
- export const Dialog: React.FC<{ title: string; onClose: () => void; children: React.ReactNode }> = (props) => {
279
- const { onClose } = props;
280
- // Escape closes the dialog, matching every native modal. A capturing
281
- // document listener catches the key wherever focus sits (the client's own
282
- // components steal focus), and the client's shortcut handlers do not see it.
421
+ export const Dialog: React.FC<{
422
+ title: string;
423
+ onClose: () => void;
424
+ size?: "normal" | "large";
425
+ children: React.ReactNode;
426
+ }> = (props) => {
427
+ const dialogRef = React.useRef<HTMLDivElement>(null);
428
+ const onCloseRef = React.useRef(props.onClose);
429
+ const titleId = React.useId();
430
+ onCloseRef.current = props.onClose;
283
431
  React.useEffect(() => {
284
- const onKey = (e: KeyboardEvent) => {
285
- if (e.key === "Escape") {
286
- e.preventDefault();
287
- e.stopPropagation();
288
- onClose();
289
- }
290
- };
291
- document.addEventListener("keydown", onKey, true);
292
- return () => document.removeEventListener("keydown", onKey, true);
293
- }, [onClose]);
432
+ const dialog = dialogRef.current;
433
+ if (!dialog) return;
434
+ return activateDialog(dialog, () => onCloseRef.current());
435
+ }, []);
436
+ const dialogClass = props.size === "large" ? `${DIALOG_CLASS} ${DIALOG_LARGE_CLASS}` : DIALOG_CLASS;
294
437
 
295
438
  return ReactDOM.createPortal(
296
439
  <div
297
440
  className={SCRIM_CLASS}
298
441
  onClick={(e) => {
299
- if (e.target === e.currentTarget) onClose();
442
+ if (e.target === e.currentTarget) props.onClose();
300
443
  }}
301
444
  >
302
- <div className={DIALOG_CLASS} role="dialog" aria-modal="true" aria-label={props.title}>
445
+ <div
446
+ ref={dialogRef}
447
+ className={dialogClass}
448
+ role="dialog"
449
+ aria-modal="true"
450
+ aria-labelledby={titleId}
451
+ tabIndex={-1}
452
+ >
303
453
  <div className={DIALOG_HEADER_CLASS}>
304
- <h2>{props.title}</h2>
305
- <IconButton ariaLabel="Close" onClick={onClose}>
454
+ <h2 id={titleId}>{props.title}</h2>
455
+ <IconButton ariaLabel="Close" onClick={props.onClose}>
306
456
  ×
307
457
  </IconButton>
308
458
  </div>
@@ -3,7 +3,7 @@
3
3
  "kind": "lib",
4
4
  "hidden": true,
5
5
  "preview": "./assets/PREVIEW.png",
6
- "version": "1.5.0",
6
+ "version": "1.10.1",
7
7
  "compat": ["0.3.0"],
8
8
  "authors": ["spicetify", "Delusoire"],
9
9
  "description": "The standard library",
@@ -18,9 +18,12 @@ export * from "./lib/primitives-vanilla.ts";
18
18
  export * from "./src/storage.ts";
19
19
  export * from "./src/wpunpk.ts";
20
20
  export * from "./src/logger.ts";
21
+ export { display as displayModal, hide as hideModal, type ModalOptions } from "./lib/modal.tsx";
22
+ export { React, ReactDOM, ReactDOMServer } from "./src/expose/React.ts";
23
+ export { Platform } from "./src/expose/Platform.ts";
24
+ export { createIconComponent } from "./src/createIconComponent.tsx";
25
+ export { startCase } from "./deps.ts";
21
26
 
22
- // Public barrel: everything dependent modules are allowed to deep-import.
23
- // Import from /modules/stdlib/mod.js at runtime; nothing else is public.
24
- export * from "./src/webpack/misc.xpui.ts";
25
- export * from "./src/webpack/ComponentLibrary.xpui.ts";
26
- export * from "./src/webpack/ReactComponents.xpui.ts";
27
+ // Palette Manager needs the client's Color constructor and its companion
28
+ // type. Export that semantic capability without exposing the webpack module.
29
+ export { Color } from "./src/webpack/misc.xpui.ts";
@@ -13,10 +13,28 @@
13
13
  * modules.
14
14
  */
15
15
 
16
+ import type { ModalOptions } from "../lib/modal.tsx";
17
+
16
18
  type SpicetifyRuntime = typeof Spicetify;
17
19
 
18
20
  export type PlayerState = Spicetify.PlayerState;
19
21
 
22
+ export interface PopupModalCompatibility {
23
+ display(options: ModalOptions): void;
24
+ hide(): void;
25
+ }
26
+
27
+ export interface DaemonCapabilities {
28
+ available(): Promise<boolean>;
29
+ apply(): Promise<unknown>;
30
+ blockUpdates(): Promise<unknown>;
31
+ unblockUpdates(): Promise<unknown>;
32
+ }
33
+
34
+ export interface SnackbarCapabilities {
35
+ enqueueSnackbar(message: string, options?: { variant?: string }): unknown;
36
+ }
37
+
20
38
  export interface ClientCapabilities {
21
39
  readonly player: typeof Spicetify.Player;
22
40
  readonly platform: typeof Spicetify.Platform;
@@ -30,9 +48,14 @@ export interface ClientCapabilities {
30
48
  readonly keyboard: typeof Spicetify.Keyboard;
31
49
  readonly mousetrap: typeof Spicetify.Mousetrap;
32
50
  readonly contextMenu: typeof Spicetify.ContextMenu;
33
- readonly popupModal: typeof Spicetify.PopupModal;
51
+ /** @deprecated Import displayModal and hideModal from /modules/stdlib/mod.ts. */
52
+ readonly popupModal: PopupModalCompatibility;
34
53
  readonly config: typeof Spicetify.Config;
35
54
  readonly modules: typeof Spicetify.Modules;
55
+ /** V3 manifest version, with the legacy wrapper config as a fallback. */
56
+ readonly spicetifyVersion: string | undefined;
57
+ readonly daemon: DaemonCapabilities | undefined;
58
+ readonly snackbar: SnackbarCapabilities | undefined;
36
59
  readonly react: typeof Spicetify.React;
37
60
  readonly reactDOM: typeof Spicetify.ReactDOM;
38
61
  readonly tippy: typeof Spicetify.Tippy;
@@ -47,6 +70,26 @@ const runtime = (): SpicetifyRuntime => {
47
70
  return value;
48
71
  };
49
72
 
73
+ const nonEmptyVersion = (value: unknown): string | undefined =>
74
+ typeof value === "string" && value.trim() ? value : undefined;
75
+
76
+ let modalRequestGeneration = 0;
77
+ const reportModalFailure = (error: unknown) => console.error("[stdlib] failed to load the owned modal:", error);
78
+ const popupModalCompatibility: PopupModalCompatibility = {
79
+ display(options) {
80
+ const generation = ++modalRequestGeneration;
81
+ void import("../lib/modal.tsx")
82
+ .then(({ display }) => {
83
+ if (generation === modalRequestGeneration) display(options);
84
+ })
85
+ .catch(reportModalFailure);
86
+ },
87
+ hide() {
88
+ ++modalRequestGeneration;
89
+ void import("../lib/modal.tsx").then(({ hide }) => hide()).catch(reportModalFailure);
90
+ },
91
+ };
92
+
50
93
  export const client: ClientCapabilities = {
51
94
  get player() {
52
95
  return runtime().Player;
@@ -85,7 +128,7 @@ export const client: ClientCapabilities = {
85
128
  return runtime().ContextMenu;
86
129
  },
87
130
  get popupModal() {
88
- return runtime().PopupModal;
131
+ return popupModalCompatibility;
89
132
  },
90
133
  get config() {
91
134
  return runtime().Config;
@@ -93,6 +136,26 @@ export const client: ClientCapabilities = {
93
136
  get modules() {
94
137
  return runtime().Modules;
95
138
  },
139
+ get spicetifyVersion() {
140
+ const current = runtime() as unknown as {
141
+ Modules?: {
142
+ manifest?: { cliVersion?: unknown };
143
+ registry?: { manifest?: { cliVersion?: unknown } };
144
+ };
145
+ Config?: { version?: unknown };
146
+ };
147
+ return (
148
+ nonEmptyVersion(current.Modules?.manifest?.cliVersion) ??
149
+ nonEmptyVersion(current.Modules?.registry?.manifest?.cliVersion) ??
150
+ nonEmptyVersion(current.Config?.version)
151
+ );
152
+ },
153
+ get daemon() {
154
+ return (runtime() as unknown as { Daemon?: DaemonCapabilities }).Daemon;
155
+ },
156
+ get snackbar() {
157
+ return (runtime() as unknown as { Snackbar?: SnackbarCapabilities }).Snackbar;
158
+ },
96
159
  get react() {
97
160
  return runtime().React;
98
161
  },
@@ -17,7 +17,10 @@ function resolvePlatform(): any {
17
17
  return cached ?? undefined;
18
18
  }
19
19
 
20
- export const Platform: any = new Proxy(
20
+ // Typed as the generated Platform surface; the Proxy target is an empty
21
+ // shell whose members only exist at get time, so the assertion is the
22
+ // honest spelling.
23
+ export const Platform = new Proxy(
21
24
  {},
22
25
  {
23
26
  get: (_, key) => {
@@ -45,4 +48,4 @@ export const Platform: any = new Proxy(
45
48
  return false;
46
49
  },
47
50
  },
48
- );
51
+ ) as typeof Spicetify.Platform;