perfectionist-plugins 1.0.0 → 1.0.1

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.
@@ -1,640 +1,578 @@
1
- /*
2
- * hide-unowned-skins.js
3
- * Hide Unowned Skins (Perfectionism, Pengu Loader).
4
- *
5
- * In champ select, the skin carousel only shows the skins you own for the picked champion.
6
- * Unowned skins are stripped from the carousel data before the client renders it (socket
7
- * pushes and fetch/XHR responses), with a DOM safety net for anything that slips through.
8
- *
9
- * Requires perfectionist-utils.js.
10
- */
11
-
12
- export const version = "2.1.0";
13
-
14
- const PLUGIN_ID = "hide-unowned-skins";
15
- const STYLE_ID = "perfectionist-hide-unowned-skins-style";
16
- const HIDDEN_CLASS = "perf-unowned-skin-hidden";
17
- const SKIN_ITEM_SELECTOR = ".skin-selection-item";
18
- const DOT_SELECTOR = ".skin-selection-indicator-selector";
19
- const SESSION_URI = "/lol-champ-select/v1/session";
20
- const SELECTION_URI = "/lol-champ-select/v1/session/my-selection";
21
- const CAROUSEL_URI = "/lol-champ-select/v1/skin-carousel-skins";
22
- const CURRENT_SUMMONER_URI = "/lol-summoner/v1/current-summoner";
23
- const SUMMONER_TTL = 10 * 60 * 1000;
24
- const INVENTORY_TTL = 60 * 1000;
25
- const SESSION_POLL_MS = 1000;
26
- const RETRY_MS = 5000;
27
- const STATE_KEY = "perfectionist-plugins:state";
28
-
29
- const CSS = `
30
- .${HIDDEN_CLASS} { display: none !important; margin: 0 !important; padding: 0 !important; border: none !important; }
31
- /* Visible dots are centred, so the hidden ones leave no gap in the row. */
32
- [data-perf-dots] { justify-content: center !important; }
33
- `;
34
-
35
- const OBSERVER_OPTIONS = {
36
- childList: true,
37
- subtree: true,
38
- attributes: true,
39
- attributeFilter: ["class", "style"],
40
- };
41
-
42
- /* ---- source filter: unowned skins never reach the client's carousel ---- */
43
-
44
- let filterActive = true;
45
- let rawFetch = null; // the unfiltered fetch, kept for the plugin's own reads of the carousel order
46
- let clientSkinOrder = []; // skin ids the client actually received, in order
47
-
48
- /** Saved plugin state decides the filter at boot; onEnable / onDisable update it afterwards. */
49
- function readDefaultEnabled() {
50
- try {
51
- const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
52
- return saved[PLUGIN_ID] !== false;
53
- } catch {
54
- return true;
55
- }
56
- }
57
-
58
- /** A skin is kept when owned; the base skin always is. Unknown ownership is left to the DOM net. */
59
- function isOwnedSkin(skin) {
60
- if (!skin || typeof skin !== "object") return true;
61
- const id = typeof skin.id === "number" ? skin.id : null;
62
- if (id === null) return true;
63
- if (id % 1000 === 0) return true;
64
- const ownership = skin.ownership;
65
- if (ownership && typeof ownership.owned === "boolean") return ownership.owned;
66
- return true;
67
- }
68
-
69
- /** Returns the owned-only list, or null when nothing was removed. */
70
- function filterSkinList(list) {
71
- if (!Array.isArray(list)) return null;
72
- const kept = list.filter(isOwnedSkin);
73
- return kept.length === list.length ? null : kept;
74
- }
75
-
76
- /** Remembers the list the client received (in order) so the DOM net can map the page dots correctly. */
77
- function recordClientOrder(list) {
78
- if (Array.isArray(list)) {
79
- clientSkinOrder = list.map((skin) => (skin && typeof skin.id === "number" ? skin.id : null)).filter((id) => id !== null);
80
- }
81
- }
82
-
83
- /** Rewrites a CoreSocket frame ([eventId, topic, { uri, eventType, data }]) for the carousel push; null when not ours. */
84
- function rewriteWebSocketFrame(text) {
85
- if (!filterActive || typeof text !== "string") return null;
86
- if (text.indexOf("skin-carousel-skins") === -1) return null;
87
- let frame;
88
- try {
89
- frame = JSON.parse(text);
90
- } catch {
91
- return null;
92
- }
93
- if (!Array.isArray(frame) || frame.length < 3) return null;
94
- const payload = frame[2];
95
- if (!payload || typeof payload !== "object" || typeof payload.uri !== "string") return null;
96
- if (payload.uri.split("?")[0] !== CAROUSEL_URI) return null;
97
- const kept = filterSkinList(payload.data);
98
- recordClientOrder(kept ?? payload.data);
99
- if (!kept) return null;
100
- return JSON.stringify([frame[0], frame[1], Object.assign({}, payload, { data: kept })]);
101
- }
102
-
103
- /** Wraps a WebSocket "message" listener so it only ever sees owned skins. */
104
- function wrapMessageListener(listener) {
105
- if (typeof listener !== "function" || listener.__perfSkinWrap) return listener;
106
- const wrapped = function (event) {
107
- if (event && typeof event.data === "string") {
108
- const rewritten = rewriteWebSocketFrame(event.data);
109
- if (rewritten !== null) {
110
- const fresh = new MessageEvent("message", {
111
- data: rewritten,
112
- origin: event.origin,
113
- lastEventId: event.lastEventId,
114
- ports: event.ports ? [...event.ports] : [],
115
- source: event.source ?? null,
116
- });
117
- return listener.call(this, fresh);
118
- }
119
- }
120
- return listener.call(this, event);
121
- };
122
- wrapped.__perfSkinWrap = true;
123
- wrapped.__perfOriginal = listener;
124
- return wrapped;
125
- }
126
-
127
- /** Filters matching WebSocket frames before any message listener runs. */
128
- function installWebSocketFilter() {
129
- const proto = window.WebSocket && window.WebSocket.prototype;
130
- if (!proto || proto.addEventListener.__perfSkinFilter) return;
131
-
132
- const origAdd = proto.addEventListener;
133
- const origRemove = proto.removeEventListener;
134
- const origDispatch = proto.dispatchEvent;
135
-
136
- proto.addEventListener = function (type, listener, options) {
137
- return origAdd.call(this, type, type === "message" ? wrapMessageListener(listener) : listener, options);
138
- };
139
- proto.removeEventListener = function (type, listener, options) {
140
- return origRemove.call(this, type, (listener && listener.__perfOriginal) || listener, options);
141
- };
142
- proto.dispatchEvent = function (event) {
143
- if (event && event.type === "message" && typeof event.data === "string") {
144
- const rewritten = rewriteWebSocketFrame(event.data);
145
- if (rewritten !== null) {
146
- return origDispatch.call(
147
- this,
148
- new MessageEvent("message", {
149
- data: rewritten,
150
- origin: event.origin,
151
- lastEventId: event.lastEventId,
152
- ports: event.ports ? [...event.ports] : [],
153
- source: event.source ?? null,
154
- }),
155
- );
156
- }
157
- }
158
- return origDispatch.call(this, event);
159
- };
160
-
161
- const descriptor = Object.getOwnPropertyDescriptor(proto, "onmessage");
162
- if (descriptor && descriptor.set && descriptor.get) {
163
- Object.defineProperty(proto, "onmessage", {
164
- configurable: true,
165
- enumerable: descriptor.enumerable,
166
- get: descriptor.get,
167
- set(value) {
168
- descriptor.set.call(this, typeof value === "function" ? wrapMessageListener(value) : value);
169
- },
170
- });
171
- }
172
-
173
- proto.addEventListener.__perfSkinFilter = true;
174
- }
175
-
176
- /** Filters matching fetch() responses (the data-binding may snapshot the endpoint over REST). */
177
- function installFetchFilter() {
178
- const original = window.fetch;
179
- if (typeof original !== "function" || original.__perfSkinFilter) return;
180
- // Keep the first captured fetch: it is the unfiltered one, and the plugin's own reads need it.
181
- if (!rawFetch) rawFetch = original;
182
-
183
- const wrapped = function (input, init) {
184
- const url = typeof input === "string" ? input : (input && input.url) || "";
185
- const promise = original.call(this, input, init);
186
- if (!filterActive || url.split("?")[0].indexOf(CAROUSEL_URI) === -1) return promise;
187
- return promise.then((response) => {
188
- if (!response || !response.ok) return response;
189
- return response
190
- .clone()
191
- .json()
192
- .catch(() => null)
193
- .then((json) => {
194
- const kept = filterSkinList(json);
195
- recordClientOrder(kept ?? json);
196
- if (!kept) return response;
197
- return new Response(JSON.stringify(kept), {
198
- status: response.status,
199
- statusText: response.statusText,
200
- headers: response.headers,
201
- });
202
- });
203
- });
204
- };
205
- wrapped.__perfSkinFilter = true;
206
- window.fetch = wrapped;
207
- }
208
-
209
- /**
210
- * Filters matching XHR responses (jQuery ajax and getJson go through XHR). The response is
211
- * filtered lazily in the responseText / response getters: consumers read it the moment it arrives.
212
- */
213
- function installXhrFilter() {
214
- const proto = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
215
- if (!proto || proto.open.__perfSkinFilter) return;
216
-
217
- const origOpen = proto.open;
218
- const origSend = proto.send;
219
- const textDescriptor = Object.getOwnPropertyDescriptor(proto, "responseText");
220
- const responseDescriptor = Object.getOwnPropertyDescriptor(proto, "response");
221
-
222
- proto.open = function (method, url, ...rest) {
223
- this.__perfSkinUrl = typeof url === "string" ? url : "";
224
- return origOpen.call(this, method, url, ...rest);
225
- };
226
- proto.open.__perfSkinFilter = true;
227
-
228
- proto.send = function (...args) {
229
- const url = this.__perfSkinUrl || "";
230
- if (textDescriptor && textDescriptor.get && url.split("?")[0].indexOf(CAROUSEL_URI) !== -1) {
231
- const xhr = this;
232
- const cache = { raw: null, filtered: null };
233
- const filteredText = () => {
234
- const raw = textDescriptor.get.call(xhr);
235
- if (!filterActive) return raw;
236
- if (cache.filtered === null || cache.raw !== raw) {
237
- cache.raw = raw;
238
- cache.filtered = raw;
239
- try {
240
- const json = JSON.parse(raw);
241
- const kept = filterSkinList(json);
242
- recordClientOrder(kept ?? json);
243
- if (kept) cache.filtered = JSON.stringify(kept);
244
- } catch {
245
- /* not JSON, leave the response alone */
246
- }
247
- }
248
- return cache.filtered;
249
- };
250
- Object.defineProperty(xhr, "responseText", { configurable: true, get: filteredText });
251
- if (responseDescriptor && responseDescriptor.get) {
252
- Object.defineProperty(xhr, "response", {
253
- configurable: true,
254
- get() {
255
- return xhr.responseType === "" || xhr.responseType === "text" ? filteredText() : responseDescriptor.get.call(xhr);
256
- },
257
- });
258
- }
259
- }
260
- return origSend.apply(this, args);
261
- };
262
- }
263
-
264
- function installSourceFilter() {
265
- try {
266
- filterActive = readDefaultEnabled();
267
- installWebSocketFilter();
268
- installFetchFilter();
269
- installXhrFilter();
270
- } catch (error) {
271
- logError("could not install the source filter.", error);
272
- }
273
- }
274
-
275
- /** Re-applies the wrappers after the client's own instrumentation replaces them; double filtering is harmless. */
276
- function ensureSourceFilter() {
277
- try {
278
- installWebSocketFilter();
279
- installFetchFilter();
280
- installXhrFilter();
281
- } catch (error) {
282
- logError("could not refresh the source filter.", error);
283
- }
284
- }
285
-
286
- /* ---- DOM safety net: hide and repack whatever the source filter misses ---- */
287
-
288
- let api = null;
289
- let summonerId = null;
290
- let ownedSkinIds = null; // Set of owned skin ids for the current champion, null while unknown
291
- let currentChampionId = null;
292
- let carouselOrder = []; // Skin ids in carousel order, used to map the page dots
293
- let selectedSkinId = null;
294
- let failure = null; // { championId, message, retryAt } after a failed inventory load
295
- let requestToken = 0;
296
- let pollTimer = null;
297
- let pollInFlight = false;
298
- let selectInFlight = false;
299
- let sessionMisses = 0; // consecutive failed session reads before state is cleared
300
- let championMisses = 0; // consecutive polls without a champion before state is cleared
301
- let renderObserver = null; // Re-applies the hiding whenever the client re-renders the carousel
302
- const repackOriginals = new Map(); // item -> its native inline left, restored when nothing is hidden
303
- let removeStyle = null;
304
-
305
- function logError(scope, error) {
306
- console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
307
- }
308
-
309
- /**
310
- * Reads a carousel item's skin id from its markup (thumbnails live in .../Skins/Skin<n>/).
311
- * Items without a readable id are left alone.
312
- */
313
- function readSkinId(item) {
314
- const html = item.innerHTML;
315
- const numbered = html.match(/Skins\/Skin(\d+)\//i) ?? html.match(/Skin(\d+)\//);
316
- if (numbered) return currentChampionId * 1000 + Number(numbered[1]);
317
- if (/Skins\/Base\//i.test(html)) return currentChampionId * 1000;
318
- return null;
319
- }
320
-
321
- /** The element to hide: climbs to the wrapper when the item is its only child, so no empty slot is left. */
322
- function hideTargetFor(item) {
323
- let target = item;
324
- for (let depth = 0; depth < 2; depth++) {
325
- const parent = target.parentElement;
326
- if (!parent || parent.children.length !== 1) break;
327
- target = parent;
328
- }
329
- return target;
330
- }
331
-
332
- /** Repacks visible items flush around the selected one so hidden items leave no gaps. */
333
- function repackVisibleItems(items) {
334
- const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
335
- if (visible.length < 2) return;
336
-
337
- const selectedIndex = items.findIndex((item) => item.classList.contains("skin-selection-item-selected"));
338
- const anchor = selectedIndex >= 0 ? items[selectedIndex] : visible[Math.floor(visible.length / 2)];
339
- const anchorDomIndex = items.indexOf(anchor);
340
-
341
- // The selected card is wider than its neighbours, so widths are read per item rather than assumed.
342
- const widthOf = (item) => item.getBoundingClientRect().width || (item === anchor ? 117 : 73);
343
- const toPx = (item) => {
344
- const raw = item.style.left || "0";
345
- if (raw.endsWith("px")) return parseFloat(raw);
346
- const box = item.parentElement?.getBoundingClientRect().width || 468;
347
- return (parseFloat(raw) / 100) * box;
348
- };
349
-
350
- // Pack outwards from the selected card: items before it go left, items after it go right.
351
- const anchorLeft = toPx(anchor);
352
- let rightX = anchorLeft + widthOf(anchor);
353
- let leftX = anchorLeft;
354
-
355
- // Only assign on change (no observer loop); the first overridden position is kept for restoreRepack.
356
- const setLeft = (item, x) => {
357
- const want = `${x}px`;
358
- if (item.style.getPropertyValue("left") !== want || item.style.getPropertyPriority("left") !== "important") {
359
- if (!repackOriginals.has(item)) {
360
- repackOriginals.set(item, {
361
- value: item.style.getPropertyValue("left"),
362
- priority: item.style.getPropertyPriority("left"),
363
- });
364
- }
365
- item.style.setProperty("left", want, "important");
366
- }
367
- };
368
-
369
- items.forEach((item, index) => {
370
- if (item === anchor || item.classList.contains(HIDDEN_CLASS)) return;
371
-
372
- const isRight = index > anchorDomIndex;
373
- const w = widthOf(item);
374
- if (isRight) {
375
- setLeft(item, rightX);
376
- rightX += w;
377
- } else {
378
- leftX -= w;
379
- setLeft(item, leftX);
380
- }
381
- });
382
- }
383
-
384
- /** Puts the carousel items the repack moved back at the client's own positions. */
385
- function restoreRepack() {
386
- if (repackOriginals.size === 0) return;
387
- for (const [item, original] of repackOriginals) {
388
- if (original.value) item.style.setProperty("left", original.value, original.priority || undefined);
389
- else item.style.removeProperty("left");
390
- }
391
- repackOriginals.clear();
392
- }
393
-
394
- /** Shows or hides every carousel item, its wrapper and its page dot. Unknown ownership stays visible. */
395
- function applyAll() {
396
- const items = [...document.querySelectorAll(SKIN_ITEM_SELECTOR)];
397
- let hidden = 0;
398
- items.forEach((item) => {
399
- const skinId = currentChampionId === null ? null : readSkinId(item);
400
- const hide = ownedSkinIds !== null && skinId !== null && !ownedSkinIds.has(skinId);
401
- if (hide) hidden++;
402
- item.classList.toggle(HIDDEN_CLASS, hide);
403
- hideTargetFor(item).classList.toggle(HIDDEN_CLASS, hide);
404
- });
405
-
406
- const dots = [...document.querySelectorAll(DOT_SELECTOR)];
407
- let dotsHidden = 0;
408
- // Dots are only mapped when their count matches the list the client received.
409
- const dotsMapped = carouselOrder.length > 0 && dots.length === carouselOrder.length;
410
- dots.forEach((dot, index) => {
411
- const skinId = dotsMapped ? carouselOrder[index] : undefined;
412
- const hide = ownedSkinIds !== null && skinId !== undefined && !ownedSkinIds.has(skinId);
413
- if (hide) dotsHidden++;
414
- dot.classList.toggle(HIDDEN_CLASS, hide);
415
- });
416
- const dotList = dots[0]?.parentElement;
417
- if (dotList) {
418
- if (dotsHidden > 0 && !dotList.hasAttribute("data-perf-dots")) dotList.setAttribute("data-perf-dots", "");
419
- else if (dotsHidden === 0 && dotList.hasAttribute("data-perf-dots")) dotList.removeAttribute("data-perf-dots");
420
- }
421
-
422
- // Only touch native positions when something was actually hidden.
423
- if (ownedSkinIds !== null && hidden > 0) repackVisibleItems(items);
424
- else if (hidden === 0) restoreRepack();
425
- }
426
-
427
- /** Applies the hiding without letting its own DOM changes retrigger the observer. */
428
- function applyAllGuarded() {
429
- // Disconnect while applying so the changes cannot retrigger the observer.
430
- renderObserver?.disconnect();
431
- try {
432
- applyAll();
433
- } finally {
434
- renderObserver?.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
435
- }
436
- }
437
-
438
- async function loadOwnedSkins(championId) {
439
- if (summonerId === null) {
440
- const summoner = await api.lcu.get(CURRENT_SUMMONER_URI, { ttl: SUMMONER_TTL });
441
- summonerId = summoner.summonerId;
442
- }
443
- const skins = await api.lcu.get(
444
- `/lol-champions/v1/inventories/${summonerId}/champions/${championId}/skins`,
445
- { ttl: INVENTORY_TTL },
446
- );
447
- if (!Array.isArray(skins) || skins.length === 0) {
448
- throw new Error(`no skin inventory returned for champion ${championId}`);
449
- }
450
- const order = skins
451
- .map((skin) => skin?.id)
452
- .filter((id) => typeof id === "number")
453
- .sort((a, b) => a - b);
454
- return { owned: new Set(skins.filter((skin) => skin.ownership?.owned).map((skin) => skin.id)), order };
455
- }
456
-
457
- /** Skin ids in carousel order; prefers the list the client received, falls back to the raw endpoint. */
458
- async function loadCarouselOrder() {
459
- if (clientSkinOrder.length > 0) return [...clientSkinOrder];
460
- try {
461
- let skins;
462
- if (rawFetch) {
463
- const response = await rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } });
464
- skins = response.ok ? await response.json() : null;
465
- } else {
466
- skins = await api.lcu.get(CAROUSEL_URI);
467
- }
468
- if (!Array.isArray(skins)) return [];
469
- return skins.map((skin) => skin?.id).filter((id) => typeof id === "number");
470
- } catch {
471
- return [];
472
- }
473
- }
474
-
475
- /** Next owned skin id in ascending order, wrapping around; null when nothing is owned. */
476
- function nextOwnedSkin(owned, fromId, direction) {
477
- const ids = [...owned].sort((a, b) => a - b);
478
- if (ids.length === 0) return null;
479
- if (direction > 0) return ids.find((id) => id > fromId) ?? ids[0];
480
- return [...ids].reverse().find((id) => id < fromId) ?? ids[ids.length - 1];
481
- }
482
-
483
- /** Selects a skin through the client's own selection endpoint, so the carousel follows it. */
484
- async function selectSkin(skinId) {
485
- selectedSkinId = skinId;
486
- selectInFlight = true;
487
- try {
488
- await api.lcu.send("PATCH", SELECTION_URI, { selectedSkinId: skinId });
489
- } finally {
490
- selectInFlight = false;
491
- }
492
- }
493
-
494
- /** Forgets the current champ select; only on real session loss, so transient errors cannot flash skins back. */
495
- function resetState() {
496
- requestToken++;
497
- ownedSkinIds = null;
498
- currentChampionId = null;
499
- selectedSkinId = null;
500
- carouselOrder = [];
501
- clientSkinOrder = [];
502
- failure = null;
503
- sessionMisses = 0;
504
- championMisses = 0;
505
- applyAll();
506
- }
507
-
508
- /** Loads the owned skins for a champion. Results that arrive after a newer request are ignored. */
509
- function loadChampion(championId) {
510
- currentChampionId = championId;
511
- ownedSkinIds = null;
512
- failure = null;
513
- // The client's list for the new champion arrives with the next carousel push.
514
- clientSkinOrder = [];
515
- const token = ++requestToken;
516
-
517
- Promise.all([loadOwnedSkins(championId), loadCarouselOrder()]).then(
518
- ([{ owned, order: inventoryOrder }, carouselIds]) => {
519
- if (token !== requestToken) return;
520
- ownedSkinIds = owned;
521
- // The carousel endpoint can return nothing; the inventory lists the same skins in the same order.
522
- carouselOrder = carouselIds.length > 0 ? carouselIds : inventoryOrder;
523
- applyAll();
524
- },
525
- (error) => {
526
- if (token !== requestToken) return;
527
- logError(`could not load owned skins for champion ${championId}.`, error);
528
- failure = { championId, message: error.message, retryAt: Date.now() + RETRY_MS };
529
- // Forget the champion so a later poll retries after RETRY_MS.
530
- currentChampionId = null;
531
- },
532
- );
533
- }
534
-
535
- /** Works out which champion the local player is on (locked in or hovered) and keeps the carousel in step. */
536
- function updateForSession(session, selection) {
537
- const me = session?.myTeam?.find((player) => player.cellId === session.localPlayerCellId);
538
- const championId = me?.championId || me?.championPickIntent || null;
539
-
540
- if (!championId) {
541
- championMisses++;
542
- if (championMisses >= 2) resetState();
543
- return;
544
- }
545
- championMisses = 0;
546
-
547
- if (championId !== currentChampionId) {
548
- if (failure?.championId === championId && Date.now() < failure.retryAt) return;
549
- loadChampion(championId);
550
- }
551
-
552
- if (ownedSkinIds === null) return;
553
-
554
- // Keep the selection in step with the client. Only skins of this champion count.
555
- const serverSkinId = selection?.selectedSkinId || me.selectedSkinId || null;
556
- const belongsToChampion = serverSkinId !== null && Math.floor(serverSkinId / 1000) === championId;
557
- if (!selectInFlight && belongsToChampion) selectedSkinId = serverSkinId;
558
-
559
- // If the client selected a skin the player doesn't own, move to the first owned skin.
560
- if (!selectInFlight && belongsToChampion && !ownedSkinIds.has(serverSkinId)) {
561
- const fallback = nextOwnedSkin(ownedSkinIds, 0, 1);
562
- if (fallback !== null) {
563
- selectSkin(fallback).catch((error) => logError("could not correct the selected skin.", error));
564
- }
565
- }
566
-
567
- applyAll();
568
- }
569
-
570
- /** Reads the session and selection once a second to keep the carousel in step; errors outside champ select clear the state. */
571
- async function pollSession() {
572
- if (pollInFlight || !api) return;
573
- ensureSourceFilter();
574
- pollInFlight = true;
575
- try {
576
- const [session, selection] = await Promise.all([
577
- api.lcu.get(SESSION_URI),
578
- api.lcu.get(SELECTION_URI).catch(() => null),
579
- ]);
580
- sessionMisses = 0;
581
- updateForSession(session, selection);
582
- } catch {
583
- sessionMisses++;
584
- if (sessionMisses >= 3) resetState();
585
- } finally {
586
- pollInFlight = false;
587
- }
588
- }
589
-
590
- function onEnable(utils) {
591
- api = utils;
592
- filterActive = true;
593
- removeStyle = api.addStyle(STYLE_ID, CSS);
594
-
595
- // The client re-creates carousel items and dots on selection changes; re-apply before the next paint.
596
- // document.body can still be null at Pengu boot, so observe the document element.
597
- renderObserver = new MutationObserver(() => applyAllGuarded());
598
- renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
599
-
600
- pollTimer = setInterval(pollSession, SESSION_POLL_MS);
601
- pollSession();
602
- }
603
-
604
- function onDisable() {
605
- clearInterval(pollTimer);
606
- pollTimer = null;
607
- renderObserver?.disconnect();
608
- removeStyle?.();
609
- renderObserver = null;
610
- removeStyle = null;
611
- filterActive = false;
612
-
613
- resetState();
614
- document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((item) => item.classList.remove(HIDDEN_CLASS));
615
- api = null;
616
- }
617
-
618
- /** Pengu entry point. Registers with the Perfectionism core. */
619
- export function init() {
620
- // Installed before the client's bundles create their sockets and send their first requests.
621
- installSourceFilter();
622
-
623
- const registerWith = (utils) => {
624
- utils.registerPlugin({
625
- id: PLUGIN_ID,
626
- name: "Hide Unowned Skins",
627
- description: "In champion select, only shows the skins you own for the picked champion.",
628
- version,
629
- defaultEnabled: true,
630
- onEnable,
631
- onDisable,
632
- });
633
- };
634
-
635
- if (window.PerfectionistUtils) {
636
- registerWith(window.PerfectionistUtils);
637
- } else {
638
- addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail), { once: true });
639
- }
640
- }
1
+ export const version = "2.1.0";
2
+
3
+ const PLUGIN_ID = "hide-unowned-skins";
4
+ const STYLE_ID = "perfectionist-hide-unowned-skins-style";
5
+ const HIDDEN_CLASS = "perf-unowned-skin-hidden";
6
+ const SKIN_ITEM_SELECTOR = ".skin-selection-item";
7
+ const DOT_SELECTOR = ".skin-selection-indicator-selector";
8
+ const SESSION_URI = "/lol-champ-select/v1/session";
9
+ const SELECTION_URI = "/lol-champ-select/v1/session/my-selection";
10
+ const CAROUSEL_URI = "/lol-champ-select/v1/skin-carousel-skins";
11
+ const CURRENT_SUMMONER_URI = "/lol-summoner/v1/current-summoner";
12
+ const SUMMONER_TTL = 10 * 60 * 1000;
13
+ const INVENTORY_TTL = 60 * 1000;
14
+ const SESSION_POLL_MS = 1000;
15
+ const RETRY_MS = 5000;
16
+ const STATE_KEY = "perfectionist-plugins:state";
17
+
18
+ const CSS = `
19
+ .${HIDDEN_CLASS} { display: none !important; margin: 0 !important; padding: 0 !important; border: none !important; }
20
+ [data-perf-dots] { justify-content: center !important; }
21
+ `;
22
+
23
+ const OBSERVER_OPTIONS = {
24
+ childList: true,
25
+ subtree: true,
26
+ attributes: true,
27
+ attributeFilter: ["class", "style"],
28
+ };
29
+
30
+ let filterActive = true;
31
+ let rawFetch = null;
32
+ let clientSkinOrder = [];
33
+
34
+ function readDefaultEnabled() {
35
+ try {
36
+ const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
37
+ return saved[PLUGIN_ID] !== false;
38
+ } catch {
39
+ return true;
40
+ }
41
+ }
42
+
43
+ function isOwnedSkin(skin) {
44
+ if (!skin || typeof skin !== "object") return true;
45
+ const id = typeof skin.id === "number" ? skin.id : null;
46
+ if (id === null) return true;
47
+ if (id % 1000 === 0) return true;
48
+ const ownership = skin.ownership;
49
+ if (ownership && typeof ownership.owned === "boolean") return ownership.owned;
50
+ return true;
51
+ }
52
+
53
+ function filterSkinList(list) {
54
+ if (!Array.isArray(list)) return null;
55
+ const kept = list.filter(isOwnedSkin);
56
+ return kept.length === list.length ? null : kept;
57
+ }
58
+
59
+ function recordClientOrder(list) {
60
+ if (Array.isArray(list)) {
61
+ clientSkinOrder = list.map((skin) => (skin && typeof skin.id === "number" ? skin.id : null)).filter((id) => id !== null);
62
+ }
63
+ }
64
+
65
+ function rewriteWebSocketFrame(text) {
66
+ if (!filterActive || typeof text !== "string") return null;
67
+ if (text.indexOf("skin-carousel-skins") === -1) return null;
68
+ let frame;
69
+ try {
70
+ frame = JSON.parse(text);
71
+ } catch {
72
+ return null;
73
+ }
74
+ if (!Array.isArray(frame) || frame.length < 3) return null;
75
+ const payload = frame[2];
76
+ if (!payload || typeof payload !== "object" || typeof payload.uri !== "string") return null;
77
+ if (payload.uri.split("?")[0] !== CAROUSEL_URI) return null;
78
+ const kept = filterSkinList(payload.data);
79
+ recordClientOrder(kept ?? payload.data);
80
+ if (!kept) return null;
81
+ return JSON.stringify([frame[0], frame[1], Object.assign({}, payload, { data: kept })]);
82
+ }
83
+
84
+ function wrapMessageListener(listener) {
85
+ if (typeof listener !== "function" || listener.__perfSkinWrap) return listener;
86
+ const wrapped = function (event) {
87
+ if (event && typeof event.data === "string") {
88
+ const rewritten = rewriteWebSocketFrame(event.data);
89
+ if (rewritten !== null) {
90
+ const fresh = new MessageEvent("message", {
91
+ data: rewritten,
92
+ origin: event.origin,
93
+ lastEventId: event.lastEventId,
94
+ ports: event.ports ? [...event.ports] : [],
95
+ source: event.source ?? null,
96
+ });
97
+ return listener.call(this, fresh);
98
+ }
99
+ }
100
+ return listener.call(this, event);
101
+ };
102
+ wrapped.__perfSkinWrap = true;
103
+ wrapped.__perfOriginal = listener;
104
+ return wrapped;
105
+ }
106
+
107
+ function installWebSocketFilter() {
108
+ const proto = window.WebSocket && window.WebSocket.prototype;
109
+ if (!proto || proto.addEventListener.__perfSkinFilter) return;
110
+
111
+ const origAdd = proto.addEventListener;
112
+ const origRemove = proto.removeEventListener;
113
+ const origDispatch = proto.dispatchEvent;
114
+
115
+ proto.addEventListener = function (type, listener, options) {
116
+ return origAdd.call(this, type, type === "message" ? wrapMessageListener(listener) : listener, options);
117
+ };
118
+ proto.removeEventListener = function (type, listener, options) {
119
+ return origRemove.call(this, type, (listener && listener.__perfOriginal) || listener, options);
120
+ };
121
+ proto.dispatchEvent = function (event) {
122
+ if (event && event.type === "message" && typeof event.data === "string") {
123
+ const rewritten = rewriteWebSocketFrame(event.data);
124
+ if (rewritten !== null) {
125
+ return origDispatch.call(
126
+ this,
127
+ new MessageEvent("message", {
128
+ data: rewritten,
129
+ origin: event.origin,
130
+ lastEventId: event.lastEventId,
131
+ ports: event.ports ? [...event.ports] : [],
132
+ source: event.source ?? null,
133
+ }),
134
+ );
135
+ }
136
+ }
137
+ return origDispatch.call(this, event);
138
+ };
139
+
140
+ const descriptor = Object.getOwnPropertyDescriptor(proto, "onmessage");
141
+ if (descriptor && descriptor.set && descriptor.get) {
142
+ Object.defineProperty(proto, "onmessage", {
143
+ configurable: true,
144
+ enumerable: descriptor.enumerable,
145
+ get: descriptor.get,
146
+ set(value) {
147
+ descriptor.set.call(this, typeof value === "function" ? wrapMessageListener(value) : value);
148
+ },
149
+ });
150
+ }
151
+
152
+ proto.addEventListener.__perfSkinFilter = true;
153
+ }
154
+
155
+ function installFetchFilter() {
156
+ const original = window.fetch;
157
+ if (typeof original !== "function" || original.__perfSkinFilter) return;
158
+ if (!rawFetch) rawFetch = original;
159
+
160
+ const wrapped = function (input, init) {
161
+ const url = typeof input === "string" ? input : (input && input.url) || "";
162
+ const promise = original.call(this, input, init);
163
+ if (!filterActive || url.split("?")[0].indexOf(CAROUSEL_URI) === -1) return promise;
164
+ return promise.then((response) => {
165
+ if (!response || !response.ok) return response;
166
+ return response
167
+ .clone()
168
+ .json()
169
+ .catch(() => null)
170
+ .then((json) => {
171
+ const kept = filterSkinList(json);
172
+ recordClientOrder(kept ?? json);
173
+ if (!kept) return response;
174
+ return new Response(JSON.stringify(kept), {
175
+ status: response.status,
176
+ statusText: response.statusText,
177
+ headers: response.headers,
178
+ });
179
+ });
180
+ });
181
+ };
182
+ wrapped.__perfSkinFilter = true;
183
+ window.fetch = wrapped;
184
+ }
185
+
186
+ function installXhrFilter() {
187
+ const proto = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
188
+ if (!proto || proto.open.__perfSkinFilter) return;
189
+
190
+ const origOpen = proto.open;
191
+ const origSend = proto.send;
192
+ const textDescriptor = Object.getOwnPropertyDescriptor(proto, "responseText");
193
+ const responseDescriptor = Object.getOwnPropertyDescriptor(proto, "response");
194
+
195
+ proto.open = function (method, url, ...rest) {
196
+ this.__perfSkinUrl = typeof url === "string" ? url : "";
197
+ return origOpen.call(this, method, url, ...rest);
198
+ };
199
+ proto.open.__perfSkinFilter = true;
200
+
201
+ proto.send = function (...args) {
202
+ const url = this.__perfSkinUrl || "";
203
+ if (textDescriptor && textDescriptor.get && url.split("?")[0].indexOf(CAROUSEL_URI) !== -1) {
204
+ const xhr = this;
205
+ const cache = { raw: null, filtered: null };
206
+ const filteredText = () => {
207
+ const raw = textDescriptor.get.call(xhr);
208
+ if (!filterActive) return raw;
209
+ if (cache.filtered === null || cache.raw !== raw) {
210
+ cache.raw = raw;
211
+ cache.filtered = raw;
212
+ try {
213
+ const json = JSON.parse(raw);
214
+ const kept = filterSkinList(json);
215
+ recordClientOrder(kept ?? json);
216
+ if (kept) cache.filtered = JSON.stringify(kept);
217
+ } catch {
218
+ }
219
+ }
220
+ return cache.filtered;
221
+ };
222
+ Object.defineProperty(xhr, "responseText", { configurable: true, get: filteredText });
223
+ if (responseDescriptor && responseDescriptor.get) {
224
+ Object.defineProperty(xhr, "response", {
225
+ configurable: true,
226
+ get() {
227
+ return xhr.responseType === "" || xhr.responseType === "text" ? filteredText() : responseDescriptor.get.call(xhr);
228
+ },
229
+ });
230
+ }
231
+ }
232
+ return origSend.apply(this, args);
233
+ };
234
+ }
235
+
236
+ function installSourceFilter() {
237
+ try {
238
+ filterActive = readDefaultEnabled();
239
+ installWebSocketFilter();
240
+ installFetchFilter();
241
+ installXhrFilter();
242
+ } catch (error) {
243
+ logError("could not install the source filter.", error);
244
+ }
245
+ }
246
+
247
+ function ensureSourceFilter() {
248
+ try {
249
+ installWebSocketFilter();
250
+ installFetchFilter();
251
+ installXhrFilter();
252
+ } catch (error) {
253
+ logError("could not refresh the source filter.", error);
254
+ }
255
+ }
256
+
257
+ let api = null;
258
+ let summonerId = null;
259
+ let ownedSkinIds = null;
260
+ let currentChampionId = null;
261
+ let carouselOrder = [];
262
+ let selectedSkinId = null;
263
+ let failure = null;
264
+ let requestToken = 0;
265
+ let pollTimer = null;
266
+ let pollInFlight = false;
267
+ let selectInFlight = false;
268
+ let sessionMisses = 0;
269
+ let championMisses = 0;
270
+ let renderObserver = null;
271
+ const repackOriginals = new Map();
272
+ let removeStyle = null;
273
+
274
+ function logError(scope, error) {
275
+ console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
276
+ }
277
+
278
+ function readSkinId(item) {
279
+ const html = item.innerHTML;
280
+ const numbered = html.match(/Skins\/Skin(\d+)\//i) ?? html.match(/Skin(\d+)\//);
281
+ if (numbered) return currentChampionId * 1000 + Number(numbered[1]);
282
+ if (/Skins\/Base\//i.test(html)) return currentChampionId * 1000;
283
+ return null;
284
+ }
285
+
286
+ function hideTargetFor(item) {
287
+ let target = item;
288
+ for (let depth = 0; depth < 2; depth++) {
289
+ const parent = target.parentElement;
290
+ if (!parent || parent.children.length !== 1) break;
291
+ target = parent;
292
+ }
293
+ return target;
294
+ }
295
+
296
+ function repackVisibleItems(items) {
297
+ const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
298
+ if (visible.length < 2) return;
299
+
300
+ const selectedIndex = items.findIndex((item) => item.classList.contains("skin-selection-item-selected"));
301
+ const anchor = selectedIndex >= 0 ? items[selectedIndex] : visible[Math.floor(visible.length / 2)];
302
+ const anchorDomIndex = items.indexOf(anchor);
303
+
304
+ const widthOf = (item) => item.getBoundingClientRect().width || (item === anchor ? 117 : 73);
305
+ const toPx = (item) => {
306
+ const raw = item.style.left || "0";
307
+ if (raw.endsWith("px")) return parseFloat(raw);
308
+ const box = item.parentElement?.getBoundingClientRect().width || 468;
309
+ return (parseFloat(raw) / 100) * box;
310
+ };
311
+
312
+ const anchorLeft = toPx(anchor);
313
+ let rightX = anchorLeft + widthOf(anchor);
314
+ let leftX = anchorLeft;
315
+
316
+ const setLeft = (item, x) => {
317
+ const want = `${x}px`;
318
+ if (item.style.getPropertyValue("left") !== want || item.style.getPropertyPriority("left") !== "important") {
319
+ if (!repackOriginals.has(item)) {
320
+ repackOriginals.set(item, {
321
+ value: item.style.getPropertyValue("left"),
322
+ priority: item.style.getPropertyPriority("left"),
323
+ });
324
+ }
325
+ item.style.setProperty("left", want, "important");
326
+ }
327
+ };
328
+
329
+ items.forEach((item, index) => {
330
+ if (item === anchor || item.classList.contains(HIDDEN_CLASS)) return;
331
+
332
+ const isRight = index > anchorDomIndex;
333
+ const w = widthOf(item);
334
+ if (isRight) {
335
+ setLeft(item, rightX);
336
+ rightX += w;
337
+ } else {
338
+ leftX -= w;
339
+ setLeft(item, leftX);
340
+ }
341
+ });
342
+ }
343
+
344
+ function restoreRepack() {
345
+ if (repackOriginals.size === 0) return;
346
+ for (const [item, original] of repackOriginals) {
347
+ if (original.value) item.style.setProperty("left", original.value, original.priority || undefined);
348
+ else item.style.removeProperty("left");
349
+ }
350
+ repackOriginals.clear();
351
+ }
352
+
353
+ function applyAll() {
354
+ const items = [...document.querySelectorAll(SKIN_ITEM_SELECTOR)];
355
+ let hidden = 0;
356
+ items.forEach((item) => {
357
+ const skinId = currentChampionId === null ? null : readSkinId(item);
358
+ const hide = ownedSkinIds !== null && skinId !== null && !ownedSkinIds.has(skinId);
359
+ if (hide) hidden++;
360
+ item.classList.toggle(HIDDEN_CLASS, hide);
361
+ hideTargetFor(item).classList.toggle(HIDDEN_CLASS, hide);
362
+ });
363
+
364
+ const dots = [...document.querySelectorAll(DOT_SELECTOR)];
365
+ let dotsHidden = 0;
366
+ const dotsMapped = carouselOrder.length > 0 && dots.length === carouselOrder.length;
367
+ dots.forEach((dot, index) => {
368
+ const skinId = dotsMapped ? carouselOrder[index] : undefined;
369
+ const hide = ownedSkinIds !== null && skinId !== undefined && !ownedSkinIds.has(skinId);
370
+ if (hide) dotsHidden++;
371
+ dot.classList.toggle(HIDDEN_CLASS, hide);
372
+ });
373
+ const dotList = dots[0]?.parentElement;
374
+ if (dotList) {
375
+ if (dotsHidden > 0 && !dotList.hasAttribute("data-perf-dots")) dotList.setAttribute("data-perf-dots", "");
376
+ else if (dotsHidden === 0 && dotList.hasAttribute("data-perf-dots")) dotList.removeAttribute("data-perf-dots");
377
+ }
378
+
379
+ if (ownedSkinIds !== null && hidden > 0) repackVisibleItems(items);
380
+ else if (hidden === 0) restoreRepack();
381
+ }
382
+
383
+ function applyAllGuarded() {
384
+ renderObserver?.disconnect();
385
+ try {
386
+ applyAll();
387
+ } finally {
388
+ renderObserver?.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
389
+ }
390
+ }
391
+
392
+ async function loadOwnedSkins(championId) {
393
+ if (summonerId === null) {
394
+ const summoner = await api.lcu.get(CURRENT_SUMMONER_URI, { ttl: SUMMONER_TTL });
395
+ summonerId = summoner.summonerId;
396
+ }
397
+ const skins = await api.lcu.get(
398
+ `/lol-champions/v1/inventories/${summonerId}/champions/${championId}/skins`,
399
+ { ttl: INVENTORY_TTL },
400
+ );
401
+ if (!Array.isArray(skins) || skins.length === 0) {
402
+ throw new Error(`no skin inventory returned for champion ${championId}`);
403
+ }
404
+ const order = skins
405
+ .map((skin) => skin?.id)
406
+ .filter((id) => typeof id === "number")
407
+ .sort((a, b) => a - b);
408
+ return { owned: new Set(skins.filter((skin) => skin.ownership?.owned).map((skin) => skin.id)), order };
409
+ }
410
+
411
+ async function loadCarouselOrder() {
412
+ if (clientSkinOrder.length > 0) return [...clientSkinOrder];
413
+ try {
414
+ let skins;
415
+ if (rawFetch) {
416
+ const response = await rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } });
417
+ skins = response.ok ? await response.json() : null;
418
+ } else {
419
+ skins = await api.lcu.get(CAROUSEL_URI);
420
+ }
421
+ if (!Array.isArray(skins)) return [];
422
+ return skins.map((skin) => skin?.id).filter((id) => typeof id === "number");
423
+ } catch {
424
+ return [];
425
+ }
426
+ }
427
+
428
+ function nextOwnedSkin(owned, fromId, direction) {
429
+ const ids = [...owned].sort((a, b) => a - b);
430
+ if (ids.length === 0) return null;
431
+ if (direction > 0) return ids.find((id) => id > fromId) ?? ids[0];
432
+ return [...ids].reverse().find((id) => id < fromId) ?? ids[ids.length - 1];
433
+ }
434
+
435
+ async function selectSkin(skinId) {
436
+ selectedSkinId = skinId;
437
+ selectInFlight = true;
438
+ try {
439
+ await api.lcu.send("PATCH", SELECTION_URI, { selectedSkinId: skinId });
440
+ } finally {
441
+ selectInFlight = false;
442
+ }
443
+ }
444
+
445
+ function resetState() {
446
+ requestToken++;
447
+ ownedSkinIds = null;
448
+ currentChampionId = null;
449
+ selectedSkinId = null;
450
+ carouselOrder = [];
451
+ clientSkinOrder = [];
452
+ failure = null;
453
+ sessionMisses = 0;
454
+ championMisses = 0;
455
+ applyAll();
456
+ }
457
+
458
+ function loadChampion(championId) {
459
+ currentChampionId = championId;
460
+ ownedSkinIds = null;
461
+ failure = null;
462
+ clientSkinOrder = [];
463
+ const token = ++requestToken;
464
+
465
+ Promise.all([loadOwnedSkins(championId), loadCarouselOrder()]).then(
466
+ ([{ owned, order: inventoryOrder }, carouselIds]) => {
467
+ if (token !== requestToken) return;
468
+ ownedSkinIds = owned;
469
+ carouselOrder = carouselIds.length > 0 ? carouselIds : inventoryOrder;
470
+ applyAll();
471
+ },
472
+ (error) => {
473
+ if (token !== requestToken) return;
474
+ logError(`could not load owned skins for champion ${championId}.`, error);
475
+ failure = { championId, message: error.message, retryAt: Date.now() + RETRY_MS };
476
+ currentChampionId = null;
477
+ },
478
+ );
479
+ }
480
+
481
+ function updateForSession(session, selection) {
482
+ const me = session?.myTeam?.find((player) => player.cellId === session.localPlayerCellId);
483
+ const championId = me?.championId || me?.championPickIntent || null;
484
+
485
+ if (!championId) {
486
+ championMisses++;
487
+ if (championMisses >= 2) resetState();
488
+ return;
489
+ }
490
+ championMisses = 0;
491
+
492
+ if (championId !== currentChampionId) {
493
+ if (failure?.championId === championId && Date.now() < failure.retryAt) return;
494
+ loadChampion(championId);
495
+ }
496
+
497
+ if (ownedSkinIds === null) return;
498
+
499
+ const serverSkinId = selection?.selectedSkinId || me.selectedSkinId || null;
500
+ const belongsToChampion = serverSkinId !== null && Math.floor(serverSkinId / 1000) === championId;
501
+ if (!selectInFlight && belongsToChampion) selectedSkinId = serverSkinId;
502
+
503
+ if (!selectInFlight && belongsToChampion && !ownedSkinIds.has(serverSkinId)) {
504
+ const fallback = nextOwnedSkin(ownedSkinIds, 0, 1);
505
+ if (fallback !== null) {
506
+ selectSkin(fallback).catch((error) => logError("could not correct the selected skin.", error));
507
+ }
508
+ }
509
+
510
+ applyAll();
511
+ }
512
+
513
+ async function pollSession() {
514
+ if (pollInFlight || !api) return;
515
+ ensureSourceFilter();
516
+ pollInFlight = true;
517
+ try {
518
+ const [session, selection] = await Promise.all([
519
+ api.lcu.get(SESSION_URI),
520
+ api.lcu.get(SELECTION_URI).catch(() => null),
521
+ ]);
522
+ sessionMisses = 0;
523
+ updateForSession(session, selection);
524
+ } catch {
525
+ sessionMisses++;
526
+ if (sessionMisses >= 3) resetState();
527
+ } finally {
528
+ pollInFlight = false;
529
+ }
530
+ }
531
+
532
+ function onEnable(utils) {
533
+ api = utils;
534
+ filterActive = true;
535
+ removeStyle = api.addStyle(STYLE_ID, CSS);
536
+
537
+ renderObserver = new MutationObserver(() => applyAllGuarded());
538
+ renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
539
+
540
+ pollTimer = setInterval(pollSession, SESSION_POLL_MS);
541
+ pollSession();
542
+ }
543
+
544
+ function onDisable() {
545
+ clearInterval(pollTimer);
546
+ pollTimer = null;
547
+ renderObserver?.disconnect();
548
+ removeStyle?.();
549
+ renderObserver = null;
550
+ removeStyle = null;
551
+ filterActive = false;
552
+
553
+ resetState();
554
+ document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((item) => item.classList.remove(HIDDEN_CLASS));
555
+ api = null;
556
+ }
557
+
558
+ export function init() {
559
+ installSourceFilter();
560
+
561
+ const registerWith = (utils) => {
562
+ utils.registerPlugin({
563
+ id: PLUGIN_ID,
564
+ name: "Hide Unowned Skins",
565
+ description: "In champion select, only shows the skins you own for the picked champion.",
566
+ version,
567
+ defaultEnabled: true,
568
+ onEnable,
569
+ onDisable,
570
+ });
571
+ };
572
+
573
+ if (window.PerfectionistUtils) {
574
+ registerWith(window.PerfectionistUtils);
575
+ } else {
576
+ addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail), { once: true });
577
+ }
578
+ }