Skip to content

Commit 5594740

Browse files
committed
refactor(modules): make registrars own buttons
1 parent fa43a1e commit 5594740

20 files changed

Lines changed: 314 additions & 111 deletions

BEST_PRACTICES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ picking a register key. It gives you ordering (`order`) and native anchoring
6767
(`near: { anchor: "playbar:lyrics" }`) so your control sits where the user
6868
would expect rather than wherever insertion order happened to put it.
6969

70+
Every first-party button must still be owned by a registrar. A stateful button
71+
may use `registrar.register("playbarButton", <MyButton />)` instead of
72+
`placeButton`, but it must not instantiate the legacy `Spicetify.Playbar.Button`
73+
or `Spicetify.Topbar.Button` APIs and manually manage registration. Those
74+
compatibility surfaces exist for v2 extensions; the registrar is what
75+
guarantees v3 unload cleanup.
76+
7077
---
7178

7279
## Degrade, never destroy

docs/authoring-guide.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ Options:
100100
It returns a handle with `remove()`; the button is also removed automatically
101101
when the module unloads.
102102

103+
`onClick` receives the native React mouse event. Use `event.currentTarget` when
104+
an overlay needs to be positioned from the button's bounds; this avoids keeping
105+
a manually registered legacy button solely to obtain its DOM element.
106+
103107
**Native anchoring.** `near` sits the button next to one of the client's own
104108
buttons instead of in the module-button group:
105109

docs/module-standard.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ for you on unload.
168168
For top-bar and playbar buttons, prefer `registrar.placeButton(location, options)`
169169
over the raw button registers above: it adds ordering and native anchoring in one
170170
call. See the [authoring guide](./authoring-guide.md#4-buttons-use-placebutton).
171+
Use the raw registrar entry only when a React component owns changing state or
172+
needs a more specialized lifecycle. First-party modules must not use the legacy
173+
`Spicetify.Playbar.Button` or `Spicetify.Topbar.Button` constructors; stdlib
174+
retains them solely as a migration bridge for third-party v2 extensions.
171175

172176
Themes are a special case: a **css-only module** (a `color.ini` plus `user.css`
173177
as the css entry). The loader parses `[Section]`s into switchable schemes and

modules/loopy-loop/metadata.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "loopy-loop",
33
"kind": "extension",
4-
"version": "0.1.2",
4+
"version": "0.1.3",
55
"authors": ["spicetify"],
66
"description": "A-B repeat and section-skip markers on the playback bar; loops and skips persist per song.",
77
"entries": {
@@ -10,7 +10,7 @@
1010
},
1111
"hasMixins": false,
1212
"dependencies": {
13-
"stdlib": "^1.5.0"
13+
"stdlib": "^1.5.2"
1414
},
1515
"preview": "https://raw.githubusercontent.com/spicetify/modules/main/previews/loopy-loop.png"
1616
}

modules/loopy-loop/mod.tsx

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,16 @@
55
* Ported to the v3 module standard from the classic "Loopy loop" extension by
66
* khanhas. Right click on the progress bar to set song start/end markers and
77
* section skips; all points persist per song across sessions. The client's
8-
* v2-compatible Player events, LocalStorage, Playbar.Button and showNotification
9-
* helpers still work in v3, so the logic is kept near-verbatim; only the injected
10-
* <style> tag moved into index.scss and all teardown routes through ctx.defer.
8+
* v2-compatible Player events, LocalStorage and showNotification helpers still
9+
* work in v3, so the logic is kept near-verbatim. The playbar control mounts
10+
* through the registrar and all teardown routes through the module lifecycle.
1111
*/
1212

13-
import { client, type ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
13+
import { client, createRegistrar, type ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
1414
import { findActiveZone, moveEnd, moveStart, moveZoneEdge, parseStoredState, restartThresholds } from "./logic.ts";
1515

1616
import type { SkipZone } from "./logic.ts";
1717

18-
interface PlaybarButtonHandle {
19-
element: HTMLElement;
20-
deregister(): void;
21-
}
22-
2318
export default async function (ctx: ModuleRuntimeContext) {
2419
let disposed = false;
2520
const timers = new Set<number>();
@@ -595,17 +590,19 @@ export default async function (ctx: ModuleRuntimeContext) {
595590
tryLoadInitialState(10);
596591

597592
// Toolbar button
598-
let toolbarBtn: PlaybarButtonHandle | null = null;
599-
try {
600-
const markerIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" height="16" width="16"><rect x="1" y="7" width="14" height="2" rx="1"/><rect x="3" y="3" width="2" height="10" rx="1"/><rect x="11" y="3" width="2" height="10" rx="1"/><rect x="6" y="5" width="1.5" height="6" rx="0.75"/><rect x="8.5" y="5" width="1.5" height="6" rx="0.75"/></svg>`;
601-
toolbarBtn = new client.playbar.Button("Loopy Loop", markerIcon, (self) => {
593+
const registrar = createRegistrar(ctx);
594+
const markerIcon = `<rect x="1" y="7" width="14" height="2" rx="1"/><rect x="3" y="3" width="2" height="10" rx="1"/><rect x="11" y="3" width="2" height="10" rx="1"/><rect x="6" y="5" width="1.5" height="6" rx="0.75"/><rect x="8.5" y="5" width="1.5" height="6" rx="0.75"/>`;
595+
registrar.placeButton("playbar", {
596+
label: "Loopy Loop",
597+
icon: markerIcon,
598+
onClick: (event) => {
599+
event.stopPropagation();
602600
mouseOnBarPercent = client.player.getProgressPercent();
603601
setupActiveMarker(null, -1);
604-
const rect = self.element.getBoundingClientRect();
602+
const rect = event.currentTarget.getBoundingClientRect();
605603
openContextMenu(rect.left, rect.top);
606-
});
607-
toolbarBtn.element.addEventListener("click", (e: MouseEvent) => e.stopPropagation());
608-
} catch (_) {}
604+
},
605+
});
609606

610607
// ----- teardown -----
611608
cleanups.push(() => {
@@ -614,7 +611,6 @@ export default async function (ctx: ModuleRuntimeContext) {
614611
window.removeEventListener("click", onWindowClick);
615612
document.removeEventListener("contextmenu", onContextMenu, true);
616613
cancelMoveHide();
617-
toolbarBtn?.deregister?.();
618614
startMark.remove();
619615
endMark.remove();
620616
contextMenu.remove();

modules/lyrics-plus/metadata.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "lyrics-plus",
33
"kind": "app",
4-
"version": "0.1.7",
4+
"version": "0.1.8",
55
"authors": ["spicetify"],
66
"description": "Full-featured lyrics: synced, karaoke, unsynced and translated, from Musixmatch, Spotify, LRCLIB, Netease and Genius.",
77
"entries": {
@@ -10,7 +10,7 @@
1010
},
1111
"hasMixins": false,
1212
"dependencies": {
13-
"stdlib": "^1.0.0"
13+
"stdlib": "^1.5.2"
1414
},
1515
"preview": "https://raw.githubusercontent.com/spicetify/modules/main/previews/lyrics-plus.png"
1616
}

modules/lyrics-plus/mod.tsx

Lines changed: 37 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import { createRegistrar } from "/modules/stdlib/mod.ts";
1919
import type { ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
2020
import { NavLink } from "/modules/stdlib/src/registers/navlink.tsx";
21+
import { PlaybarButton } from "/modules/stdlib/src/registers/playbarButton.tsx";
2122

2223
import {
2324
APP_NAME,
@@ -37,6 +38,8 @@ import { createProviders } from "./providers/index.ts";
3738
import { AdjustmentsMenu, TranslationMenu } from "./options-menu.tsx";
3839
import { TopBarContent } from "./tab-bar.tsx";
3940
import { openConfig } from "./settings.tsx";
41+
import { lyricsReplacementReady, mountLyricsPlaybarStyleWhenReady, watchLyricsHistory } from "./playbar-lifecycle.ts";
42+
import type { LyricsHistory } from "./playbar-lifecycle.ts";
4043
import {
4144
emptyLine,
4245
GeniusPage,
@@ -1537,71 +1540,45 @@ class LyricsContainer extends react.Component {
15371540
}
15381541

15391542
// ============================================================================
1540-
// PlaybarButton.js (adapted) — classic IIFE turned into a ctx-scoped function
1543+
// PlaybarButton.js (adapted) — v3 registrar-owned React control
15411544
// ============================================================================
15421545

1543-
function initPlaybarButton(ctx: ModuleRuntimeContext) {
1544-
if (!Spicetify.Platform?.History) {
1545-
const retry = window.setTimeout(() => initPlaybarButton(ctx), 300);
1546-
ctx.defer(() => window.clearTimeout(retry));
1547-
return;
1548-
}
1546+
const PLAYBAR_ICON = `<path d="M13.426 2.574a2.831 2.831 0 0 0-4.797 1.55l3.247 3.247a2.831 2.831 0 0 0 1.55-4.797zM10.5 8.118l-2.619-2.62A63303.13 63303.13 0 0 0 4.74 9.075L2.065 12.12a1.287 1.287 0 0 0 1.816 1.816l3.06-2.688 3.56-3.129zM7.12 4.094a4.331 4.331 0 1 1 4.786 4.786l-3.974 3.493-3.06 2.689a2.787 2.787 0 0 1-3.933-3.933l2.676-3.045 3.505-3.99z"></path>`;
15491547

1550-
const button = new Spicetify.Playbar.Button(
1551-
"Lyrics Plus",
1552-
`<svg role="img" height="16" width="16" aria-hidden="true" viewBox="0 0 16 16" data-encore-id="icon" fill="currentColor"><path d="M13.426 2.574a2.831 2.831 0 0 0-4.797 1.55l3.247 3.247a2.831 2.831 0 0 0 1.55-4.797zM10.5 8.118l-2.619-2.62A63303.13 63303.13 0 0 0 4.74 9.075L2.065 12.12a1.287 1.287 0 0 0 1.816 1.816l3.06-2.688 3.56-3.129zM7.12 4.094a4.331 4.331 0 1 1 4.786 4.786l-3.974 3.493-3.06 2.689a2.787 2.787 0 0 1-3.933-3.933l2.676-3.045 3.505-3.99z"></path></svg>`,
1553-
() =>
1554-
Spicetify.Platform.History.location.pathname !== "/lyrics-plus"
1555-
? Spicetify.Platform.History.push("/lyrics-plus")
1556-
: Spicetify.Platform.History.goBack(),
1557-
false,
1558-
Spicetify.Platform.History.location.pathname === "/lyrics-plus",
1559-
false,
1548+
function LyricsPlusPlaybarButton() {
1549+
const [history, setHistory] = react.useState<LyricsHistory | null>(null);
1550+
const [visible, setVisible] = react.useState(
1551+
Spicetify.LocalStorage.get("lyrics-plus:visual:playbar-button") === "true",
15601552
);
1553+
const [active, setActive] = react.useState(false);
15611554

1562-
const style = document.createElement("style");
1563-
style.innerHTML = `
1564-
.main-nowPlayingBar-lyricsButton[data-testid="lyrics-button"] {
1565-
display: none !important;
1566-
}
1567-
li[data-id="/lyrics-plus"] {
1568-
display: none;
1569-
}
1570-
`;
1571-
style.classList.add("lyrics-plus:visual:playbar-button");
1572-
1573-
let registered = false;
1574-
const setPlaybarButton = () => {
1575-
if (registered) return;
1576-
document.head.appendChild(style);
1577-
button.register();
1578-
registered = true;
1579-
};
1580-
const removePlaybarButton = () => {
1581-
if (!registered) return;
1582-
style.remove();
1583-
button.deregister();
1584-
registered = false;
1585-
};
1586-
1587-
if (Spicetify.LocalStorage.get("lyrics-plus:visual:playbar-button") === "true") setPlaybarButton();
1588-
1589-
const onToggle = (event: any) => {
1590-
if (event.detail?.name === "playbar-button") {
1591-
if (event.detail.value) setPlaybarButton();
1592-
else removePlaybarButton();
1593-
}
1594-
};
1595-
window.addEventListener("lyrics-plus", onToggle);
1596-
1597-
const unlisten = Spicetify.Platform.History.listen((location: any) => {
1598-
button.active = location.pathname === "/lyrics-plus";
1599-
});
1555+
react.useEffect(
1556+
() =>
1557+
watchLyricsHistory(
1558+
() => Spicetify.Platform?.History,
1559+
setHistory,
1560+
(pathname) => setActive(pathname === ROUTE),
1561+
),
1562+
[],
1563+
);
16001564

1601-
ctx.defer(() => {
1602-
removePlaybarButton();
1603-
window.removeEventListener("lyrics-plus", onToggle);
1604-
if (typeof unlisten === "function") unlisten();
1565+
react.useEffect(() => {
1566+
const onToggle = (event: any) => {
1567+
if (event.detail?.name === "playbar-button") setVisible(Boolean(event.detail.value));
1568+
};
1569+
window.addEventListener("lyrics-plus", onToggle);
1570+
return () => window.removeEventListener("lyrics-plus", onToggle);
1571+
}, []);
1572+
1573+
const ready = lyricsReplacementReady(visible, history);
1574+
react.useEffect(() => mountLyricsPlaybarStyleWhenReady(document, ROUTE, visible, history), [visible, history]);
1575+
1576+
if (!ready) return null;
1577+
return react.createElement(PlaybarButton, {
1578+
label: "Lyrics Plus",
1579+
icon: PLAYBAR_ICON,
1580+
isActive: active,
1581+
onClick: () => (history.location.pathname !== ROUTE ? history.push(ROUTE) : history.goBack()),
16051582
});
16061583
}
16071584

@@ -1616,5 +1593,5 @@ export default function (ctx: ModuleRuntimeContext) {
16161593
react.createElement(NavLink, { localizedApp: "Lyrics", appRoutePath: ROUTE, icon: ICON, activeIcon: ICON }),
16171594
);
16181595
registrar.registerRoute(ROUTE, react.createElement(LyricsContainer));
1619-
initPlaybarButton(ctx);
1596+
registrar.register("playbarButton", react.createElement(LyricsPlusPlaybarButton));
16201597
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright (C) 2026 spicetify
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
6+
import "../stdlib/lib/test-setup.mts";
7+
8+
import assert from "node:assert/strict";
9+
import { describe, it } from "node:test";
10+
11+
import {
12+
lyricsReplacementReady,
13+
mountLyricsPlaybarStyle,
14+
mountLyricsPlaybarStyleWhenReady,
15+
watchLyricsHistory,
16+
type LyricsHistory,
17+
} from "./playbar-lifecycle.ts";
18+
19+
describe("watchLyricsHistory", () => {
20+
it("waits for late History, follows locations, and removes its listener", () => {
21+
let history: LyricsHistory | undefined;
22+
let retry: (() => void) | undefined;
23+
let unlistened = false;
24+
let listener: ((location: { pathname: string }) => void) | undefined;
25+
const paths: string[] = [];
26+
const ready: LyricsHistory[] = [];
27+
const cancelCalls: unknown[] = [];
28+
29+
const dispose = watchLyricsHistory(
30+
() => history,
31+
(value) => ready.push(value),
32+
(pathname) => paths.push(pathname),
33+
(callback) => {
34+
retry = callback;
35+
return 1 as unknown as ReturnType<typeof setTimeout>;
36+
},
37+
(timer) => cancelCalls.push(timer),
38+
);
39+
assert.equal(ready.length, 0);
40+
41+
history = {
42+
location: { pathname: "/" },
43+
listen(callback) {
44+
listener = callback;
45+
return () => {
46+
unlistened = true;
47+
};
48+
},
49+
push() {},
50+
goBack() {},
51+
};
52+
retry?.();
53+
listener?.({ pathname: "/lyrics-plus" });
54+
55+
assert.deepEqual(ready, [history]);
56+
assert.deepEqual(paths, ["/", "/lyrics-plus"]);
57+
dispose();
58+
assert.equal(unlistened, true);
59+
assert.deepEqual(cancelCalls, [1]);
60+
});
61+
62+
it("stops polling after the bounded attempt budget", () => {
63+
const retries: Array<() => void> = [];
64+
watchLyricsHistory(
65+
() => undefined,
66+
() => assert.fail("History never becomes ready"),
67+
() => assert.fail("no location can arrive"),
68+
(callback) => {
69+
retries.push(callback);
70+
return retries.length as unknown as ReturnType<typeof setTimeout>;
71+
},
72+
() => {},
73+
3,
74+
);
75+
for (const retry of retries) retry();
76+
assert.equal(retries.length, 2);
77+
});
78+
});
79+
80+
describe("mountLyricsPlaybarStyle", () => {
81+
it("stays absent until the replacement button can render", () => {
82+
assert.equal(lyricsReplacementReady(true, null), false);
83+
mountLyricsPlaybarStyleWhenReady(document, "/lyrics-plus", true, null);
84+
assert.equal(document.querySelector("style.lyrics-plus\\:visual\\:playbar-button"), null);
85+
});
86+
87+
it("conditionally adopts and cleans up the replacement style once ready", () => {
88+
const history = { location: { pathname: "/" }, listen() {}, push() {}, goBack() {} };
89+
const dispose = mountLyricsPlaybarStyleWhenReady(document, "/lyrics-plus", true, history);
90+
assert.ok(document.querySelector("style.lyrics-plus\\:visual\\:playbar-button"));
91+
if (typeof dispose === "function") dispose();
92+
assert.equal(document.querySelector("style.lyrics-plus\\:visual\\:playbar-button"), null);
93+
});
94+
95+
it("removes the adopted style during cleanup", () => {
96+
const dispose = mountLyricsPlaybarStyle(document, "/lyrics-plus");
97+
const style = document.querySelector("style.lyrics-plus\\:visual\\:playbar-button");
98+
assert.ok(style?.textContent?.includes('li[data-id="/lyrics-plus"]'));
99+
dispose();
100+
assert.equal(document.contains(style), false);
101+
});
102+
});

0 commit comments

Comments
 (0)