perfectionist-plugins 2.0.0 → 2.4.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,11 +1,10 @@
1
- export const version = "2.4.0";
1
+ export const version = "2.4.3";
2
2
 
3
3
  const PLUGIN_ID = "hide-unowned-skins";
4
4
  const STYLE_ID = "perfectionist-hide-unowned-skins-style";
5
5
  const HIDDEN_CLASS = "perf-unowned-skin-hidden";
6
6
  const SKIN_ITEM_SELECTOR = ".skin-selection-item";
7
7
  const DOT_SELECTOR = ".skin-selection-indicator-selector";
8
- const SELECTED_CLASS = "skin-selection-item-selected";
9
8
  const SESSION_URI = "/lol-champ-select/v1/session";
10
9
  const SELECTION_URI = "/lol-champ-select/v1/session/my-selection";
11
10
  const CAROUSEL_URI = "/lol-champ-select/v1/skin-carousel-skins";
@@ -13,24 +12,83 @@ const CURRENT_SUMMONER_URI = "/lol-summoner/v1/current-summoner";
13
12
  const SUMMONER_TTL = 10 * 60 * 1000;
14
13
  const INVENTORY_TTL = 60 * 1000;
15
14
  const REFRESH_DEBOUNCE_MS = 350;
16
- const REPACK_SETTLE_MS = 120;
17
15
  const RETRY_MS = 5000;
18
16
  const STATE_KEY = "perfectionist-plugins:state";
19
17
 
20
18
  const CSS = `
21
19
  .${HIDDEN_CLASS} { display: none !important; margin: 0 !important; padding: 0 !important; border: none !important; }
22
- [data-perf-dots] { justify-content: center !important; }
20
+ .perf-native-hidden { display: none !important; }
23
21
  `;
24
22
 
25
23
  const OBSERVER_OPTIONS = {
26
24
  childList: true,
27
25
  subtree: true,
28
26
  attributes: true,
29
- attributeFilter: ["class", "style", "src"],
27
+ attributeFilter: ["src"],
30
28
  };
31
29
 
32
- let filterActive = true;
33
- let rawFetch = null;
30
+ const FILTER_VERSION = "2.4.3";
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 || !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
+ filterStats.array = (filterStats.array || 0) + 1;
82
+ filterStats.source = "array";
83
+ filterStats.lastIn = result.length;
84
+ filterStats.lastOut = kept.length;
85
+ return kept;
86
+ };
87
+ wrapped.__perfSkinFilter = FILTER_VERSION;
88
+ proto.filter = wrapped;
89
+ }
90
+ const filterStats = (globalThis.__perfSkinFilterStats ??= { carousel: 0, source: "", lastIn: 0, lastOut: 0 });
91
+ let rawFetch = (typeof window !== "undefined" && window.fetch && window.fetch.__perfOriginalFetch) || null;
34
92
  let clientSkinOrder = [];
35
93
 
36
94
  function readDefaultEnabled() {
@@ -47,15 +105,31 @@ function isOwnedSkin(skin) {
47
105
  const id = typeof skin.id === "number" ? skin.id : null;
48
106
  if (id === null) return true;
49
107
  if (id % 1000 === 0) return true;
108
+ if (ownedSkinIds && allSkinIds && allSkinIds.has(id)) return ownedSkinIds.has(id);
50
109
  const ownership = skin.ownership;
51
110
  if (ownership && typeof ownership.owned === "boolean") return ownership.owned;
111
+ if (typeof skin.owned === "boolean") return skin.owned;
112
+ if (typeof skin.unlocked === "boolean") return skin.unlocked;
52
113
  return true;
53
114
  }
54
115
 
55
116
  function filterSkinList(list) {
56
117
  if (!Array.isArray(list)) return null;
57
- const kept = list.filter(isOwnedSkin);
58
- return kept.length === list.length ? null : kept;
118
+ if (!skinShapeKeys && isSkinCollection(list)) learnSkinShape(list);
119
+ let changed = false;
120
+ const filtered = list
121
+ .map((skin) => {
122
+ if (!skin || !Array.isArray(skin.childSkins)) return skin;
123
+ const childSkins = skin.childSkins.filter(isOwnedSkin);
124
+ if (childSkins.length === skin.childSkins.length) return skin;
125
+ changed = true;
126
+ return Object.assign({}, skin, { childSkins });
127
+ })
128
+ .filter(isOwnedSkin);
129
+ if (filtered.length !== list.length) changed = true;
130
+ filterStats.lastIn = list.length;
131
+ filterStats.lastOut = filtered.length;
132
+ return changed ? filtered : null;
59
133
  }
60
134
 
61
135
  function recordClientOrder(list) {
@@ -66,7 +140,7 @@ function recordClientOrder(list) {
66
140
  }
67
141
 
68
142
  function rewriteWebSocketFrame(text) {
69
- if (!filterActive || typeof text !== "string") return null;
143
+ if (!filterState.active || typeof text !== "string") return null;
70
144
  if (text.indexOf("skin-carousel-skins") === -1) return null;
71
145
  let frame;
72
146
  try {
@@ -79,6 +153,8 @@ function rewriteWebSocketFrame(text) {
79
153
  if (!payload || typeof payload !== "object" || typeof payload.uri !== "string") return null;
80
154
  if (payload.uri.split("?")[0] !== CAROUSEL_URI) return null;
81
155
  const kept = filterSkinList(payload.data);
156
+ filterStats.carousel++;
157
+ filterStats.source = "ws";
82
158
  recordClientOrder(kept ?? payload.data);
83
159
  if (!kept) return null;
84
160
  return JSON.stringify([frame[0], frame[1], Object.assign({}, payload, { data: kept })]);
@@ -109,7 +185,7 @@ function wrapMessageListener(listener) {
109
185
 
110
186
  function installWebSocketFilter() {
111
187
  const proto = window.WebSocket && window.WebSocket.prototype;
112
- if (!proto || proto.addEventListener.__perfSkinFilter) return;
188
+ if (!proto || proto.addEventListener.__perfSkinFilter === FILTER_VERSION) return;
113
189
 
114
190
  const origAdd = proto.addEventListener;
115
191
  const origRemove = proto.removeEventListener;
@@ -152,13 +228,14 @@ function installWebSocketFilter() {
152
228
  });
153
229
  }
154
230
 
155
- proto.addEventListener.__perfSkinFilter = true;
231
+ proto.addEventListener.__perfSkinFilter = FILTER_VERSION;
156
232
  }
157
233
 
158
234
  function installFetchFilter() {
159
235
  const original = window.fetch;
160
- if (typeof original !== "function" || original.__perfSkinFilter) return;
161
- if (!rawFetch) rawFetch = original;
236
+ if (typeof original !== "function" || original.__perfSkinFilter === FILTER_VERSION) return;
237
+ if (!rawFetch) rawFetch = original.__perfOriginalFetch || original;
238
+ globalThis.__perfNativeFetch = rawFetch;
162
239
 
163
240
  const wrapped = function (input, init) {
164
241
  const url =
@@ -170,15 +247,22 @@ function installFetchFilter() {
170
247
  ? input.href
171
248
  : "";
172
249
  const promise = original.call(this, input, init);
173
- if (!filterActive || url.split("?")[0].indexOf(CAROUSEL_URI) === -1) return promise;
250
+ if (!filterState.active || url.split("?")[0].indexOf(CAROUSEL_URI) === -1) return promise;
174
251
  return promise.then((response) => {
175
252
  if (!response || !response.ok) return response;
176
- return response
177
- .clone()
253
+ let body;
254
+ try {
255
+ body = response.clone();
256
+ } catch {
257
+ return response;
258
+ }
259
+ return body
178
260
  .json()
179
261
  .catch(() => null)
180
262
  .then((json) => {
181
263
  const kept = filterSkinList(json);
264
+ filterStats.carousel++;
265
+ filterStats.source = "fetch";
182
266
  recordClientOrder(kept ?? json);
183
267
  if (!kept) return response;
184
268
  return new Response(JSON.stringify(kept), {
@@ -189,13 +273,14 @@ function installFetchFilter() {
189
273
  });
190
274
  });
191
275
  };
192
- wrapped.__perfSkinFilter = true;
276
+ wrapped.__perfSkinFilter = FILTER_VERSION;
277
+ wrapped.__perfOriginalFetch = rawFetch;
193
278
  window.fetch = wrapped;
194
279
  }
195
280
 
196
281
  function installXhrFilter() {
197
282
  const proto = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
198
- if (!proto || proto.open.__perfSkinFilter) return;
283
+ if (!proto || proto.open.__perfSkinFilter === FILTER_VERSION) return;
199
284
 
200
285
  const origOpen = proto.open;
201
286
  const origSend = proto.send;
@@ -206,7 +291,7 @@ function installXhrFilter() {
206
291
  this.__perfSkinUrl = typeof url === "string" ? url : "";
207
292
  return origOpen.call(this, method, url, ...rest);
208
293
  };
209
- proto.open.__perfSkinFilter = true;
294
+ proto.open.__perfSkinFilter = FILTER_VERSION;
210
295
 
211
296
  proto.send = function (...args) {
212
297
  const url = this.__perfSkinUrl || "";
@@ -215,13 +300,15 @@ function installXhrFilter() {
215
300
  const cache = { raw: null, filtered: null };
216
301
  const filteredText = () => {
217
302
  const raw = textDescriptor.get.call(xhr);
218
- if (!filterActive) return raw;
303
+ if (!filterState.active) return raw;
219
304
  if (cache.filtered === null || cache.raw !== raw) {
220
305
  cache.raw = raw;
221
306
  cache.filtered = raw;
222
307
  try {
223
308
  const json = JSON.parse(raw);
224
309
  const kept = filterSkinList(json);
310
+ filterStats.carousel++;
311
+ filterStats.source = "xhr";
225
312
  recordClientOrder(kept ?? json);
226
313
  if (kept) cache.filtered = JSON.stringify(kept);
227
314
  } catch {
@@ -245,7 +332,8 @@ function installXhrFilter() {
245
332
 
246
333
  function installSourceFilter() {
247
334
  try {
248
- filterActive = readDefaultEnabled();
335
+ filterState.active = readDefaultEnabled();
336
+ installArrayFilter();
249
337
  installWebSocketFilter();
250
338
  installFetchFilter();
251
339
  installXhrFilter();
@@ -256,6 +344,7 @@ function installSourceFilter() {
256
344
 
257
345
  function ensureSourceFilter() {
258
346
  try {
347
+ installArrayFilter();
259
348
  installWebSocketFilter();
260
349
  installFetchFilter();
261
350
  installXhrFilter();
@@ -268,10 +357,11 @@ let api = null;
268
357
  let summonerId = null;
269
358
  let ownedSkinIds = null;
270
359
  let allSkinIds = null;
360
+ let topLevelOrder = [];
361
+ let ownedTopLevelIds = null;
271
362
  let currentChampionId = null;
272
363
  let carouselOrder = [];
273
364
  let selectedSkinId = null;
274
- let lastServerSkinId = null;
275
365
  let failure = null;
276
366
  let requestToken = 0;
277
367
  let refreshTimer = null;
@@ -281,15 +371,8 @@ let selectInFlight = false;
281
371
  let sessionMisses = 0;
282
372
  let championMisses = 0;
283
373
  let renderObserver = null;
284
- const repackOriginals = new Map();
285
- const lastAppliedLeft = new Map();
286
374
  const skinSuffixCache = new Map();
287
- let repackTimer = null;
288
375
  let removeStyle = null;
289
- let lastSelectedDomIndex = -1;
290
- let navFixAt = 0;
291
- let lastSkipSignature = null;
292
- const widthCache = new Map();
293
376
 
294
377
  function logError(scope, error) {
295
378
  console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
@@ -301,18 +384,22 @@ function skinSuffixOf(text) {
301
384
  return /Skins\/Base\//i.test(text) ? 0 : null;
302
385
  }
303
386
 
387
+ const skinIdCache = new WeakMap();
388
+
304
389
  function readSkinId(item) {
305
- for (const img of item.querySelectorAll("img[src]")) {
306
- const src = img.getAttribute("src");
307
- let suffix = skinSuffixCache.get(src);
308
- if (suffix === undefined) {
309
- suffix = skinSuffixOf(src);
310
- skinSuffixCache.set(src, suffix);
311
- }
312
- if (suffix !== null) return currentChampionId * 1000 + suffix;
313
- }
314
- const suffix = skinSuffixOf(item.innerHTML);
315
- return suffix === null ? null : currentChampionId * 1000 + suffix;
390
+ const img = item.querySelector("img[src]");
391
+ const src = img ? img.getAttribute("src") || "" : "";
392
+ const art = item.querySelector('[style*="background-image"]');
393
+ const background = art ? art.style.backgroundImage || "" : "";
394
+ const key = src + "|" + background;
395
+ const cached = skinIdCache.get(item);
396
+ if (cached && cached.key === key) return cached.id;
397
+ let suffix = src ? skinSuffixOf(src) : null;
398
+ if (suffix === null && background) suffix = skinSuffixOf(background);
399
+ if (suffix === null) suffix = skinSuffixOf(item.getAttribute("style") || "");
400
+ const id = suffix === null || currentChampionId === null ? null : currentChampionId * 1000 + suffix;
401
+ skinIdCache.set(item, { key, id });
402
+ return id;
316
403
  }
317
404
 
318
405
  function hideTargetFor(item) {
@@ -325,123 +412,8 @@ function hideTargetFor(item) {
325
412
  return target;
326
413
  }
327
414
 
328
- function pruneRepack() {
329
- for (const item of [...repackOriginals.keys()]) {
330
- if (item.isConnected) continue;
331
- repackOriginals.delete(item);
332
- lastAppliedLeft.delete(item);
333
- }
334
- }
335
-
336
- function guardSelection(items) {
337
- const selectedIndex = items.findIndex((item) => item.classList.contains(SELECTED_CLASS));
338
- if (selectedIndex < 0) return false;
339
- const previous = lastSelectedDomIndex;
340
- lastSelectedDomIndex = selectedIndex;
341
- const selected = items[selectedIndex];
342
- const hidden = selected.classList.contains(HIDDEN_CLASS) || Boolean(selected.parentElement?.classList.contains(HIDDEN_CLASS));
343
- if (!hidden) {
344
- lastSkipSignature = null;
345
- return false;
346
- }
347
-
348
- const visible = items.map((item, index) => (item.classList.contains(HIDDEN_CLASS) ? -1 : index)).filter((index) => index >= 0);
349
- if (visible.length === 0) return false;
350
-
351
- const forward = previous < 0 ? true : selectedIndex >= previous;
352
- const target = forward
353
- ? (visible.find((index) => index > selectedIndex) ?? visible[0])
354
- : ([...visible].reverse().find((index) => index < selectedIndex) ?? visible[visible.length - 1]);
355
- const skinId = readSkinId(items[target]);
356
- if (skinId === null) return false;
357
-
358
- const signature = `${selectedIndex}:${skinId}`;
359
- if (lastSkipSignature === signature) return true;
360
- const now = Date.now();
361
- if (now < navFixAt) return true;
362
- navFixAt = now + 150;
363
- lastSkipSignature = signature;
364
- lastSelectedDomIndex = target;
365
- selectSkin(skinId).catch((error) => logError("could not skip the unowned skin.", error));
366
- return true;
367
- }
368
-
369
- function repackVisibleItems(items) {
370
- pruneRepack();
371
- const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
372
- if (visible.length < 2) {
373
- restoreRepack();
374
- return;
375
- }
376
-
377
- const selectedIndex = items.findIndex((item) => item.classList.contains("skin-selection-item-selected"));
378
- let anchor = selectedIndex >= 0 ? items[selectedIndex] : null;
379
- if (!anchor && selectedSkinId !== null) {
380
- anchor = visible.find((item) => readSkinId(item) === selectedSkinId) ?? null;
381
- }
382
- if (!anchor) anchor = visible[Math.floor(visible.length / 2)];
383
- const anchorDomIndex = items.indexOf(anchor);
384
-
385
- const widthOf = (item) => {
386
- const measured = item.getBoundingClientRect().width;
387
- if (measured > 0) {
388
- widthCache.set(item, measured);
389
- return measured;
390
- }
391
- return widthCache.get(item) ?? (item === anchor ? 117 : 73);
392
- };
393
- const toPx = (item) => {
394
- const raw = item.style.left || "0";
395
- if (raw.endsWith("px")) return parseFloat(raw);
396
- const box = item.parentElement?.getBoundingClientRect().width || 468;
397
- return (parseFloat(raw) / 100) * box;
398
- };
399
-
400
- const anchorLeft = toPx(anchor);
401
- let rightX = anchorLeft + widthOf(anchor);
402
- let leftX = anchorLeft;
403
-
404
- const setLeft = (item, x) => {
405
- const want = `${x}px`;
406
- const current = item.style.getPropertyValue("left");
407
- const priority = item.style.getPropertyPriority("left");
408
- const last = lastAppliedLeft.get(item);
409
- const clientMoved = last !== undefined && (current !== last || priority !== "important");
410
- if (clientMoved || !repackOriginals.has(item)) {
411
- repackOriginals.set(item, { value: current, priority });
412
- }
413
- if (current !== want || priority !== "important") {
414
- item.style.setProperty("left", want, "important");
415
- }
416
- lastAppliedLeft.set(item, want);
417
- };
418
-
419
- items.forEach((item, index) => {
420
- if (item === anchor || item.classList.contains(HIDDEN_CLASS)) return;
421
-
422
- const isRight = index > anchorDomIndex;
423
- const w = widthOf(item);
424
- if (isRight) {
425
- setLeft(item, rightX);
426
- rightX += w;
427
- } else {
428
- leftX -= w;
429
- setLeft(item, leftX);
430
- }
431
- });
432
- }
433
-
434
- function restoreRepack() {
435
- for (const [item, original] of repackOriginals) {
436
- if (!item.isConnected) continue;
437
- const current = item.style.getPropertyValue("left");
438
- const last = lastAppliedLeft.get(item);
439
- if (last !== undefined && (current !== last || item.style.getPropertyPriority("left") !== "important")) continue;
440
- if (original.value) item.style.setProperty("left", original.value, original.priority || undefined);
441
- else item.style.removeProperty("left");
442
- }
443
- repackOriginals.clear();
444
- lastAppliedLeft.clear();
415
+ function setHidden(element, hide) {
416
+ if (element.classList.contains(HIDDEN_CLASS) !== hide) element.classList.toggle(HIDDEN_CLASS, hide);
445
417
  }
446
418
 
447
419
  function applyAll() {
@@ -451,56 +423,44 @@ function applyAll() {
451
423
  const skinId = currentChampionId === null ? null : readSkinId(item);
452
424
  const hide = ownedSkinIds !== null && skinId !== null && allSkinIds !== null && allSkinIds.has(skinId) && !ownedSkinIds.has(skinId);
453
425
  if (hide) hidden++;
454
- item.classList.toggle(HIDDEN_CLASS, hide);
455
- hideTargetFor(item).classList.toggle(HIDDEN_CLASS, hide);
426
+ setHidden(item, hide);
427
+ setHidden(hideTargetFor(item), hide);
456
428
  });
457
429
 
458
430
  const dots = [...document.querySelectorAll(DOT_SELECTOR)];
431
+ const dotOrder =
432
+ topLevelOrder.length === dots.length
433
+ ? topLevelOrder
434
+ : carouselOrder.length === dots.length
435
+ ? carouselOrder
436
+ : [];
459
437
  let dotsHidden = 0;
460
- const dotsMapped = carouselOrder.length > 0 && dots.length === carouselOrder.length;
438
+ const dotsMapped = dotOrder.length > 0;
461
439
  dots.forEach((dot, index) => {
462
- const skinId = dotsMapped ? carouselOrder[index] : undefined;
463
- const hide = ownedSkinIds !== null && skinId !== undefined && allSkinIds !== null && allSkinIds.has(skinId) && !ownedSkinIds.has(skinId);
440
+ const skinId = dotsMapped ? dotOrder[index] : undefined;
441
+ const hide = ownedTopLevelIds !== null && skinId !== undefined && !ownedTopLevelIds.has(skinId);
464
442
  if (hide) dotsHidden++;
465
- dot.classList.toggle(HIDDEN_CLASS, hide);
443
+ setHidden(dot, hide);
466
444
  });
467
- const dotList = dots[0]?.parentElement;
468
- if (dotList) {
469
- if (dotsHidden > 0 && !dotList.hasAttribute("data-perf-dots")) dotList.setAttribute("data-perf-dots", "");
470
- else if (dotsHidden === 0 && dotList.hasAttribute("data-perf-dots")) dotList.removeAttribute("data-perf-dots");
471
- }
472
-
473
- if (ownedSkinIds !== null && hidden > 0) {
474
- if (guardSelection(items)) runRepack();
475
- else scheduleRepack();
476
- } else if (hidden === 0) restoreRepack();
477
445
  }
478
446
 
479
- function scheduleRepack() {
480
- clearTimeout(repackTimer);
481
- repackTimer = setTimeout(runRepack, REPACK_SETTLE_MS);
447
+ function applyAllGuarded() {
448
+ applyAll();
482
449
  }
483
450
 
484
- function runRepack() {
485
- repackTimer = null;
486
- renderObserver?.disconnect();
487
- try {
488
- const items = [...document.querySelectorAll(SKIN_ITEM_SELECTOR)];
489
- const hidden = items.some((item) => item.classList.contains(HIDDEN_CLASS));
490
- if (ownedSkinIds !== null && hidden) repackVisibleItems(items);
491
- else restoreRepack();
492
- } finally {
493
- renderObserver?.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
494
- }
495
- }
451
+ let applyQueued = false;
496
452
 
497
- function applyAllGuarded() {
498
- renderObserver?.disconnect();
499
- try {
453
+ function scheduleApply() {
454
+ if (applyQueued) return;
455
+ applyQueued = true;
456
+ requestAnimationFrame(() => {
457
+ applyQueued = false;
458
+ ensureSourceFilter();
459
+ const item = document.querySelector(SKIN_ITEM_SELECTOR);
460
+ if (!item && !document.querySelector(DOT_SELECTOR)) return;
500
461
  applyAll();
501
- } finally {
502
- renderObserver?.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
503
- }
462
+ if (item) scheduleRefresh();
463
+ });
504
464
  }
505
465
 
506
466
  async function loadOwnedSkins(championId) {
@@ -515,19 +475,36 @@ async function loadOwnedSkins(championId) {
515
475
  if (!Array.isArray(skins) || skins.length === 0) {
516
476
  throw new Error(`no skin inventory returned for champion ${championId}`);
517
477
  }
518
- const order = skins
519
- .map((skin) => skin?.id)
520
- .filter((id) => typeof id === "number")
521
- .sort((a, b) => a - b);
522
- return { owned: new Set(skins.filter((skin) => skin.ownership?.owned).map((skin) => skin.id)), order };
478
+ const all = new Set();
479
+ const owned = new Set();
480
+ const topLevel = [];
481
+ const ownedTopLevel = new Set();
482
+ for (const skin of skins) {
483
+ if (typeof skin?.id === "number") {
484
+ topLevel.push(skin.id);
485
+ if (skin.ownership?.owned) ownedTopLevel.add(skin.id);
486
+ }
487
+ const variants = [
488
+ skin,
489
+ ...(Array.isArray(skin.chromas) ? skin.chromas : []),
490
+ ...(Array.isArray(skin.questSkinInfo?.tiers) ? skin.questSkinInfo.tiers : []),
491
+ ];
492
+ for (const variant of variants) {
493
+ if (typeof variant?.id !== "number") continue;
494
+ all.add(variant.id);
495
+ if (variant.ownership?.owned) owned.add(variant.id);
496
+ }
497
+ }
498
+ const order = [...all].sort((a, b) => a - b);
499
+ return { owned, order, topLevel, ownedTopLevel };
523
500
  }
524
501
 
525
502
  async function loadCarouselOrder() {
526
- if (clientSkinOrder.length > 0) return [...clientSkinOrder];
503
+ const preferred = clientSkinOrder.length > 0 ? [...clientSkinOrder] : null;
527
504
  try {
528
505
  let skins;
529
- if (rawFetch) {
530
- let timer = null;
506
+ if (typeof rawFetch === "function") {
507
+ let timer;
531
508
  try {
532
509
  const response = await Promise.race([
533
510
  rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } }),
@@ -542,10 +519,12 @@ async function loadCarouselOrder() {
542
519
  } else {
543
520
  skins = await api.lcu.get(CAROUSEL_URI);
544
521
  }
522
+ learnSkinShape(skins);
523
+ if (preferred) return preferred;
545
524
  if (!Array.isArray(skins)) return [];
546
525
  return skins.map((skin) => skin?.id).filter((id) => typeof id === "number");
547
526
  } catch {
548
- return [];
527
+ return preferred ?? [];
549
528
  }
550
529
  }
551
530
 
@@ -574,6 +553,8 @@ function resetState() {
574
553
  requestToken++;
575
554
  ownedSkinIds = null;
576
555
  allSkinIds = null;
556
+ topLevelOrder = [];
557
+ ownedTopLevelIds = null;
577
558
  currentChampionId = null;
578
559
  selectedSkinId = null;
579
560
  carouselOrder = [];
@@ -588,15 +569,19 @@ function loadChampion(championId) {
588
569
  currentChampionId = championId;
589
570
  ownedSkinIds = null;
590
571
  allSkinIds = null;
572
+ topLevelOrder = [];
573
+ ownedTopLevelIds = null;
591
574
  failure = null;
592
575
  clientSkinOrder = [];
593
576
  const token = ++requestToken;
594
577
 
595
578
  Promise.all([loadOwnedSkins(championId), loadCarouselOrder()]).then(
596
- ([{ owned, order: inventoryOrder }, carouselIds]) => {
579
+ ([{ owned, order: inventoryOrder, topLevel, ownedTopLevel, entries }, carouselIds]) => {
597
580
  if (token !== requestToken) return;
598
581
  ownedSkinIds = owned;
599
582
  allSkinIds = new Set(inventoryOrder);
583
+ topLevelOrder = topLevel;
584
+ ownedTopLevelIds = ownedTopLevel;
600
585
  carouselOrder = carouselIds.length > 0 ? carouselIds : inventoryOrder;
601
586
  applyAllGuarded();
602
587
  },
@@ -633,21 +618,15 @@ function updateForSession(session, selection) {
633
618
 
634
619
  const knownUnownedSkin = allSkinIds !== null && allSkinIds.has(serverSkinId) && !ownedSkinIds.has(serverSkinId);
635
620
  if (!selectInFlight && knownUnownedSkin) {
636
- const direction = lastServerSkinId !== null && serverSkinId !== null && serverSkinId < lastServerSkinId ? -1 : 1;
637
- const fallback = nextOwnedSkin(ownedSkinIds, serverSkinId ?? 0, direction) ?? nextOwnedSkin(ownedSkinIds, 0, 1);
621
+ const fallback = nextOwnedSkin(ownedTopLevelIds ?? ownedSkinIds, 0, 1);
638
622
  if (fallback !== null) {
639
623
  selectSkin(fallback).catch((error) => logError("could not correct the selected skin.", error));
640
624
  }
641
625
  }
642
- if (!selectInFlight && belongsToChampion && ownedSkinIds.has(serverSkinId)) lastServerSkinId = serverSkinId;
643
626
 
644
627
  applyAllGuarded();
645
628
  }
646
629
 
647
- function onWindowResize() {
648
- scheduleRepack();
649
- }
650
-
651
630
  function scheduleRefresh() {
652
631
  clearTimeout(refreshTimer);
653
632
  refreshTimer = setTimeout(pollSession, REFRESH_DEBOUNCE_MS);
@@ -683,36 +662,26 @@ async function pollSession() {
683
662
 
684
663
  function onEnable(utils) {
685
664
  api = utils;
686
- filterActive = true;
665
+ filterState.active = true;
687
666
  removeStyle = api.addStyle(STYLE_ID, CSS);
688
667
 
689
- renderObserver = new MutationObserver(() => {
690
- ensureSourceFilter();
691
- const item = document.querySelector(SKIN_ITEM_SELECTOR);
692
- if (!item && !document.querySelector(DOT_SELECTOR)) return;
693
- applyAllGuarded();
694
- if (item) scheduleRefresh();
695
- });
668
+ renderObserver = new MutationObserver(scheduleApply);
696
669
  renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
697
670
 
698
- window.addEventListener("resize", onWindowResize);
699
671
  unsubscribe = api.subscribe(SESSION_URI, onSessionPush);
700
672
  pollSession();
701
673
  }
702
674
 
703
675
  function onDisable() {
704
- window.removeEventListener("resize", onWindowResize);
705
676
  unsubscribe?.();
706
677
  unsubscribe = null;
707
678
  clearTimeout(refreshTimer);
708
679
  refreshTimer = null;
709
- clearTimeout(repackTimer);
710
- repackTimer = null;
711
680
  renderObserver?.disconnect();
712
681
  removeStyle?.();
713
682
  renderObserver = null;
714
683
  removeStyle = null;
715
- filterActive = false;
684
+ filterState.active = false;
716
685
 
717
686
  resetState();
718
687
  document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((item) => item.classList.remove(HIDDEN_CLASS));