perfectionist-plugins 2.4.0 → 2.4.2

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.5.1";
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
- .${HIDDEN_CLASS} { display: none !important; margin: 0 !important; padding: 0 !important; border: none !important; pointer-events: none !important; }
22
- [data-perf-dots] { justify-content: center !important; }
19
+ .${HIDDEN_CLASS} { display: none !important; margin: 0 !important; padding: 0 !important; border: none !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,33 +228,41 @@ 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 =
165
- typeof input === "string"
166
- ? input
167
- : typeof input?.url === "string"
168
- ? input.url
169
- : typeof input?.href === "string"
170
- ? input.href
171
- : "";
242
+ typeof input === "string"
243
+ ? input
244
+ : typeof input?.url === "string"
245
+ ? input.url
246
+ : typeof input?.href === "string"
247
+ ? input.href
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,16 +300,19 @@ 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
- } catch {}
314
+ } catch {
315
+ }
228
316
  }
229
317
  return cache.filtered;
230
318
  };
@@ -244,7 +332,8 @@ function installXhrFilter() {
244
332
 
245
333
  function installSourceFilter() {
246
334
  try {
247
- filterActive = readDefaultEnabled();
335
+ filterState.active = readDefaultEnabled();
336
+ installArrayFilter();
248
337
  installWebSocketFilter();
249
338
  installFetchFilter();
250
339
  installXhrFilter();
@@ -255,6 +344,7 @@ function installSourceFilter() {
255
344
 
256
345
  function ensureSourceFilter() {
257
346
  try {
347
+ installArrayFilter();
258
348
  installWebSocketFilter();
259
349
  installFetchFilter();
260
350
  installXhrFilter();
@@ -267,10 +357,11 @@ let api = null;
267
357
  let summonerId = null;
268
358
  let ownedSkinIds = null;
269
359
  let allSkinIds = null;
360
+ let topLevelOrder = [];
361
+ let ownedTopLevelIds = null;
270
362
  let currentChampionId = null;
271
363
  let carouselOrder = [];
272
364
  let selectedSkinId = null;
273
- let lastServerSkinId = null;
274
365
  let failure = null;
275
366
  let requestToken = 0;
276
367
  let refreshTimer = null;
@@ -280,17 +371,8 @@ let selectInFlight = false;
280
371
  let sessionMisses = 0;
281
372
  let championMisses = 0;
282
373
  let renderObserver = null;
283
- let sessionActive = false;
284
- let repackTimer = null;
285
- let removeStyle = null;
286
- let lastSelectedDomIndex = -1;
287
- let navFixAt = 0;
288
- let lastSkipSignature = null;
289
- let itemSkinIds = new WeakMap();
290
- const repackOriginals = new Map();
291
- const lastAppliedLeft = new Map();
292
374
  const skinSuffixCache = new Map();
293
- const widthCache = new Map();
375
+ let removeStyle = null;
294
376
 
295
377
  function logError(scope, error) {
296
378
  console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
@@ -302,38 +384,22 @@ function skinSuffixOf(text) {
302
384
  return /Skins\/Base\//i.test(text) ? 0 : null;
303
385
  }
304
386
 
305
- function skinIdFromImages(item) {
306
- if (currentChampionId === null) return null;
307
- for (const img of item.querySelectorAll("img[src]")) {
308
- const src = img.getAttribute("src");
309
- let suffix = skinSuffixCache.get(src);
310
- if (suffix === undefined) {
311
- suffix = skinSuffixOf(src);
312
- skinSuffixCache.set(src, suffix);
313
- }
314
- if (suffix !== null) return currentChampionId * 1000 + suffix;
315
- }
316
- const suffix = skinSuffixOf(item.innerHTML);
317
- return suffix === null ? null : currentChampionId * 1000 + suffix;
318
- }
387
+ const skinIdCache = new WeakMap();
319
388
 
320
389
  function readSkinId(item) {
321
- return itemSkinIds.get(item) ?? skinIdFromImages(item);
322
- }
323
-
324
- function effectiveOrder() {
325
- return clientSkinOrder.length > 0 ? clientSkinOrder : carouselOrder;
326
- }
327
-
328
- function mapCarouselToItems(items) {
329
- const order = effectiveOrder();
330
- itemSkinIds = new WeakMap();
331
- if (order.length === 0 || order.length !== items.length) return false;
332
- items.forEach((item, index) => {
333
- const id = order[index];
334
- if (typeof id === "number") itemSkinIds.set(item, id);
335
- });
336
- return true;
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;
337
403
  }
338
404
 
339
405
  function hideTargetFor(item) {
@@ -346,226 +412,55 @@ function hideTargetFor(item) {
346
412
  return target;
347
413
  }
348
414
 
349
- function pruneRepack() {
350
- for (const item of [...repackOriginals.keys()]) {
351
- if (item.isConnected) continue;
352
- repackOriginals.delete(item);
353
- lastAppliedLeft.delete(item);
354
- }
355
- }
356
-
357
- function guardSelection(items) {
358
- const selectedIndex = items.findIndex((item) => item.classList.contains(SELECTED_CLASS));
359
- if (selectedIndex < 0) return false;
360
- const previous = lastSelectedDomIndex;
361
- lastSelectedDomIndex = selectedIndex;
362
- const selected = items[selectedIndex];
363
- const hidden = selected.classList.contains(HIDDEN_CLASS) || Boolean(selected.parentElement?.classList.contains(HIDDEN_CLASS));
364
- if (!hidden) {
365
- lastSkipSignature = null;
366
- return false;
367
- }
368
-
369
- const visible = items.map((item, index) => (item.classList.contains(HIDDEN_CLASS) ? -1 : index)).filter((index) => index >= 0);
370
- if (visible.length === 0) return false;
371
-
372
- const forward = previous < 0 ? true : selectedIndex >= previous;
373
- const target = forward
374
- ? (visible.find((index) => index > selectedIndex) ?? visible[0])
375
- : ([...visible].reverse().find((index) => index < selectedIndex) ?? visible[visible.length - 1]);
376
- const skinId = readSkinId(items[target]);
377
- if (skinId === null) return false;
378
-
379
- const signature = `${selectedIndex}:${skinId}`;
380
- if (lastSkipSignature === signature) return true;
381
- const now = Date.now();
382
- if (now < navFixAt) return true;
383
- navFixAt = now + 150;
384
- lastSkipSignature = signature;
385
- lastSelectedDomIndex = target;
386
- selectSkin(skinId).catch((error) => logError("could not skip the unowned skin.", error));
387
- return true;
388
- }
389
-
390
- function leftToPx(item, raw) {
391
- if (!raw) return 0;
392
- if (raw.endsWith("px")) return parseFloat(raw);
393
- if (raw.endsWith("%")) return (parseFloat(raw) / 100) * (item.parentElement?.offsetWidth || 468);
394
- return 0;
395
- }
396
-
397
- function originalLeftOf(item) {
398
- const original = repackOriginals.get(item);
399
- return leftToPx(item, original ? original.value : item.style.getPropertyValue("left"));
400
- }
401
-
402
- function refreshOriginal(item) {
403
- const current = item.style.getPropertyValue("left");
404
- const priority = item.style.getPropertyPriority("left");
405
- const last = lastAppliedLeft.get(item);
406
- if (last !== undefined && current === last && priority === "important") return;
407
- repackOriginals.set(item, { value: current, priority });
408
- lastAppliedLeft.delete(item);
409
- }
410
-
411
- function widthOf(item, anchor) {
412
- if (item.offsetWidth > 0) {
413
- widthCache.set(item, item.offsetWidth);
414
- return item.offsetWidth;
415
- }
416
- const measured = item.getBoundingClientRect().width;
417
- if (measured > 0) {
418
- widthCache.set(item, measured);
419
- return measured;
420
- }
421
- return widthCache.get(item) ?? (item === anchor ? 117 : 73);
422
- }
423
-
424
- function repackVisibleItems(items) {
425
- pruneRepack();
426
- const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
427
- if (visible.length < 2) {
428
- restoreRepack();
429
- return;
430
- }
431
-
432
- const selectedIndex = items.findIndex((item) => item.classList.contains(SELECTED_CLASS));
433
- let anchor = selectedIndex >= 0 ? items[selectedIndex] : null;
434
- if (!anchor && selectedSkinId !== null) {
435
- anchor = visible.find((item) => readSkinId(item) === selectedSkinId) ?? null;
436
- }
437
- if (!anchor) anchor = visible[Math.floor(visible.length / 2)];
438
- const anchorDomIndex = items.indexOf(anchor);
439
- refreshOriginal(anchor);
440
- const anchorLeft = originalLeftOf(anchor);
441
-
442
- const setLeft = (item, x) => {
443
- const want = `${x}px`;
444
- const current = item.style.getPropertyValue("left");
445
- const priority = item.style.getPropertyPriority("left");
446
- const last = lastAppliedLeft.get(item);
447
- const clientMoved = last !== undefined && (current !== last || priority !== "important");
448
- if (clientMoved || !repackOriginals.has(item)) {
449
- repackOriginals.set(item, { value: current, priority });
450
- }
451
- if (current !== want || priority !== "important") {
452
- item.style.setProperty("left", want, "important");
453
- }
454
- lastAppliedLeft.set(item, want);
455
- };
456
-
457
- let rightX = anchorLeft + widthOf(anchor, anchor);
458
- for (let index = anchorDomIndex + 1; index < items.length; index++) {
459
- const item = items[index];
460
- if (item.classList.contains(HIDDEN_CLASS)) continue;
461
- const width = widthOf(item, anchor);
462
- setLeft(item, rightX);
463
- rightX += width;
464
- }
465
-
466
- let leftX = anchorLeft;
467
- for (let index = anchorDomIndex - 1; index >= 0; index--) {
468
- const item = items[index];
469
- if (item.classList.contains(HIDDEN_CLASS)) continue;
470
- const width = widthOf(item, anchor);
471
- leftX -= width;
472
- setLeft(item, leftX);
473
- }
474
- }
475
-
476
- function restoreRepack() {
477
- for (const [item, original] of repackOriginals) {
478
- if (!item.isConnected) continue;
479
- const current = item.style.getPropertyValue("left");
480
- const last = lastAppliedLeft.get(item);
481
- if (last !== undefined && (current !== last || item.style.getPropertyPriority("left") !== "important")) continue;
482
- if (original.value) item.style.setProperty("left", original.value, original.priority || undefined);
483
- else item.style.removeProperty("left");
484
- }
485
- repackOriginals.clear();
486
- lastAppliedLeft.clear();
415
+ function setHidden(element, hide) {
416
+ if (element.classList.contains(HIDDEN_CLASS) !== hide) element.classList.toggle(HIDDEN_CLASS, hide);
487
417
  }
488
418
 
489
419
  function applyAll() {
490
420
  const items = [...document.querySelectorAll(SKIN_ITEM_SELECTOR)];
491
- mapCarouselToItems(items);
492
421
  let hidden = 0;
493
422
  items.forEach((item) => {
494
423
  const skinId = currentChampionId === null ? null : readSkinId(item);
495
424
  const hide = ownedSkinIds !== null && skinId !== null && allSkinIds !== null && allSkinIds.has(skinId) && !ownedSkinIds.has(skinId);
496
425
  if (hide) hidden++;
497
- item.classList.toggle(HIDDEN_CLASS, hide);
498
- hideTargetFor(item).classList.toggle(HIDDEN_CLASS, hide);
426
+ setHidden(item, hide);
427
+ setHidden(hideTargetFor(item), hide);
499
428
  });
500
429
 
501
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
+ : [];
502
437
  let dotsHidden = 0;
503
- const dotOrder = effectiveOrder();
504
- const dotsMapped = dotOrder.length > 0 && dots.length === dotOrder.length;
438
+ const dotsMapped = dotOrder.length > 0;
505
439
  dots.forEach((dot, index) => {
506
440
  const skinId = dotsMapped ? dotOrder[index] : undefined;
507
- const hide = ownedSkinIds !== null && skinId !== undefined && allSkinIds !== null && allSkinIds.has(skinId) && !ownedSkinIds.has(skinId);
441
+ const hide = ownedTopLevelIds !== null && skinId !== undefined && !ownedTopLevelIds.has(skinId);
508
442
  if (hide) dotsHidden++;
509
- dot.classList.toggle(HIDDEN_CLASS, hide);
443
+ setHidden(dot, hide);
510
444
  });
511
- const dotList = dots[0]?.parentElement;
512
- if (dotList) {
513
- if (dotsHidden > 0 && !dotList.hasAttribute("data-perf-dots")) dotList.setAttribute("data-perf-dots", "");
514
- else if (dotsHidden === 0 && dotList.hasAttribute("data-perf-dots")) dotList.removeAttribute("data-perf-dots");
515
- }
516
-
517
- if (ownedSkinIds !== null && hidden > 0) {
518
- if (guardSelection(items)) runRepack();
519
- else scheduleRepack();
520
- } else if (hidden === 0) restoreRepack();
521
445
  }
522
446
 
523
- function scheduleRepack() {
524
- clearTimeout(repackTimer);
525
- repackTimer = setTimeout(runRepack, REPACK_SETTLE_MS);
447
+ function applyAllGuarded() {
448
+ applyAll();
526
449
  }
527
450
 
528
- function startRenderObserver() {
529
- if (!sessionActive || renderObserver || !api) return;
530
- renderObserver = new MutationObserver(() => {
451
+ let applyQueued = false;
452
+
453
+ function scheduleApply() {
454
+ if (applyQueued) return;
455
+ applyQueued = true;
456
+ requestAnimationFrame(() => {
457
+ applyQueued = false;
531
458
  ensureSourceFilter();
532
459
  const item = document.querySelector(SKIN_ITEM_SELECTOR);
533
460
  if (!item && !document.querySelector(DOT_SELECTOR)) return;
534
- applyAllGuarded();
461
+ applyAll();
535
462
  if (item) scheduleRefresh();
536
463
  });
537
- try {
538
- renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
539
- } catch {
540
- renderObserver = null;
541
- }
542
- }
543
-
544
- function stopRenderObserver() {
545
- renderObserver?.disconnect();
546
- renderObserver = null;
547
- }
548
-
549
- function runRepack() {
550
- repackTimer = null;
551
- renderObserver?.disconnect();
552
- try {
553
- const items = [...document.querySelectorAll(SKIN_ITEM_SELECTOR)];
554
- const hidden = items.some((item) => item.classList.contains(HIDDEN_CLASS));
555
- if (ownedSkinIds !== null && hidden) repackVisibleItems(items);
556
- else restoreRepack();
557
- } finally {
558
- startRenderObserver();
559
- }
560
- }
561
-
562
- function applyAllGuarded() {
563
- renderObserver?.disconnect();
564
- try {
565
- applyAll();
566
- } finally {
567
- startRenderObserver();
568
- }
569
464
  }
570
465
 
571
466
  async function loadOwnedSkins(championId) {
@@ -580,19 +475,36 @@ async function loadOwnedSkins(championId) {
580
475
  if (!Array.isArray(skins) || skins.length === 0) {
581
476
  throw new Error(`no skin inventory returned for champion ${championId}`);
582
477
  }
583
- const order = skins
584
- .map((skin) => skin?.id)
585
- .filter((id) => typeof id === "number")
586
- .sort((a, b) => a - b);
587
- 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 };
588
500
  }
589
501
 
590
502
  async function loadCarouselOrder() {
591
- if (clientSkinOrder.length > 0) return [...clientSkinOrder];
503
+ const preferred = clientSkinOrder.length > 0 ? [...clientSkinOrder] : null;
592
504
  try {
593
505
  let skins;
594
- if (rawFetch) {
595
- let timer = null;
506
+ if (typeof rawFetch === "function") {
507
+ let timer;
596
508
  try {
597
509
  const response = await Promise.race([
598
510
  rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } }),
@@ -607,10 +519,12 @@ async function loadCarouselOrder() {
607
519
  } else {
608
520
  skins = await api.lcu.get(CAROUSEL_URI);
609
521
  }
522
+ learnSkinShape(skins);
523
+ if (preferred) return preferred;
610
524
  if (!Array.isArray(skins)) return [];
611
525
  return skins.map((skin) => skin?.id).filter((id) => typeof id === "number");
612
526
  } catch {
613
- return [];
527
+ return preferred ?? [];
614
528
  }
615
529
  }
616
530
 
@@ -639,6 +553,8 @@ function resetState() {
639
553
  requestToken++;
640
554
  ownedSkinIds = null;
641
555
  allSkinIds = null;
556
+ topLevelOrder = [];
557
+ ownedTopLevelIds = null;
642
558
  currentChampionId = null;
643
559
  selectedSkinId = null;
644
560
  carouselOrder = [];
@@ -653,15 +569,19 @@ function loadChampion(championId) {
653
569
  currentChampionId = championId;
654
570
  ownedSkinIds = null;
655
571
  allSkinIds = null;
572
+ topLevelOrder = [];
573
+ ownedTopLevelIds = null;
656
574
  failure = null;
657
575
  clientSkinOrder = [];
658
576
  const token = ++requestToken;
659
577
 
660
578
  Promise.all([loadOwnedSkins(championId), loadCarouselOrder()]).then(
661
- ([{ owned, order: inventoryOrder }, carouselIds]) => {
579
+ ([{ owned, order: inventoryOrder, topLevel, ownedTopLevel, entries }, carouselIds]) => {
662
580
  if (token !== requestToken) return;
663
581
  ownedSkinIds = owned;
664
582
  allSkinIds = new Set(inventoryOrder);
583
+ topLevelOrder = topLevel;
584
+ ownedTopLevelIds = ownedTopLevel;
665
585
  carouselOrder = carouselIds.length > 0 ? carouselIds : inventoryOrder;
666
586
  applyAllGuarded();
667
587
  },
@@ -698,21 +618,15 @@ function updateForSession(session, selection) {
698
618
 
699
619
  const knownUnownedSkin = allSkinIds !== null && allSkinIds.has(serverSkinId) && !ownedSkinIds.has(serverSkinId);
700
620
  if (!selectInFlight && knownUnownedSkin) {
701
- const direction = lastServerSkinId !== null && serverSkinId !== null && serverSkinId < lastServerSkinId ? -1 : 1;
702
- const fallback = nextOwnedSkin(ownedSkinIds, serverSkinId ?? 0, direction) ?? nextOwnedSkin(ownedSkinIds, 0, 1);
621
+ const fallback = nextOwnedSkin(ownedTopLevelIds ?? ownedSkinIds, 0, 1);
703
622
  if (fallback !== null) {
704
623
  selectSkin(fallback).catch((error) => logError("could not correct the selected skin.", error));
705
624
  }
706
625
  }
707
- if (!selectInFlight && belongsToChampion && ownedSkinIds.has(serverSkinId)) lastServerSkinId = serverSkinId;
708
626
 
709
627
  applyAllGuarded();
710
628
  }
711
629
 
712
- function onWindowResize() {
713
- scheduleRepack();
714
- }
715
-
716
630
  function scheduleRefresh() {
717
631
  clearTimeout(refreshTimer);
718
632
  refreshTimer = setTimeout(pollSession, REFRESH_DEBOUNCE_MS);
@@ -721,15 +635,11 @@ function scheduleRefresh() {
721
635
  function onSessionPush(raw) {
722
636
  const msg = raw ?? {};
723
637
  if (msg.eventType === "Delete") {
724
- sessionActive = false;
725
- stopRenderObserver();
726
638
  resetState();
727
639
  return;
728
640
  }
729
641
  const s = msg.data ?? msg;
730
642
  if (!s?.myTeam) return;
731
- sessionActive = true;
732
- startRenderObserver();
733
643
  updateForSession(s, null);
734
644
  }
735
645
 
@@ -740,17 +650,11 @@ async function pollSession() {
740
650
  try {
741
651
  const session = await api.lcu.get(SESSION_URI);
742
652
  sessionMisses = 0;
743
- sessionActive = true;
744
- startRenderObserver();
745
653
  const selection = await api.lcu.get(SELECTION_URI).catch(() => null);
746
654
  updateForSession(session, selection);
747
655
  } catch {
748
656
  sessionMisses++;
749
- if (sessionMisses >= 3) {
750
- sessionActive = false;
751
- stopRenderObserver();
752
- resetState();
753
- }
657
+ if (sessionMisses >= 3) resetState();
754
658
  } finally {
755
659
  pollInFlight = false;
756
660
  }
@@ -758,28 +662,26 @@ async function pollSession() {
758
662
 
759
663
  function onEnable(utils) {
760
664
  api = utils;
761
- filterActive = true;
762
- sessionActive = false;
665
+ filterState.active = true;
763
666
  removeStyle = api.addStyle(STYLE_ID, CSS);
764
667
 
765
- window.addEventListener("resize", onWindowResize);
668
+ renderObserver = new MutationObserver(scheduleApply);
669
+ renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
670
+
766
671
  unsubscribe = api.subscribe(SESSION_URI, onSessionPush);
767
672
  pollSession();
768
673
  }
769
674
 
770
675
  function onDisable() {
771
- window.removeEventListener("resize", onWindowResize);
772
676
  unsubscribe?.();
773
677
  unsubscribe = null;
774
678
  clearTimeout(refreshTimer);
775
679
  refreshTimer = null;
776
- clearTimeout(repackTimer);
777
- repackTimer = null;
778
- sessionActive = false;
779
- stopRenderObserver();
680
+ renderObserver?.disconnect();
780
681
  removeStyle?.();
682
+ renderObserver = null;
781
683
  removeStyle = null;
782
- filterActive = false;
684
+ filterState.active = false;
783
685
 
784
686
  resetState();
785
687
  document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((item) => item.classList.remove(HIDDEN_CLASS));