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.
package/better-client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const PLUGIN_ID = "better-client";
2
- const version = "1.2.0";
2
+ const version = "1.7.0";
3
3
 
4
4
  const MAX_CACHE_ENTRIES = 24;
5
5
  const MAX_CACHE_ENTRY_BYTES = 2 * 1024 * 1024;
@@ -7,17 +7,23 @@ const MAX_CACHE_BYTES = 16 * 1024 * 1024;
7
7
  const UNGATE_MS = 1000;
8
8
  const UNGATE_IDLE_MS = 5000;
9
9
  const UNGATE_BACKOFF_AFTER = 20;
10
- const PHASE_POLL_MS = 5000;
11
- const IMAGE_BATCH = 200;
12
-
10
+ const PHASE_POLL_FALLBACK_MS = 15000;
11
+ const IMAGE_BATCH = 160;
12
+ const IMAGE_QUEUE_LIMIT = 4000;
13
+ const DECISION_CACHE_LIMIT = 256;
13
14
  const TELEMETRY_HOST = /(^|\.)(telemetry|analytics|metrics|sentry|bugsnag|crash-?report(er)?|pulse|tracking|beacon|datadog|loggly)(\.|:|$)/i;
14
- const TELEMETRY_PATH = /(^|\/)(lol-telemetry|lol-analytics|lol-metrics|lol-rms|rms|lol-sentry|lol-crash|telemetry|analytics|metrics|sentry|bugsnag|crash-?report\w*|tracking|beacon)(\/|$)/i;
15
+ const TELEMETRY_PATH = /(^|\/)(lol-telemetry|lol-analytics|lol-metrics|lol-rms|rms|lol-sentry|lol-crash|telemetry|analytics|metrics|sentry|bugsnag|crash-?report\w*|tracking|beacon|tracing)(\/|\?|$)/i;
15
16
  const ASSET_PATH = /^\/lol-game-data\/assets\//i;
16
17
  const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
17
18
 
19
+ const CHAMP_SELECT_DATA = /^\/lol-champ-select\/v1\/(session|all-grid-champions|pickable-champion-ids|bannable-champion-ids)$/i;
20
+ const CHAMP_SELECT_RETRY_MS = [120, 260, 520];
21
+
18
22
  const STATIC_ENDPOINTS = [
19
23
  ["/lol-game-data/assets/", 60 * 60 * 1000],
20
24
  ["/lol-perks/v1/perks", 60 * 60 * 1000],
25
+ ["/riotclient/region-locale", 60 * 1000],
26
+ ["/lol-summoner/v1/current-summoner", 10 * 1000],
21
27
  ["/performance/v1/quickstats", 5000],
22
28
  ];
23
29
 
@@ -31,8 +37,33 @@ const UNGATE_SELECTORS = [
31
37
  ".lol-navigation-item",
32
38
  "[class*='friends-list']",
33
39
  ];
40
+ const UNGATE_SELECTOR = UNGATE_SELECTORS.join(",");
34
41
  const BLOCK_MARKER = "[disabled], [aria-disabled='true'], [style*='pointer-events']";
35
42
 
43
+ const PERF_STYLE_ID = "perfectionist-better-client-performance";
44
+ const PERFORMANCE_CSS = `
45
+ [class*="blur"], [class*="Blur"], [class*="backdrop"], [class*="Backdrop"] {
46
+ filter: none !important;
47
+ backdrop-filter: none !important;
48
+ -webkit-backdrop-filter: none !important;
49
+ }
50
+ [class*="particle"], [class*="Particle"], [class*="parallax"], [class*="Parallax"],
51
+ [class*="animated-background"], [class*="animatedBackground"], [class*="glow"], [class*="Glow"] {
52
+ animation: none !important;
53
+ transition: none !important;
54
+ }
55
+ [class*="background"] video, [class*="Background"] video { display: none !important; }
56
+ `;
57
+
58
+ const OPTIONS = [
59
+ {
60
+ key: "performanceMode",
61
+ label: "Performance mode",
62
+ description: "Turns off background blur, decorative animation and looping background video to save GPU and CPU on weaker machines.",
63
+ default: false,
64
+ },
65
+ ];
66
+
36
67
  let api = null;
37
68
  let active = false;
38
69
  let phase = "None";
@@ -41,10 +72,14 @@ let restores = [];
41
72
  let ungateTimer = null;
42
73
  let ungateIdle = 0;
43
74
  let phaseTimer = null;
75
+ let phaseWatchListener = null;
76
+ let performanceMode = false;
77
+ let removePerformanceStyle = null;
44
78
  let cacheBytes = 0;
45
79
 
46
80
  const cache = new Map();
47
81
  const inflight = new Map();
82
+ const blockDecisions = new Map();
48
83
 
49
84
  function baseHref() {
50
85
  try {
@@ -67,11 +102,24 @@ function isLocalHost(host) {
67
102
  }
68
103
 
69
104
  function isTelemetryUrl(raw, base) {
70
- const url = resolveUrl(raw, base);
71
- if (!url || url.protocol === "data:" || url.protocol === "blob:") return false;
72
- if (ASSET_PATH.test(url.pathname)) return false;
73
- if (isLocalHost(url.hostname)) return TELEMETRY_PATH.test(url.pathname);
74
- return TELEMETRY_HOST.test(url.hostname) || TELEMETRY_PATH.test(url.pathname);
105
+ const text = typeof raw === "string" ? raw : urlOf(raw);
106
+ const memo = blockDecisions.get(text);
107
+ if (memo !== undefined) return memo;
108
+
109
+ let verdict;
110
+ if (text.startsWith("/") && !text.startsWith("//")) {
111
+ verdict = !ASSET_PATH.test(text) && TELEMETRY_PATH.test(text);
112
+ } else {
113
+ const url = resolveUrl(text, base);
114
+ if (!url || url.protocol === "data:" || url.protocol === "blob:") verdict = false;
115
+ else if (ASSET_PATH.test(url.pathname)) verdict = false;
116
+ else if (isLocalHost(url.hostname)) verdict = TELEMETRY_PATH.test(url.pathname);
117
+ else verdict = TELEMETRY_HOST.test(url.hostname) || TELEMETRY_PATH.test(url.pathname);
118
+ }
119
+
120
+ if (blockDecisions.size >= DECISION_CACHE_LIMIT) blockDecisions.clear();
121
+ blockDecisions.set(text, verdict);
122
+ return verdict;
75
123
  }
76
124
 
77
125
  function shouldBlockUrl(raw, base) {
@@ -140,54 +188,111 @@ function clearVolatileCache() {
140
188
  }
141
189
  }
142
190
 
191
+ function champSelectDataPath(url) {
192
+ if (!url) return null;
193
+ return CHAMP_SELECT_DATA.test(url.pathname) ? url.pathname.toLowerCase() : null;
194
+ }
195
+
196
+ function isEmptyChampSelect(path, json) {
197
+ if (path.endsWith("/session")) {
198
+ if (!json || typeof json !== "object") return true;
199
+ return !Array.isArray(json.myTeam) || json.myTeam.length === 0;
200
+ }
201
+ return Array.isArray(json) && json.length === 0;
202
+ }
203
+
204
+ function champSelectPathOf(rawUrl) {
205
+ return champSelectDataPath(resolveUrl(rawUrl));
206
+ }
207
+
208
+ async function primeChampSelect(raw, path) {
209
+ for (const wait of [0, 120, 240]) {
210
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
211
+ try {
212
+ const response = await raw(path);
213
+ if (!response?.ok) continue;
214
+ const json = await response.json();
215
+ if (!isEmptyChampSelect(path, json)) return;
216
+ } catch {}
217
+ }
218
+ }
219
+
143
220
  function createFetch(env) {
144
221
  const native = env.fetch.bind(env);
145
222
 
223
+ function repairChampSelect(input, init, request, path) {
224
+ let attempt = 0;
225
+ const step = (current) =>
226
+ current.then((response) => {
227
+ if (!active || phase !== "ChampSelect" || attempt >= CHAMP_SELECT_RETRY_MS.length) return response;
228
+ if (!response || !response.ok || !response.clone) return response;
229
+ return response.clone().json().then(
230
+ (json) => {
231
+ if (!isEmptyChampSelect(path, json)) return response;
232
+ const wait = CHAMP_SELECT_RETRY_MS[attempt];
233
+ attempt += 1;
234
+ return new Promise((resolve) => setTimeout(resolve, wait)).then(() => step(native(input, init)));
235
+ },
236
+ () => response,
237
+ );
238
+ });
239
+ return step(request);
240
+ }
241
+
146
242
  function betterFetch(input, init) {
147
243
  const raw = urlOf(input);
148
- const method = methodOf(input, init);
149
244
 
150
245
  if (active && shouldBlockUrl(raw)) {
151
246
  return Promise.resolve(emptyJsonResponse(env));
152
247
  }
153
248
 
249
+ const method = methodOf(input, init);
250
+ if (method !== "GET" && method !== "HEAD") {
251
+ return native(input, init);
252
+ }
253
+
154
254
  const url = resolveUrl(raw);
155
- const local = url ? isLocalHost(url.hostname) : false;
156
- const cacheable = local && method === "GET";
157
- const key = `${method} ${url ? url.origin + url.pathname + url.search : raw}`;
158
-
159
- if (cacheable && active) {
160
- const pending = inflight.get(key);
161
- if (pending) {
162
- return pending.then((response) => (response?.clone ? response.clone() : response));
163
- }
255
+ if (!url || !isLocalHost(url.hostname)) {
256
+ return native(input, init);
257
+ }
258
+
259
+ const key = `${method} ${url.pathname}${url.search}`;
260
+ const pending = inflight.get(key);
261
+ if (pending) {
262
+ return pending.then((response) => (response?.clone ? response.clone() : response));
164
263
  }
165
264
 
166
- if (cacheable && active) {
167
- const ttl = staticTtl(url?.pathname);
168
- const hit = ttl ? cache.get(key) : null;
265
+ const ttl = active ? staticTtl(url.pathname) : 0;
266
+ if (ttl) {
267
+ const hit = cache.get(key);
169
268
  if (hit && Date.now() - hit.at < hit.ttl) {
170
269
  return Promise.resolve(hit.res.clone());
171
270
  }
172
271
  }
173
272
 
174
- const request = native(input, init);
175
- if (local && (method === "GET" || method === "HEAD")) {
176
- inflight.set(key, request);
177
- const done = () => inflight.delete(key);
178
- request.then(done, done);
179
- if (cacheable) {
180
- request.then((response) => {
181
- const ttl = staticTtl(url?.pathname);
182
- if (!response?.ok || !ttl || !active) return;
183
- storeInCache(key, ttl, response);
184
- }, () => {});
185
- }
273
+ let request = native(input, init);
274
+ const dataPath = champSelectDataPath(url);
275
+ if (dataPath && active && phase === "ChampSelect") {
276
+ request = repairChampSelect(input, init, request, dataPath);
277
+ }
278
+
279
+ inflight.set(key, request);
280
+ const done = () => inflight.delete(key);
281
+ request.then(done, done);
282
+
283
+ if (ttl) {
284
+ request.then((response) => {
285
+ if (!response?.ok || !active) return;
286
+ storeInCache(key, ttl, response);
287
+ }, () => {});
186
288
  }
187
- return request;
289
+ // Every caller gets its own clone; the original response stays unread so
290
+ // concurrent callers can clone it after one caller consumes the body.
291
+ return request.then((response) => (response?.clone ? response.clone() : response));
188
292
  }
189
293
 
190
294
  betterFetch.__better = true;
295
+ betterFetch.raw = native;
191
296
  return betterFetch;
192
297
  }
193
298
 
@@ -224,6 +329,17 @@ function installXhr(env) {
224
329
  emitBlockedXhr(this, env);
225
330
  return undefined;
226
331
  }
332
+ const path = champSelectPathOf(this.__betterUrl);
333
+ const raw = env.fetch?.raw;
334
+ if (path && raw && active && phase === "ChampSelect" && !this.__betterPrimed) {
335
+ this.__betterPrimed = true;
336
+ primeChampSelect(raw, path).finally(() => {
337
+ try {
338
+ nativeSend.call(this, body);
339
+ } catch {}
340
+ });
341
+ return undefined;
342
+ }
227
343
  return nativeSend.call(this, body);
228
344
  };
229
345
  proto.__better = true;
@@ -249,9 +365,16 @@ function installBeacon(env) {
249
365
  };
250
366
  }
251
367
 
368
+ let mediaObserver = null;
369
+ let mediaScanObserver = null;
370
+ let mediaVisibilityListener = null;
371
+ let mediaPaused = new WeakSet();
372
+
252
373
  let imageObserver = null;
253
374
  let imageQueue = [];
254
375
  let imageFlushTimer = null;
376
+ let imageFlushIdle = false;
377
+ let imageVisibilityListener = null;
255
378
 
256
379
  function markAsyncDecode(img) {
257
380
  if (!img || img.tagName !== "IMG" || img.decoding === "async") return 0;
@@ -263,38 +386,144 @@ function markAsyncDecode(img) {
263
386
  }
264
387
  }
265
388
 
266
- function flushImageQueue() {
267
- imageFlushTimer = null;
268
- const batch = imageQueue.splice(0, IMAGE_BATCH);
269
- for (const img of batch) markAsyncDecode(img);
270
- if (imageQueue.length > 0) scheduleImageFlush();
389
+ function documentView() {
390
+ try {
391
+ return document.defaultView ?? null;
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+
397
+ function pageHidden() {
398
+ try {
399
+ return document.hidden === true;
400
+ } catch {
401
+ return false;
402
+ }
271
403
  }
272
404
 
273
405
  function scheduleImageFlush() {
274
406
  if (imageFlushTimer !== null) return;
275
- imageFlushTimer = setTimeout(flushImageQueue, 150);
407
+ const view = documentView();
408
+ if (typeof view?.requestIdleCallback === "function") {
409
+ imageFlushIdle = true;
410
+ imageFlushTimer = view.requestIdleCallback(flushImageQueue, { timeout: 600 });
411
+ } else {
412
+ imageFlushIdle = false;
413
+ imageFlushTimer = setTimeout(flushImageQueue, 150);
414
+ }
276
415
  }
277
416
 
278
- function enqueueImages(root) {
279
- if (!root || root.nodeType !== 1) return 0;
280
- let added = 0;
281
- if (root.tagName === "IMG") {
282
- imageQueue.push(root);
283
- added += 1;
284
- }
285
- let list = [];
417
+ function cancelImageFlush() {
418
+ if (imageFlushTimer === null) return;
419
+ const view = documentView();
420
+ if (imageFlushIdle && typeof view?.cancelIdleCallback === "function") view.cancelIdleCallback(imageFlushTimer);
421
+ else clearTimeout(imageFlushTimer);
422
+ imageFlushTimer = null;
423
+ }
424
+
425
+ function scanImages(root) {
426
+ let found;
286
427
  try {
287
- list = root.querySelectorAll("img:not([decoding='async'])");
428
+ found = root.querySelectorAll("img[src]:not([decoding='async'])");
288
429
  } catch {
289
- list = [];
430
+ return 0;
290
431
  }
291
- for (const img of list) {
292
- imageQueue.push(img);
293
- added += 1;
432
+ let marked = 0;
433
+ for (const img of found) marked += markAsyncDecode(img);
434
+ return marked;
435
+ }
436
+
437
+ function flushImageQueue() {
438
+ imageFlushTimer = null;
439
+ if (pageHidden()) {
440
+ scheduleImageFlush();
441
+ return;
294
442
  }
295
- if (imageQueue.length > 6000) imageQueue.splice(0, imageQueue.length - 6000);
296
- if (added > 0) scheduleImageFlush();
297
- return added;
443
+ const batch = imageQueue.splice(0, IMAGE_BATCH);
444
+ for (const root of batch) {
445
+ if (!root?.isConnected) continue;
446
+ markAsyncDecode(root);
447
+ scanImages(root);
448
+ }
449
+ if (imageQueue.length > 0) scheduleImageFlush();
450
+ }
451
+
452
+ function enqueueImages(root) {
453
+ if (!root || root.nodeType !== 1) return 0;
454
+ if (imageQueue.length >= IMAGE_QUEUE_LIMIT) return 0;
455
+ if (root.tagName !== "IMG" && root.childElementCount === 0) return 0;
456
+ imageQueue.push(root);
457
+ scheduleImageFlush();
458
+ return 1;
459
+ }
460
+
461
+ function pauseDecorativeVideo(video) {
462
+ if (!video || video.tagName !== "VIDEO" || video.paused) return;
463
+ if (!video.autoplay && !video.muted) return;
464
+ try {
465
+ video.pause();
466
+ mediaPaused.add(video);
467
+ } catch {}
468
+ }
469
+
470
+ function resumeDecorativeVideo(video) {
471
+ if (!video || !mediaPaused.has(video)) return;
472
+ mediaPaused.delete(video);
473
+ try {
474
+ const play = video.play?.();
475
+ if (play?.catch) play.catch(() => {});
476
+ } catch {}
477
+ }
478
+
479
+ function installMediaIdle() {
480
+ if (typeof document === "undefined" || !document.documentElement) return () => {};
481
+ const Obs = document.defaultView?.IntersectionObserver ?? IntersectionObserver;
482
+ const Mut = document.defaultView?.MutationObserver ?? MutationObserver;
483
+ if (!Obs || !Mut) return () => {};
484
+ mediaObserver = new Obs(
485
+ (entries) => {
486
+ for (const entry of entries) {
487
+ if (entry.isIntersecting) resumeDecorativeVideo(entry.target);
488
+ else pauseDecorativeVideo(entry.target);
489
+ }
490
+ },
491
+ { rootMargin: "240px" },
492
+ );
493
+ const scan = () => {
494
+ for (const video of document.querySelectorAll("video")) {
495
+ if (video.__betterMedia) continue;
496
+ video.__betterMedia = true;
497
+ try {
498
+ mediaObserver.observe(video);
499
+ } catch {}
500
+ }
501
+ };
502
+ scan();
503
+ mediaScanObserver = new Mut(scan);
504
+ try {
505
+ mediaScanObserver.observe(document.documentElement, { childList: true, subtree: true });
506
+ } catch {}
507
+ mediaVisibilityListener = () => {
508
+ if (pageHidden()) {
509
+ for (const video of document.querySelectorAll("video")) pauseDecorativeVideo(video);
510
+ return;
511
+ }
512
+ scan();
513
+ for (const video of document.querySelectorAll("video")) {
514
+ if (video.getClientRects().length > 0) resumeDecorativeVideo(video);
515
+ }
516
+ };
517
+ document.addEventListener("visibilitychange", mediaVisibilityListener);
518
+ return () => {
519
+ document.removeEventListener("visibilitychange", mediaVisibilityListener);
520
+ mediaVisibilityListener = null;
521
+ mediaScanObserver?.disconnect();
522
+ mediaScanObserver = null;
523
+ mediaObserver?.disconnect();
524
+ mediaObserver = null;
525
+ mediaPaused = new WeakSet();
526
+ };
298
527
  }
299
528
 
300
529
  function installImageDecode() {
@@ -302,31 +531,37 @@ function installImageDecode() {
302
531
  const Obs = document.defaultView?.MutationObserver ?? MutationObserver;
303
532
  if (imageObserver || !Obs) return () => {};
304
533
  imageObserver = new Obs((records) => {
305
- let queued = false;
534
+ let queued = 0;
306
535
  for (const record of records) {
307
536
  if (record.type === "attributes") {
308
- if (enqueueImages(record.target) > 0) queued = true;
537
+ if (record.target?.tagName === "IMG") queued += enqueueImages(record.target);
309
538
  continue;
310
539
  }
311
- for (const node of record.addedNodes) {
312
- if (node?.nodeType !== 1) continue;
313
- if (enqueueImages(node) > 0) queued = true;
540
+ const nodes = record.addedNodes;
541
+ for (let index = 0; index < nodes.length; index++) {
542
+ const node = nodes[index];
543
+ if (node?.nodeType === 1) queued += enqueueImages(node);
314
544
  }
545
+ if (queued > 400) break;
315
546
  }
316
- if (queued) scheduleImageFlush();
317
547
  });
318
548
  try {
319
- imageObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["src"] });
549
+ imageObserver.observe(document.documentElement, { childList: true, subtree: true });
320
550
  } catch {
321
551
  imageObserver = null;
322
552
  return () => {};
323
553
  }
324
554
  enqueueImages(document.documentElement);
325
- const sweep = setInterval(() => enqueueImages(document.documentElement), 30000);
555
+ imageVisibilityListener = () => {
556
+ if (pageHidden()) return;
557
+ enqueueImages(document.documentElement);
558
+ scheduleImageFlush();
559
+ };
560
+ document.addEventListener("visibilitychange", imageVisibilityListener);
326
561
  return () => {
327
- clearInterval(sweep);
328
- clearTimeout(imageFlushTimer);
329
- imageFlushTimer = null;
562
+ document.removeEventListener("visibilitychange", imageVisibilityListener);
563
+ imageVisibilityListener = null;
564
+ cancelImageFlush();
330
565
  imageQueue = [];
331
566
  imageObserver?.disconnect();
332
567
  imageObserver = null;
@@ -366,16 +601,14 @@ function ungateNode(node) {
366
601
 
367
602
  function ungatePass() {
368
603
  if (!active || phase !== "InProgress") return 0;
369
- let changed = 0;
370
- for (const selector of UNGATE_SELECTORS) {
371
- let nodes = [];
372
- try {
373
- nodes = document.querySelectorAll(selector);
374
- } catch {
375
- nodes = [];
376
- }
377
- for (const node of nodes) changed += ungateNode(node);
604
+ let nodes;
605
+ try {
606
+ nodes = document.querySelectorAll(UNGATE_SELECTOR);
607
+ } catch {
608
+ return 0;
378
609
  }
610
+ let changed = 0;
611
+ for (const node of nodes) changed += ungateNode(node);
379
612
  return changed;
380
613
  }
381
614
 
@@ -386,6 +619,10 @@ function ungateDelay() {
386
619
  function ungateTick() {
387
620
  ungateTimer = null;
388
621
  if (!active || phase !== "InProgress") return;
622
+ if (pageHidden()) {
623
+ ungateTimer = setTimeout(ungateTick, UNGATE_IDLE_MS);
624
+ return;
625
+ }
389
626
  const changed = ungatePass();
390
627
  ungateIdle = changed > 0 ? 0 : ungateIdle + 1;
391
628
  ungateTimer = setTimeout(ungateTick, ungateDelay());
@@ -416,14 +653,47 @@ function onPhase(next) {
416
653
  }
417
654
 
418
655
  async function refreshPhase() {
419
- if (!active) return;
420
- if (typeof document !== "undefined" && document.hidden) return;
656
+ if (!active || pageHidden()) return;
421
657
  try {
422
658
  const current = await api?.lcu?.get?.("/lol-gameflow/v1/gameflow-phase");
423
659
  if (current) onPhase(current);
424
660
  } catch {}
425
661
  }
426
662
 
663
+ function startPhaseWatch() {
664
+ stopPhaseWatch();
665
+ if (unsubscribes.length === 0) {
666
+ phaseTimer = setInterval(() => {
667
+ if (!pageHidden()) refreshPhase();
668
+ }, PHASE_POLL_FALLBACK_MS);
669
+ }
670
+ phaseWatchListener = () => {
671
+ if (!pageHidden()) refreshPhase();
672
+ };
673
+ document.addEventListener("visibilitychange", phaseWatchListener);
674
+ }
675
+
676
+ function stopPhaseWatch() {
677
+ clearInterval(phaseTimer);
678
+ phaseTimer = null;
679
+ if (phaseWatchListener) {
680
+ document.removeEventListener("visibilitychange", phaseWatchListener);
681
+ phaseWatchListener = null;
682
+ }
683
+ }
684
+
685
+ function applyPerformanceMode() {
686
+ if (!api) return;
687
+ if (performanceMode) {
688
+ if (!removePerformanceStyle) removePerformanceStyle = api.addStyle(PERF_STYLE_ID, PERFORMANCE_CSS);
689
+ return;
690
+ }
691
+ if (removePerformanceStyle) {
692
+ removePerformanceStyle();
693
+ removePerformanceStyle = null;
694
+ }
695
+ }
696
+
427
697
  function installNetwork(env) {
428
698
  const installed = [];
429
699
  try {
@@ -449,7 +719,7 @@ function installNetwork(env) {
449
719
  function onEnable(utils) {
450
720
  api = utils;
451
721
  active = true;
452
- restores = [installNetwork(window), installImageDecode()];
722
+ restores = [installNetwork(window), installImageDecode(), installMediaIdle()];
453
723
  unsubscribes = [];
454
724
  try {
455
725
  const off = api.subscribe("/lol-gameflow/v1/gameflow-phase", onPhase);
@@ -461,16 +731,15 @@ function onEnable(utils) {
461
731
  .then((value) => onPhase(value))
462
732
  .catch(() => {});
463
733
  } catch {}
464
- refreshPhase();
465
- clearInterval(phaseTimer);
466
- phaseTimer = setInterval(refreshPhase, PHASE_POLL_MS);
734
+ startPhaseWatch();
735
+ performanceMode = api.getOption?.(PLUGIN_ID, "performanceMode") === true;
736
+ applyPerformanceMode();
467
737
  if (phase === "InProgress") startUngate();
468
738
  }
469
739
 
470
740
  function onDisable() {
471
741
  active = false;
472
- clearInterval(phaseTimer);
473
- phaseTimer = null;
742
+ stopPhaseWatch();
474
743
  stopUngate();
475
744
  for (const off of unsubscribes) {
476
745
  try {
@@ -484,12 +753,25 @@ function onDisable() {
484
753
  } catch {}
485
754
  }
486
755
  restores = [];
756
+ if (removePerformanceStyle) {
757
+ removePerformanceStyle();
758
+ removePerformanceStyle = null;
759
+ }
760
+ performanceMode = false;
487
761
  inflight.clear();
762
+ blockDecisions.clear();
488
763
  for (const key of [...cache.keys()]) evictOne(key);
489
764
  cacheBytes = 0;
490
765
  api = null;
491
766
  }
492
767
 
768
+ function onOptionChange(key, value) {
769
+ if (key === "performanceMode") {
770
+ performanceMode = value === true;
771
+ applyPerformanceMode();
772
+ }
773
+ }
774
+
493
775
  export function init() {
494
776
  const registerWith = (utils) => {
495
777
  utils.registerPlugin({
@@ -499,8 +781,10 @@ export function init() {
499
781
  "Debloats the client: blocks telemetry and analytics calls, merges duplicate requests, caches static client data, decodes images asynchronously and keeps settings, chat and friend requests usable during a game.",
500
782
  version,
501
783
  defaultEnabled: true,
784
+ options: OPTIONS,
502
785
  onEnable,
503
786
  onDisable,
787
+ onOptionChange,
504
788
  });
505
789
  };
506
790