perfectionist-plugins 2.4.8 → 2.5.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.
@@ -1,687 +1,63 @@
1
- export const version = "2.4.5";
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 REFRESH_DEBOUNCE_MS = 350;
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
- .perf-native-hidden { display: none !important; }
21
- `;
22
-
23
- const OBSERVER_OPTIONS = {
24
- childList: true,
25
- subtree: true,
26
- attributes: true,
27
- attributeFilter: ["src"],
28
- };
29
-
30
- const FILTER_VERSION = "2.4.5";
31
- const FILTER_STATE_KEY = "__perfSkinFilterState";
32
- const filterState = (globalThis[FILTER_STATE_KEY] ??= { active: true });
33
-
34
- let skinShapeKeys = null;
35
-
36
- function learnSkinShape(list) {
37
- if (!Array.isArray(list) || list.length === 0) return;
38
- const keys = new Set(skinShapeKeys || []);
39
- for (const item of list.slice(0, 4)) {
40
- if (item && typeof item === "object") for (const key of Object.keys(item)) keys.add(key);
41
- }
42
- if (keys.size >= 2) skinShapeKeys = keys;
43
- }
44
-
45
- function isSkinLike(item) {
46
- if (!item || typeof item !== "object" || typeof item.id !== "number") return false;
47
- if (skinShapeKeys) {
48
- let hits = 0;
49
- for (const key of Object.keys(item)) if (skinShapeKeys.has(key)) hits++;
50
- return hits >= 2;
51
- }
52
- return (
53
- "ownership" in item ||
54
- "tilePath" in item ||
55
- "splashPath" in item ||
56
- "unlocked" in item ||
57
- Array.isArray(item.chromas)
58
- );
59
- }
60
-
61
- function isSkinCollection(list) {
62
- if (!Array.isArray(list) || list.length < 2) return false;
63
- return isSkinLike(list[0]) && isSkinLike(list[list.length - 1]);
64
- }
65
-
66
- function installArrayFilter() {
67
- const proto = Array.prototype;
68
- if (!proto || proto.filter.__perfSkinFilter === FILTER_VERSION) return;
69
- const nativeFilter = proto.filter;
70
- const wrapped = function (callback, thisArg) {
71
- const result = nativeFilter.call(this, callback, thisArg);
72
- if (!filterState.active || currentChampionId === null || !isSkinCollection(result)) return result;
73
- let changed = false;
74
- if (!skinShapeKeys) learnSkinShape(result);
75
- const kept = nativeFilter.call(result, (skin) => {
76
- const owned = isOwnedSkin(skin);
77
- if (!owned) changed = true;
78
- return owned;
79
- });
80
- if (!changed) return result;
81
- return kept;
82
- };
83
- wrapped.__perfSkinFilter = FILTER_VERSION;
84
- proto.filter = wrapped;
85
- }
86
- let rawFetch = (typeof window !== "undefined" && window.fetch && window.fetch.__perfOriginalFetch) || null;
87
- let clientSkinOrder = [];
88
-
89
- function readDefaultEnabled() {
90
- try {
91
- const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
92
- return saved.enabled?.[PLUGIN_ID] !== false;
93
- } catch {
94
- return true;
95
- }
96
- }
97
-
98
- function isOwnedSkin(skin) {
99
- if (!skin || typeof skin !== "object") return true;
100
- const id = typeof skin.id === "number" ? skin.id : null;
101
- if (id === null) return true;
102
- if (id % 1000 === 0) return true;
103
- if (ownedSkinIds && allSkinIds && allSkinIds.has(id)) return ownedSkinIds.has(id);
104
- const ownership = skin.ownership;
105
- if (ownership && typeof ownership.owned === "boolean") return ownership.owned;
106
- if (typeof skin.owned === "boolean") return skin.owned;
107
- if (typeof skin.unlocked === "boolean") return skin.unlocked;
108
- return true;
109
- }
110
-
111
- function filterSkinList(list) {
112
- if (!Array.isArray(list) || currentChampionId === null) return null;
113
- if (!skinShapeKeys && isSkinCollection(list)) learnSkinShape(list);
114
- let changed = false;
115
- const filtered = list
116
- .map((skin) => {
117
- if (!skin || !Array.isArray(skin.childSkins)) return skin;
118
- const childSkins = skin.childSkins.filter(isOwnedSkin);
119
- if (childSkins.length === skin.childSkins.length) return skin;
120
- changed = true;
121
- return Object.assign({}, skin, { childSkins });
122
- })
123
- .filter(isOwnedSkin);
124
- if (filtered.length !== list.length) changed = true;
125
- return changed ? filtered : null;
126
- }
127
-
128
- function recordClientOrder(list) {
129
- scheduleRefresh();
130
- if (Array.isArray(list)) {
131
- clientSkinOrder = list.map((skin) => (skin && typeof skin.id === "number" ? skin.id : null)).filter((id) => id !== null);
132
- }
133
- }
134
-
135
- function rewriteWebSocketFrame(text) {
136
- if (!filterState.active || currentChampionId === null || typeof text !== "string") return null;
137
- if (text.indexOf("skin-carousel-skins") === -1) return null;
138
- let frame;
139
- try {
140
- frame = JSON.parse(text);
141
- } catch {
142
- return null;
143
- }
144
- if (!Array.isArray(frame) || frame.length < 3) return null;
145
- const payload = frame[2];
146
- if (!payload || typeof payload !== "object" || typeof payload.uri !== "string") return null;
147
- if (payload.uri.split("?")[0] !== CAROUSEL_URI) return null;
148
- const kept = filterSkinList(payload.data);
149
- recordClientOrder(kept ?? payload.data);
150
- if (!kept) return null;
151
- return JSON.stringify([frame[0], frame[1], Object.assign({}, payload, { data: kept })]);
152
- }
153
-
154
- function wrapMessageListener(listener) {
155
- if (typeof listener !== "function" || listener.__perfSkinWrap) return listener;
156
- const wrapped = function (event) {
157
- if (event && typeof event.data === "string") {
158
- const rewritten = rewriteWebSocketFrame(event.data);
159
- if (rewritten !== null) {
160
- const fresh = new MessageEvent("message", {
161
- data: rewritten,
162
- origin: event.origin,
163
- lastEventId: event.lastEventId,
164
- ports: event.ports ? [...event.ports] : [],
165
- source: event.source ?? null,
166
- });
167
- return listener.call(this, fresh);
168
- }
169
- }
170
- return listener.call(this, event);
171
- };
172
- wrapped.__perfSkinWrap = true;
173
- wrapped.__perfOriginal = listener;
174
- return wrapped;
175
- }
176
-
177
- function installWebSocketFilter() {
178
- const proto = window.WebSocket && window.WebSocket.prototype;
179
- if (!proto || proto.addEventListener.__perfSkinFilter === FILTER_VERSION) return;
180
-
181
- const origAdd = proto.addEventListener;
182
- const origRemove = proto.removeEventListener;
183
- const origDispatch = proto.dispatchEvent;
184
-
185
- proto.addEventListener = function (type, listener, options) {
186
- return origAdd.call(this, type, type === "message" ? wrapMessageListener(listener) : listener, options);
187
- };
188
- proto.removeEventListener = function (type, listener, options) {
189
- return origRemove.call(this, type, (listener && listener.__perfOriginal) || listener, options);
190
- };
191
- proto.dispatchEvent = function (event) {
192
- if (event && event.type === "message" && typeof event.data === "string") {
193
- const rewritten = rewriteWebSocketFrame(event.data);
194
- if (rewritten !== null) {
195
- return origDispatch.call(
196
- this,
197
- new MessageEvent("message", {
198
- data: rewritten,
199
- origin: event.origin,
200
- lastEventId: event.lastEventId,
201
- ports: event.ports ? [...event.ports] : [],
202
- source: event.source ?? null,
203
- }),
204
- );
205
- }
206
- }
207
- return origDispatch.call(this, event);
208
- };
209
-
210
- const descriptor = Object.getOwnPropertyDescriptor(proto, "onmessage");
211
- if (descriptor && descriptor.set && descriptor.get) {
212
- Object.defineProperty(proto, "onmessage", {
213
- configurable: true,
214
- enumerable: descriptor.enumerable,
215
- get: descriptor.get,
216
- set(value) {
217
- descriptor.set.call(this, typeof value === "function" ? wrapMessageListener(value) : value);
218
- },
219
- });
220
- }
221
-
222
- proto.addEventListener.__perfSkinFilter = FILTER_VERSION;
223
- }
224
-
225
- function installFetchFilter() {
226
- const original = window.fetch;
227
- if (typeof original !== "function" || original.__perfSkinFilter === FILTER_VERSION) return;
228
- if (!rawFetch) rawFetch = original.__perfOriginalFetch || original;
229
-
230
- const wrapped = function (input, init) {
231
- const url =
232
- typeof input === "string"
233
- ? input
234
- : typeof input?.url === "string"
235
- ? input.url
236
- : typeof input?.href === "string"
237
- ? input.href
238
- : "";
239
- const promise = original.call(this, input, init);
240
- if (!filterState.active || currentChampionId === null || url.split("?")[0].indexOf(CAROUSEL_URI) === -1) return promise;
241
- return promise.then((response) => {
242
- if (!response || !response.ok) return response;
243
- let body;
244
- try {
245
- body = response.clone();
246
- } catch {
247
- return response;
248
- }
249
- return body
250
- .json()
251
- .catch(() => null)
252
- .then((json) => {
253
- const kept = filterSkinList(json);
254
- recordClientOrder(kept ?? json);
255
- if (!kept) return response;
256
- return new Response(JSON.stringify(kept), {
257
- status: response.status,
258
- statusText: response.statusText,
259
- headers: response.headers,
260
- });
261
- });
262
- });
263
- };
264
- wrapped.__perfSkinFilter = FILTER_VERSION;
265
- wrapped.__perfOriginalFetch = rawFetch;
266
- window.fetch = wrapped;
267
- }
268
-
269
- function installXhrFilter() {
270
- const proto = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
271
- if (!proto || proto.open.__perfSkinFilter === FILTER_VERSION) return;
272
-
273
- const origOpen = proto.open;
274
- const origSend = proto.send;
275
- const textDescriptor = Object.getOwnPropertyDescriptor(proto, "responseText");
276
- const responseDescriptor = Object.getOwnPropertyDescriptor(proto, "response");
277
-
278
- proto.open = function (method, url, ...rest) {
279
- this.__perfSkinUrl = typeof url === "string" ? url : "";
280
- return origOpen.call(this, method, url, ...rest);
281
- };
282
- proto.open.__perfSkinFilter = FILTER_VERSION;
283
-
284
- proto.send = function (...args) {
285
- const url = this.__perfSkinUrl || "";
286
- if (textDescriptor && textDescriptor.get && url.split("?")[0].indexOf(CAROUSEL_URI) !== -1) {
287
- const xhr = this;
288
- const cache = { raw: null, filtered: null };
289
- const filteredText = () => {
290
- const raw = textDescriptor.get.call(xhr);
291
- if (!filterState.active || currentChampionId === null) return raw;
292
- if (cache.filtered === null || cache.raw !== raw) {
293
- cache.raw = raw;
294
- cache.filtered = raw;
295
- try {
296
- const json = JSON.parse(raw);
297
- const kept = filterSkinList(json);
298
- recordClientOrder(kept ?? json);
299
- if (kept) cache.filtered = JSON.stringify(kept);
300
- } catch {
301
- }
302
- }
303
- return cache.filtered;
304
- };
305
- Object.defineProperty(xhr, "responseText", { configurable: true, get: filteredText });
306
- if (responseDescriptor && responseDescriptor.get) {
307
- Object.defineProperty(xhr, "response", {
308
- configurable: true,
309
- get() {
310
- return xhr.responseType === "" || xhr.responseType === "text" ? filteredText() : responseDescriptor.get.call(xhr);
311
- },
312
- });
313
- }
314
- }
315
- return origSend.apply(this, args);
316
- };
317
- }
318
-
319
- function installSourceFilter() {
320
- try {
321
- filterState.active = readDefaultEnabled();
322
- installArrayFilter();
323
- installWebSocketFilter();
324
- installFetchFilter();
325
- installXhrFilter();
326
- } catch (error) {
327
- logError("could not install the source filter.", error);
328
- }
329
- }
330
-
331
- function ensureSourceFilter() {
332
- try {
333
- installArrayFilter();
334
- installWebSocketFilter();
335
- installFetchFilter();
336
- installXhrFilter();
337
- } catch (error) {
338
- logError("could not refresh the source filter.", error);
339
- }
340
- }
341
-
342
- let api = null;
343
- let summonerId = null;
344
- let ownedSkinIds = null;
345
- let allSkinIds = null;
346
- let topLevelOrder = [];
347
- let ownedTopLevelIds = null;
348
- let currentChampionId = null;
349
- let carouselOrder = [];
350
- let failure = null;
351
- let requestToken = 0;
352
- let refreshTimer = null;
353
- let unsubscribe = null;
354
- let pollInFlight = false;
355
- let selectInFlight = false;
356
- let sessionMisses = 0;
357
- let championMisses = 0;
358
- let renderObserver = null;
359
- let removeStyle = null;
360
-
361
- function logError(scope, error) {
362
- console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
363
- }
364
-
365
- function skinSuffixOf(text) {
366
- const numbered = text.match(/Skins\/Skin(\d+)\//i) ?? text.match(/skin(\d+)\//i);
367
- if (numbered) return Number(numbered[1]);
368
- return /Skins\/Base\//i.test(text) ? 0 : null;
369
- }
370
-
371
- const skinIdCache = new WeakMap();
372
-
373
- function readSkinId(item) {
374
- const img = item.querySelector("img[src]");
375
- const src = img ? img.getAttribute("src") || "" : "";
376
- const art = item.querySelector('[style*="background-image"]');
377
- const background = art ? art.style.backgroundImage || "" : "";
378
- const key = src + "|" + background;
379
- const cached = skinIdCache.get(item);
380
- if (cached && cached.key === key) return cached.id;
381
- let suffix = src ? skinSuffixOf(src) : null;
382
- if (suffix === null && background) suffix = skinSuffixOf(background);
383
- if (suffix === null) suffix = skinSuffixOf(item.getAttribute("style") || "");
384
- const id = suffix === null || currentChampionId === null ? null : currentChampionId * 1000 + suffix;
385
- skinIdCache.set(item, { key, id });
386
- return id;
387
- }
388
-
389
- function hideTargetFor(item) {
390
- let target = item;
391
- for (let depth = 0; depth < 2; depth++) {
392
- const parent = target.parentElement;
393
- if (!parent || parent.children.length !== 1) break;
394
- target = parent;
395
- }
396
- return target;
397
- }
398
-
399
- function setHidden(element, hide) {
400
- if (element.classList.contains(HIDDEN_CLASS) !== hide) element.classList.toggle(HIDDEN_CLASS, hide);
401
- }
402
-
403
- function applyAll() {
404
- const items = [...document.querySelectorAll(SKIN_ITEM_SELECTOR)];
405
- let hidden = 0;
406
- items.forEach((item) => {
407
- const skinId = currentChampionId === null ? null : readSkinId(item);
408
- const hide = ownedSkinIds !== null && skinId !== null && allSkinIds !== null && allSkinIds.has(skinId) && !ownedSkinIds.has(skinId);
409
- if (hide) hidden++;
410
- setHidden(item, hide);
411
- setHidden(hideTargetFor(item), hide);
412
- });
413
-
414
- const dots = [...document.querySelectorAll(DOT_SELECTOR)];
415
- const dotOrder =
416
- topLevelOrder.length === dots.length
417
- ? topLevelOrder
418
- : carouselOrder.length === dots.length
419
- ? carouselOrder
420
- : [];
421
- let dotsHidden = 0;
422
- const dotsMapped = dotOrder.length > 0;
423
- dots.forEach((dot, index) => {
424
- const skinId = dotsMapped ? dotOrder[index] : undefined;
425
- const hide = ownedTopLevelIds !== null && skinId !== undefined && !ownedTopLevelIds.has(skinId);
426
- if (hide) dotsHidden++;
427
- setHidden(dot, hide);
428
- });
429
- }
430
-
431
- let applyQueued = false;
432
-
433
- function scheduleApply() {
434
- if (applyQueued) return;
435
- applyQueued = true;
436
- requestAnimationFrame(() => {
437
- applyQueued = false;
438
- ensureSourceFilter();
439
- if (currentChampionId === null) return;
440
- const item = document.querySelector(SKIN_ITEM_SELECTOR);
441
- if (!item && !document.querySelector(DOT_SELECTOR)) return;
442
- applyAll();
443
- if (item) scheduleRefresh();
444
- });
445
- }
446
-
447
- async function loadOwnedSkins(championId) {
448
- if (summonerId === null) {
449
- const summoner = await api.lcu.get(CURRENT_SUMMONER_URI, { ttl: SUMMONER_TTL });
450
- summonerId = summoner.summonerId;
451
- }
452
- const skins = await api.lcu.get(
453
- `/lol-champions/v1/inventories/${summonerId}/champions/${championId}/skins`,
454
- { ttl: INVENTORY_TTL },
455
- );
456
- if (!Array.isArray(skins) || skins.length === 0) {
457
- throw new Error(`no skin inventory returned for champion ${championId}`);
458
- }
459
- const all = new Set();
460
- const owned = new Set();
461
- const topLevel = [];
462
- const ownedTopLevel = new Set();
463
- for (const skin of skins) {
464
- if (typeof skin?.id === "number") {
465
- topLevel.push(skin.id);
466
- if (skin.ownership?.owned) ownedTopLevel.add(skin.id);
467
- }
468
- const variants = [
469
- skin,
470
- ...(Array.isArray(skin.chromas) ? skin.chromas : []),
471
- ...(Array.isArray(skin.questSkinInfo?.tiers) ? skin.questSkinInfo.tiers : []),
472
- ];
473
- for (const variant of variants) {
474
- if (typeof variant?.id !== "number") continue;
475
- all.add(variant.id);
476
- if (variant.ownership?.owned) owned.add(variant.id);
477
- }
478
- }
479
- const order = [...all].sort((a, b) => a - b);
480
- return { owned, order, topLevel, ownedTopLevel };
481
- }
482
-
483
- async function loadCarouselOrder() {
484
- const preferred = clientSkinOrder.length > 0 ? [...clientSkinOrder] : null;
485
- try {
486
- let skins;
487
- if (typeof rawFetch === "function") {
488
- let timer;
489
- try {
490
- const response = await Promise.race([
491
- rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } }),
492
- new Promise((_, reject) => {
493
- timer = setTimeout(() => reject(new Error("carousel request timed out")), 8000);
494
- }),
495
- ]);
496
- skins = response.ok ? await response.json() : null;
497
- } finally {
498
- clearTimeout(timer);
499
- }
500
- } else {
501
- skins = await api.lcu.get(CAROUSEL_URI);
502
- }
503
- learnSkinShape(skins);
504
- if (preferred) return preferred;
505
- if (!Array.isArray(skins)) return [];
506
- return skins.map((skin) => skin?.id).filter((id) => typeof id === "number");
507
- } catch {
508
- return preferred ?? [];
509
- }
510
- }
511
-
512
- function nextOwnedSkin(owned, fromId, direction) {
513
- const ids = [...owned].sort((a, b) => a - b);
514
- if (ids.length === 0) return null;
515
- if (direction > 0) return ids.find((id) => id > fromId) ?? ids[0];
516
- return [...ids].reverse().find((id) => id < fromId) ?? ids[ids.length - 1];
517
- }
518
-
519
- async function selectSkin(skinId) {
520
- selectInFlight = true;
521
- const guard = setTimeout(() => {
522
- selectInFlight = false;
523
- }, 8000);
524
- try {
525
- await api.lcu.send("PATCH", SELECTION_URI, { selectedSkinId: skinId });
526
- } finally {
527
- clearTimeout(guard);
528
- selectInFlight = false;
529
- }
530
- }
531
-
532
- function resetState() {
533
- requestToken++;
534
- ownedSkinIds = null;
535
- allSkinIds = null;
536
- topLevelOrder = [];
537
- ownedTopLevelIds = null;
538
- currentChampionId = null;
539
- carouselOrder = [];
540
- clientSkinOrder = [];
541
- failure = null;
542
- sessionMisses = 0;
543
- championMisses = 0;
544
- applyAll();
545
- }
546
-
547
- function loadChampion(championId) {
548
- currentChampionId = championId;
549
- ownedSkinIds = null;
550
- allSkinIds = null;
551
- topLevelOrder = [];
552
- ownedTopLevelIds = null;
553
- failure = null;
554
- clientSkinOrder = [];
555
- const token = ++requestToken;
556
-
557
- Promise.all([loadOwnedSkins(championId), loadCarouselOrder()]).then(
558
- ([{ owned, order: inventoryOrder, topLevel, ownedTopLevel }, carouselIds]) => {
559
- if (token !== requestToken) return;
560
- ownedSkinIds = owned;
561
- allSkinIds = new Set(inventoryOrder);
562
- topLevelOrder = topLevel;
563
- ownedTopLevelIds = ownedTopLevel;
564
- carouselOrder = carouselIds.length > 0 ? carouselIds : inventoryOrder;
565
- applyAll();
566
- },
567
- (error) => {
568
- if (token !== requestToken) return;
569
- logError(`could not load owned skins for champion ${championId}.`, error);
570
- failure = { championId, message: error.message, retryAt: Date.now() + RETRY_MS };
571
- currentChampionId = null;
572
- },
573
- );
574
- }
575
-
576
- function updateForSession(session, selection) {
577
- const me = session?.myTeam?.find((player) => player.cellId === session.localPlayerCellId);
578
- const championId = me?.championId || me?.championPickIntent || null;
579
-
580
- if (!championId) {
581
- championMisses++;
582
- if (championMisses >= 2) resetState();
583
- return;
584
- }
585
- championMisses = 0;
586
-
587
- if (championId !== currentChampionId) {
588
- if (failure?.championId === championId && Date.now() < failure.retryAt) return;
589
- loadChampion(championId);
590
- }
591
-
592
- if (ownedSkinIds === null) return;
593
-
594
- const serverSkinId = selection?.selectedSkinId || me.selectedSkinId || null;
595
-
596
- const knownUnownedSkin = allSkinIds !== null && allSkinIds.has(serverSkinId) && !ownedSkinIds.has(serverSkinId);
597
- if (!selectInFlight && knownUnownedSkin) {
598
- const fallback = nextOwnedSkin(ownedTopLevelIds ?? ownedSkinIds, 0, 1);
599
- if (fallback !== null) {
600
- selectSkin(fallback).catch((error) => logError("could not correct the selected skin.", error));
601
- }
602
- }
603
-
604
- applyAll();
605
- }
606
-
607
- function scheduleRefresh() {
608
- clearTimeout(refreshTimer);
609
- refreshTimer = setTimeout(pollSession, REFRESH_DEBOUNCE_MS);
610
- }
611
-
612
- function onSessionPush(raw) {
613
- const msg = raw ?? {};
614
- if (msg.eventType === "Delete") {
615
- resetState();
616
- return;
617
- }
618
- const s = msg.data ?? msg;
619
- if (!s?.myTeam) return;
620
- updateForSession(s, null);
621
- }
622
-
623
- async function pollSession() {
624
- if (pollInFlight || !api) return;
625
- ensureSourceFilter();
626
- pollInFlight = true;
627
- try {
628
- const session = await api.lcu.get(SESSION_URI);
629
- sessionMisses = 0;
630
- const selection = await api.lcu.get(SELECTION_URI).catch(() => null);
631
- updateForSession(session, selection);
632
- } catch {
633
- sessionMisses++;
634
- if (sessionMisses >= 3) resetState();
635
- } finally {
636
- pollInFlight = false;
637
- }
638
- }
639
-
640
- function onEnable(utils) {
641
- api = utils;
642
- filterState.active = true;
643
- removeStyle = api.addStyle(STYLE_ID, CSS);
644
-
645
- renderObserver = new MutationObserver(scheduleApply);
646
- renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
647
-
648
- unsubscribe = api.subscribe(SESSION_URI, onSessionPush);
649
- pollSession();
650
- }
651
-
652
- function onDisable() {
653
- unsubscribe?.();
654
- unsubscribe = null;
655
- clearTimeout(refreshTimer);
656
- refreshTimer = null;
657
- renderObserver?.disconnect();
658
- removeStyle?.();
659
- renderObserver = null;
660
- removeStyle = null;
661
- filterState.active = false;
662
-
663
- resetState();
664
- document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((item) => item.classList.remove(HIDDEN_CLASS));
665
- api = null;
666
- }
667
-
668
- export function init() {
669
- installSourceFilter();
670
-
671
- const registerWith = (utils) => {
672
- utils.registerPlugin({
673
- id: PLUGIN_ID,
674
- name: "Hide Unowned Skins",
675
- description: "In champion select, only shows the skins you own for the picked champion.",
676
- version,
677
- onEnable,
678
- onDisable,
679
- });
680
- };
681
-
682
- if (window.PerfectionistUtils) {
683
- registerWith(window.PerfectionistUtils);
684
- } else {
685
- addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail), { once: true });
686
- }
687
- }
1
+ export const version = "3.0.0";
2
+
3
+ const PLUGIN_ID = "hide-unowned-skins";
4
+
5
+ // Champ select reads the skin carousel through its own (rcp-fe-lol-champ-select) data binding. Removing
6
+ // the locked skins there means the client itself builds the carousel from owned skins only: spacing,
7
+ // arrows, dots and keyboard navigation all stay native, and nothing else in the client is affected.
8
+ const CHAMP_SELECT_PLUGIN = "rcp-fe-lol-champ-select";
9
+ const CAROUSEL_PATH = "/lol-champ-select/v1/skin-carousel-skins";
10
+
11
+ let handle = null;
12
+
13
+ function usable(skin) {
14
+ return Boolean(skin) && (skin.unlocked === true || skin.awUnlocked === true || skin.ownership?.owned === true);
15
+ }
16
+
17
+ function ownedOnly(skins) {
18
+ if (!Array.isArray(skins)) return skins;
19
+ const kept = [];
20
+ for (const skin of skins) {
21
+ if (!skin || typeof skin !== "object") continue;
22
+ // The base skin always stays, so the carousel is never empty.
23
+ if (!skin.isBase && !usable(skin)) continue;
24
+ if (Array.isArray(skin.childSkins) && skin.childSkins.some((child) => !usable(child))) {
25
+ kept.push({ ...skin, childSkins: skin.childSkins.filter(usable) });
26
+ } else {
27
+ kept.push(skin);
28
+ }
29
+ }
30
+ return kept;
31
+ }
32
+
33
+ function onEnable(utils) {
34
+ handle = utils.transformBinding({
35
+ identifier: CHAMP_SELECT_PLUGIN,
36
+ match: (path) => path === CAROUSEL_PATH,
37
+ transform: ownedOnly,
38
+ });
39
+ }
40
+
41
+ function onDisable() {
42
+ handle?.dispose();
43
+ handle = null;
44
+ }
45
+
46
+ export function init() {
47
+ const registerWith = (utils) => {
48
+ utils.registerPlugin({
49
+ id: PLUGIN_ID,
50
+ name: "Hide Unowned Skins",
51
+ description: "In champion select, only shows the skins you own for the picked champion.",
52
+ version,
53
+ onEnable,
54
+ onDisable,
55
+ });
56
+ };
57
+
58
+ if (window.PerfectionistUtils) {
59
+ registerWith(window.PerfectionistUtils);
60
+ } else {
61
+ addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail ?? window.PerfectionistUtils), { once: true });
62
+ }
63
+ }