-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDynamicBackgrounds.plugin.js
More file actions
5157 lines (4810 loc) · 224 KB
/
Copy pathDynamicBackgrounds.plugin.js
File metadata and controls
5157 lines (4810 loc) · 224 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* @name DynamicBackgrounds
* @author Enju
* @description Extends Discord themes with background images, slideshow, transitions and ambient effects.
* @version 3.10.1
* @source https://github.com/Enjuchan/DynamicBackgrounds/blob/main/DynamicBackgrounds.plugin.js
* @updateUrl https://raw.githubusercontent.com/Enjuchan/DynamicBackgrounds/main/DynamicBackgrounds.plugin.js
* @website https://github.com/Enjuchan/DynamicBackgrounds
*/
/* Based on BackgroundManager by Naru-kami, MIT licensed:
https://github.com/Naru-kami/BackgroundManager-plugin
This plugin started from that code and still contains parts of it.
Die CSS-Klassen hiessen bis 3.7.2 BackgroundManager-*, geerbt aus jener
Vorlage. Wer sie erneut umbenennt, muss das Theme mitziehen: Lovelace
erkennt das laufende Plugin an .DynamicBackgrounds-bgContainer und tritt
dann beim Hintergrund zurueck. */
const { React, Webpack, UI, Webpack: { Filters }, Patcher, DOM, ContextMenu, Data } = BdApi;
/** @type {typeof import("react")} */
const { useState, useEffect, useRef, useCallback, useId, useMemo, createElement: jsx, Fragment } = React;
const DATA_BASE_NAME = 'DynamicBackgrounds';
/* ---- Timing des Stapel-Effekts in der Kategorie-Uebersicht ----
Der Ablauf ist bewusst GESTAFFELT, damit es wie echtes Durchblaettern
wirkt: erst wird die oberste Karte angehoben und nach hinten gefuehrt,
und ERST DANN ruecken die anderen nach. Laufen beide gleichzeitig los,
ist die zweite Karte schon oben, bevor die erste weg ist - dann sieht es
aus, als wuerde sie einfach erscheinen. */
const STACK_TRAVEL_MS = 900; // Reise der obersten Karte (anheben, hinter den Stapel, einreihen)
const STACK_SHIFT_MS = 520; // wie lange die uebrigen Karten zum Nachruecken brauchen
const STACK_SHIFT_DELAY_MS = 350; // warten, bis die Karte angehoben UND hinter dem Stapel ist
// (der z-index-Wechsel liegt bei 38% von STACK_TRAVEL_MS)
/* Sperre gegen erneutes Ausloesen: so lange, wie die laengste Bewegung dauert. */
const STACK_ANIM_MS = Math.max(STACK_TRAVEL_MS, STACK_SHIFT_MS + STACK_SHIFT_DELAY_MS);
/* ÜBERGÄNGE - laufen einmal beim Bildwechsel, Dauer aus den Einstellungen. */
const transitionTypes = {
fade: 'Fade',
slide: 'Slide (horizontal)',
slideup: 'Slide (from bottom)',
zoom: 'Zoom in',
zoomout: 'Zoom out',
blur: 'Blur',
spin: 'Spin',
curtain: 'Curtain',
wipe: 'Wipe',
iris: 'Iris'
};
/* DAUEREFFEKTE - liegen permanent auf dem Bild und laufen weiter.
Frei mit jedem Übergang kombinierbar. */
const ambientTypes = {
none: 'None',
kenburns: 'Ken Burns',
drift: 'Drift',
pulse: 'Breathe',
grain: 'Film grain',
scanlines: 'Scanlines',
glitch: 'Glitch'
};
/* Alte gespeicherte Werte auf die neue Aufteilung umbiegen. */
/* Name der Auffang-Kategorie. Sie nimmt Bilder ohne eigene Zuordnung auf und
laesst sich nicht loeschen - beim Loeschen anderer Kategorien landen deren
Bilder hier, statt verwaist zu werden. */
const FALLBACK_CATEGORY = 'Uncategorized';
/* Bis Version 3.4.3 hiess sie 'Standard'. Der Name steht an jedem Bild in der
Datenbank, ein blosses Umbenennen wuerde also eine zweite, leere Kategorie
erzeugen und die vorhandenen Bilder verwaist zuruecklassen. */
const LEGACY_FALLBACK_CATEGORY = 'Standard';
/* Reihenfolge der Diashow, wenn Shuffle AUS ist.
Kategorien alphabetisch, innerhalb einer Kategorie nach id - das ist die
Reihenfolge, in der die Bilder auch im Raster stehen. Derselbe Vergleich wie
bei allCategories im Manager, damit Anzeige und Ablauf uebereinstimmen.
Warum eine gemeinsame Funktion: Es gibt ZWEI Wege, die das Bild
weiterschalten - den Takt der Diashow in slideShowManager.tick() und den
Knopf "Next background" ueber onNextShuffle(). Beide hatten ihre eigene
Auswahl, und eine Korrektur an nur einem Weg blieb ohne sichtbare Wirkung. */
function vergleicheNachKategorie(a, b) {
const ka = a.category || FALLBACK_CATEGORY;
const kb = b.category || FALLBACK_CATEGORY;
const k = ka.localeCompare(kb, undefined, { sensitivity: 'base' });
return k !== 0 ? k : (a.id ?? 0) - (b.id ?? 0);
}
const legacyTransitionMap = {
/* rotateY auf einer vollflaechigen Ebene laeuft in Electron unrund - der
Browser muss dafuer jedes Bild neu rastern. Ersatzlos gestrichen,
gespeicherte Werte landen auf "Drehen". */
flip: { type: 'spin' },
svgwipe: { type: 'wipe' },
cube: { type: 'spin' },
focus: { type: 'blur' },
pixelate: { type: 'zoom' },
particle: { type: 'fade' },
kenburns: { type: 'fade', ambient: 'kenburns' },
parallax: { type: 'fade', ambient: 'drift' },
glitch: { type: 'fade', ambient: 'glitch' },
film: { type: 'fade', ambient: 'grain' }
};
function migrateTransition(transition) {
const t = { ...(transition || {}) };
if (!t.ambient) t.ambient = 'none';
const mapped = legacyTransitionMap[t.type];
if (mapped) {
t.type = mapped.type;
if (mapped.ambient && t.ambient === 'none') t.ambient = mapped.ambient;
}
if (typeof t.ambientSpeed !== 'number' || !isFinite(t.ambientSpeed)) t.ambientSpeed = 100;
t.ambientSpeed = Math.min(600, Math.max(10, t.ambientSpeed));
if (!transitionTypes[t.type]) t.type = 'fade';
if (!ambientTypes[t.ambient]) t.ambient = 'none';
return t;
}
module.exports = meta => {
'use strict';
const defaultSettings = {
enableDrop: false,
transition: { enabled: true, duration: 1000, type: 'fade', ambient: 'none', ambientSpeed: 100 },
slideshow: { enabled: false, interval: 10000, shuffle: true, favoritesOnly: false, categoryFilter: null, categoryFilters: [] },
// Standard AUS: das Plugin bringt seine eigene Hintergrund-Ebene mit (siehe
// viewTransition.create). overwriteCSS ist nur ein Kompatibilitaets-Feature fuer
// fremde Themes, die ihren Hintergrund ueber eine eigene CSS-Variable setzen.
overwriteCSS: false,
adjustment: {
xPosition: 0,
yPosition: 0,
/* Nicht 0: Seit die Flaechen immer durchsichtig sind, liegt der Text
direkt auf dem Bild. Ohne Abdunklung ist er auf hellen Motiven kaum zu
lesen, und der erste Eindruck nach dem Einschalten waere "unbrauchbar".
0.3 ist dunkel genug zum Lesen und hell genug, dass das Motiv wirkt. */
dimming: 0.3,
blur: 0,
grayscale: 0,
saturate: 100,
contrast: 100
},
addContextMenu: true,
categories: [FALLBACK_CATEGORY],
selectedCategory: null
}
/** @type { {settings: typeof defaultSettings, [key: string]: unknown} } */
const constants = {};
// ObjectURL helpers to centralize create/revoke and avoid leaks
function ensureObjectURL(item) {
try {
if (!item) return null;
if (item.src) return item.src;
if (item._bgObjectURL) return item._bgObjectURL;
if (item.image) {
const url = URL.createObjectURL(item.image);
item._bgObjectURL = url;
// also mirror to src for older codepaths
item.src = url;
return url;
}
} catch (e) { /* ignore */ }
return null;
}
function setObjectURL(target, blob) {
try {
if (!target) return null;
if (target._bgObjectURL) {
try { URL.revokeObjectURL(target._bgObjectURL); } catch (e) {}
target._bgObjectURL = null;
}
if (!blob) {
target.src = null;
return null;
}
const url = URL.createObjectURL(blob);
target._bgObjectURL = url;
target.src = url;
return url;
} catch (e) { return null; }
}
function clearObjectURL(target) {
try {
if (!target) return;
if (target._bgObjectURL) {
try { URL.revokeObjectURL(target._bgObjectURL); } catch (e) {}
target._bgObjectURL = null;
}
if (target._thumbObjectURL) {
try { URL.revokeObjectURL(target._thumbObjectURL); } catch (e) {}
target._thumbObjectURL = null;
}
if (target.src && (!target.image || target.src !== target.image)) target.src = null;
} catch (e) { /* ignore */ }
}
/* VORSCHAUBILDER ---------------------------------------------------------
Das Raster zeigt Bilder auf rund 240px Breite, gespeichert sind sie aber in
voller Aufloesung. Entscheidend ist dabei nicht die Dateigroesse, sondern
die Pixelzahl: Dekodiert kostet ein Bild Breite * Hoehe * 4 Bytes, egal wie
gut es komprimiert ist. Gemessen an einer echten Sammlung: 2688x1536 sind
600 KB als Datei und 15,8 MB im Speicher, Faktor 26.
Weil die Kategorien als aufgefaecherte Stapel mit je fuenf Karten
nebeneinander liegen, ist praktisch die ganze Sammlung gleichzeitig gemalt.
Bei 34 Bildern waren das rund 537 MB.
Deshalb liegt neben dem Original eine kleine Fassung unter .thumb. Das
Original wird NICHT angetastet und weiterhin als Hintergrund benutzt - die
Vorschau ist ausschliesslich fuer die Rasteransicht da. */
const THUMB_WIDTH = 480;
function ensureThumbURL(item) {
try {
if (!item) return null;
if (item._thumbObjectURL) return item._thumbObjectURL;
if (item.thumb instanceof Blob) {
const url = URL.createObjectURL(item.thumb);
item._thumbObjectURL = url;
return url;
}
} catch (e) { /* ignore */ }
/* Noch keine Vorschau vorhanden - bis backfillThumbs durch ist, wird das
Original gezeigt. Lieber kurz teuer als ein leeres Raster. */
return item?.src || ensureObjectURL(item);
}
/**
* @typedef {Object} ImageItem
* @property {Blob} image - The image blob.
* @property {boolean} selected - The selected Image for the background.
* @property {boolean} favorite - Whether the image is marked as favorite.
* @property {string} category - The category/folder of the image.
* @property {string} src - The objectURL for the image
* @property {number} id - The ID of the image.
* @property {width} width - The width of the image.
* @property {height} height - The height of the image.
*/
// Hooks
/** @returns {[typeof defaultSettings, React.Dispatch<typeof defaultSettings>]} */
function useSettings() {
const [settings, setSettings] = useState(constants.settings);
const setSyncedSettings = useCallback((newSettings) => {
setSettings((prevSettings) => {
const updatedSettings = newSettings instanceof Function ? newSettings(prevSettings) : newSettings;
Data.save(meta.slug, 'settings', updatedSettings);
constants.settings = { ...updatedSettings };
return updatedSettings;
});
}, []);
return [settings, setSyncedSettings]
}
/**
* Utility function to open an IndexedDB database.
* @param {string} storeName - The name of the object store.
* @returns {Promise<IDBDatabase>} A promise that resolves to the database instance.
*/
function openDB(storeName) {
return new Promise((resolve, reject) => {
// Erst oeffnen und pruefen; fehlt der Store, mit erhoehter Version nachlegen.
const probe = indexedDB.open(DATA_BASE_NAME);
probe.onupgradeneeded = event => {
/** @type {IDBDatabase} */
const db = event.target.result;
if (!db.objectStoreNames.contains(storeName))
db.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true });
};
probe.onsuccess = event => {
/** @type {IDBDatabase} */
const db = event.target.result;
if (db.objectStoreNames.contains(storeName)) return resolve(db);
// Store fehlt -> Version hochziehen und nachtraeglich anlegen.
const nextVersion = db.version + 1;
db.close();
const upgrade = indexedDB.open(DATA_BASE_NAME, nextVersion);
upgrade.onupgradeneeded = e => {
const upgraded = e.target.result;
if (!upgraded.objectStoreNames.contains(storeName))
upgraded.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true });
};
upgrade.onsuccess = e => resolve(e.target.result);
upgrade.onerror = e => reject(e.target.error);
upgrade.onblocked = () => reject(new Error(
`IndexedDB-Upgrade blockiert: eine andere Verbindung zu "${DATA_BASE_NAME}" ist noch offen.`
));
};
probe.onerror = event => {
reject(event.target.error);
};
});
};
/**
* Utility function to get all items from the store.
* @param {IDBDatabase} db - The database instance.
* @param {string} storeName - The name of the object store.
* @returns {Promise<ImageItem[]>} A promise that resolves to an array of items.
*/
function getAllItems(db, storeName) {
return new Promise((resolve, reject) => {
const store = db.transaction([storeName], 'readonly').objectStore(storeName);
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
};
/**
* Utility function to save items to the store.
* @param {IDBDatabase} db - The database instance.
* @param {string} storeName - The name of the object store.
* @param {ImageItem[]} newItems - The items to save.
* @param {ImageItem[]} prevItems - The previous state of items.
* @returns {Promise<void>}
*/
function saveItems(db, storeName, newItems, prevItems) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const newIds = new Set(newItems.map(item => item.id));
const prevIds = new Set(prevItems.map(item => item.id));
// Add/update items
newItems.forEach(e => {
if (!prevIds.has(e.id)) {
store.add(e);
} else {
store.put(e);
}
});
// Remove deleted items
prevItems.forEach(item => {
if (!newIds.has(item.id)) {
store.delete(item.id);
}
});
transaction.oncomplete = () => {
try {
// Keep an in-memory cache of images to avoid repeated DB reads
constants._cachedImages = Array.isArray(newItems) ? newItems.map(e => ({ ...e })) : [];
} catch (e) { /* ignore */ }
resolve();
};
transaction.onerror = (event) => {
reject(event.target.error);
};
});
};
/**
* Custom hook for IndexedDB.
* @param {string} storeName - The name of the object store.
* @returns {[ImageItem[], React.Dispatch<React.SetStateAction<ImageItem[]>]} An array containing the items and a function to add items.
*/
function useIDB(storeName = 'images') {
/** @type [ImageItem[], React.Dispatch<React.SetStateAction<ImageItem[]>>] */
const [items, setItems] = useState([]);
const countEffect = useRef(0);
const accessDB = useCallback(/** @param {(storedItems: ImageItem[], database: IDBDatabase) => void} cb */ cb => {
/** @type {IDBDatabase | undefined} db */
let db;
openDB(storeName).then(database => {
db = database;
return getAllItems(db, storeName);
}).then(storedItems =>
cb(storedItems, db)
).catch(err => {
console.error('Error opening database: ', err);
}).finally(() => {
db?.close();
});
}, []);
useEffect(() => {
accessDB(storedItems => {
setItems(storedItems.map(e => {
if (!e.src) ensureObjectURL(e);
return e;
}))
/* Fehlende Vorschauen im Hintergrund nachrechnen. Solange sie fehlen,
zeigt das Raster die Originale, es ist also nur langsam, nie leer. */
backfillThumbs(storedItems, () => setItems(prev => [...prev]));
})
return () => {
accessDB((storedItems, db) => {
const clearedItems = storedItems.map(e => {
if (!e.selected) {
clearObjectURL(e);
e.src = null;
}
return e;
});
saveItems(db, storeName, clearedItems, storedItems);
})
}
}, []);
const itemsRef = useRef(items);
useEffect(() => { itemsRef.current = items }, [items]);
const saveChainRef = useRef(Promise.resolve());
useEffect(() => {
countEffect.current++;
if (countEffect.current > 1) {
// Chain onto the previous save so writes happen strictly one at a time, and always
// persist the LATEST items (itemsRef.current) rather than the value captured when this
// effect fired. Without this, uploading several images at once could trigger multiple
// overlapping save cycles; a slower one finishing after a faster one would see the
// faster one's newly-added items as "not in my newItems list" and delete them again.
saveChainRef.current = saveChainRef.current.then(() => new Promise(resolve => {
accessDB((storedItems, db) => {
saveItems(db, storeName, itemsRef.current, storedItems).then(resolve).catch(err => { console.error('Error saving images: ', err); resolve(); });
});
}));
}
}, [items]);
return [items, setItems];
};
// Similar to useState, but also returns a ref with the current state. Useful when you need the most recent state when unmounting.
function useStateWithRef(initial) {
const [state, setState] = useState(initial);
const ref = useRef(state);
const setStateAndRef = useCallback((newState) => {
ref.current = newState instanceof Function ? newState(ref.current) : newState;
setState(newState);
}, [setState]);
return [state, setStateAndRef, ref];
}
// Components
function IconComponent({ onClick, ...props }) {
const handleKeyDown = useCallback(e => {
props.onKeyDown?.(e);
if (e.key === 'Enter' || e.key === ' ') onClick();
}, [onClick, props.onKeyDown]);
return jsx(IconButton, {
TooltipProps: { text: 'DynamicBackgrounds', position: 'bottom', shouldShow: props.showTooltip },
ButtonProps: {
...props,
component: 'div',
tabIndex: '0',
onKeyDown: handleKeyDown,
onClick: onClick,
className: [constants.toolbarClasses?.iconWrapper, !props.showTooltip ? constants.toolbarClasses?.selected : undefined, constants.toolbarClasses?.clickable].join(' '),
},
SvgProps: {
path: "M20 4v12H8V4zm0-2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-8.5 9.67 1.69 2.26 2.48-3.1L19 15H9zM2 6v14c0 1.1.9 2 2 2h14v-2H4V6z",
className: constants.toolbarClasses?.icon,
}
})
}
function ManagerComponent({ onRequestClose }) {
const mainComponent = useRef(null);
useEffect(() => {
let mouseDownOnPopout = false;
/* Drei Stufen, absichtlich in dieser Reihenfolge:
1. der Klassenname aus dem Webpack-Modul, wenn er auffindbar ist
2. sonst ueber das Attribut - der Name "layerContainer" ueberlebt einen
Hash-Wechsel, die Klasse selbst gibt es weiterhin
3. sonst das Fenster selbst als Bezug
Frueher stand hier ein hartes Aussteigen, wenn Schritt 1 fehlschlug.
Genau das ist passiert: Discord entfernte "trapClicks", der Selektor
wurde ".undefined", und mit dem Ausstieg wurden die Handler nie
installiert. Das Fenster liess sich dann nur noch ueber sein Symbol
schliessen, blieb ueber allem liegen und ging auch beim Oeffnen eines
anderen Plugins nicht weg.
Ein fehlender Klassenname darf eine Funktion verlangsamen oder
ungenauer machen, aber nicht abschalten. Fuer das Schliessen bei Klick
nach aussen genuegt das Fenster als Bezug voellig. */
const layerContainer =
reverseQuerySelector(mainComponent.current, '.' + constants.layerContainerClass?.layerContainer) ||
reverseQuerySelector(mainComponent.current, '[class*="layerContainer"]') ||
mainComponent.current;
if (!layerContainer) return;
const ctrl = new AbortController();
constants.settings.enableDrop && layerContainer.style.setProperty('z-index', '2002');
addEventListener('mousedown', e => {
mouseDownOnPopout = layerContainer.contains(e.target)
}, ctrl);
addEventListener('mouseup', e => {
// If a context menu was just opened by our code, suppress closing the popout briefly
if (constants._suppressClose) { constants._suppressClose = false; return; }
/* Liegt der Klick in einer Ebene UEBER der App, soll nichts zugehen -
sonst wuerde sich das Fenster beim Bedienen des eigenen Kontextmenues
selbst schliessen.
Hier stand vorher [class*="layer"] in der Aufzaehlung, und das war zu
grob: Discord haengt die gesamte App unter "layer__ baseLayer__".
Damit traf closest() bei JEDEM Klick, die Bedingung war immer wahr,
und das Fenster ging nie zu. Es liess sich nur noch ueber sein Symbol
schliessen und blieb ueber allem liegen.
Deshalb umgekehrt gefragt: Alles INNERHALB der Grundebene ist normaler
App-Inhalt und schliesst. Was darueber liegt, schliesst nicht.
Findet sich die Grundebene nicht mehr, wird bewusst auf "schliessen"
zurueckgefallen. Dann geht das Fenster einmal zu viel zu, statt gar
nicht mehr - das ist die harmlosere Richtung. */
const baseLayer = document.querySelector('[class*="baseLayer"]');
const ueberDerApp = (baseLayer ? !baseLayer.contains(e.target) : false)
|| !!e.target.closest('[role="menu"], [role="listbox"], [role="dialog"]');
if (mouseDownOnPopout || layerContainer.contains(e.target) || e.target.closest('#' + meta.slug) || ueberDerApp) return;
onRequestClose();
}, ctrl);
addEventListener('keydown', e => {
e.key === 'Escape' && layerContainer.childElementCount === 1 && (onRequestClose(), e.stopPropagation())
}, { capture: true, signal: ctrl.signal });
return () => {
layerContainer.style.removeProperty('z-index');
ctrl.abort();
}
}, []);
// FocusLock deaktiviert - verursacht Probleme mit dem Settings-Menü
// !constants.settings.enableDrop && constants.nativeUI.useFocusLock?.(mainComponent);
return jsx('div', {
ref: mainComponent,
role: "dialog",
tabIndex: "-1",
"aria-modal": "true",
style: { overflow: 'visible' },
className: [constants.messagesPopoutClasses?.messagesPopoutWrap, 'DynamicBackgrounds-popoutWrap'].filter(Boolean).join(' '),
}, jsx('div', { className: 'DynamicBackgrounds-popoutInner' }, jsx(ManagerHead), jsx(ManagerBody)) )
}
function ManagerHead() {
return jsx('div', {
className: [constants.messagesPopoutClasses?.header, 'DynamicBackgrounds-head'].filter(Boolean).join(' ')
}, jsx('h1', {
className: [constants.textStyles?.defaultColor, constants.textStyles?.['heading-md/medium']].join(' '),
}, "DynamicBackgrounds"));
}
function ManagerBody() {
const [images, setImages] = useIDB();
const [settings, setSettings] = useSettings();
const [stackOffsets, setStackOffsets] = useState({});
const [stackAnimating, setStackAnimating] = useState({});
const [showCategoryInput, setShowCategoryInput] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const categoryInputRef = useRef(null);
// State: Slideshow nur Favoriten (initial from saved settings)
const [slideshowFavoritesOnly, setSlideshowFavoritesOnly] = useState(constants.settings?.slideshow?.favoritesOnly || false);
// Export Overlay State
const [showExportOverlay, setShowExportOverlay] = useState(false);
// Bildübersicht bleibt immer gleich (nur Kategorie-Filter)
const filteredImages = useMemo(() => {
let imgs = images;
if (settings.selectedCategory) imgs = imgs.filter(img => (img.category || FALLBACK_CATEGORY) === settings.selectedCategory);
return imgs;
}, [images, settings.selectedCategory]);
// Gruppiere Bilder pro Kategorie für die Stack-Übersicht
const groupedByCategory = useMemo(() => {
const map = {};
(settings.categories || []).forEach(c => map[c] = []);
for (const img of images) {
const c = img.category || FALLBACK_CATEGORY;
if (!map[c]) map[c] = [];
map[c].push(img);
}
return map;
}, [images, settings.categories]);
/* ALLE vorhandenen Kategorien: die eingetragenen PLUS die, die nur noch an
Bildern haengen. Ohne diese Vereinigung waeren Bilder verwaister
Kategorien in der Oberflaeche unerreichbar. */
const allCategories = useMemo(() => {
const seen = new Set();
const out = [];
for (const c of [...(settings.categories || []), ...Object.keys(groupedByCategory)]) {
if (c && !seen.has(c)) { seen.add(c); out.push(c); }
}
return out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}, [settings.categories, groupedByCategory]);
/* Selbstheilung: verwaiste Kategorien zurueck in die Einstellungen, damit
sie auch in den Auswahllisten auftauchen. */
useEffect(() => {
const known = new Set(settings.categories || []);
const orphans = Object.keys(groupedByCategory)
.filter(c => c && !known.has(c) && (groupedByCategory[c] || []).length > 0);
if (!orphans.length) return;
console.log('%c[DynamicBackgrounds] %cOrphaned categories restored:', "color:#DBDCA6;font-weight:bold", "", orphans);
setSettings(prev => ({ ...prev, categories: [...(prev.categories || []), ...orphans] }));
}, [groupedByCategory, settings.categories]);
// Preload cache to avoid blank images when rotating stacks
const preloadCache = useRef(new Set());
// Warm-cache: preload all image.src URLs (objectURLs) so showing them is instant
// Throttle preloads using small batches to avoid CPU/memory spikes
useEffect(() => {
let cancelled = false;
const toPreload = images.filter(it => it && it.src && !preloadCache.current.has(it.id));
const batchSize = 6;
let idx = 0;
const runBatch = () => {
if (cancelled) return;
const end = Math.min(idx + batchSize, toPreload.length);
for (; idx < end; idx++) {
const it = toPreload[idx];
try {
const img = new Image();
img.src = it.src;
if (img.decode) {
img.decode().then(() => { if (!cancelled) preloadCache.current.add(it.id); }).catch(() => { if (!cancelled) preloadCache.current.add(it.id); });
} else {
img.onload = () => { if (!cancelled) preloadCache.current.add(it.id); };
}
} catch (e) { /* ignore */ }
}
if (idx < toPreload.length) {
if ('requestIdleCallback' in window) requestIdleCallback(runBatch, { timeout: 500 });
else setTimeout(runBatch, 50);
}
};
if (toPreload.length) {
if ('requestIdleCallback' in window) requestIdleCallback(runBatch, { timeout: 500 });
else setTimeout(runBatch, 50);
}
return () => { cancelled = true; };
}, [images]);
// When stack offsets change, ensure the upcoming window images are preloaded (reduces flicker)
// Throttled and batched similar to the global warm-cache
useEffect(() => {
let cancelled = false;
try {
const candidates = [];
Object.keys(stackOffsets).forEach(cat => {
const items = groupedByCategory[cat] || [];
const m = items.length;
if (!m) return;
const offset = stackOffsets[cat] || 0;
const toPreload = Math.min(6, m);
for (let i = 0; i < toPreload; i++) {
const it = items[(offset + i) % m];
if (!it || !it.src) continue;
if (preloadCache.current.has(it.id)) continue;
candidates.push(it);
}
});
const batchSize = 6;
let idx = 0;
const runBatch = () => {
if (cancelled) return;
const end = Math.min(idx + batchSize, candidates.length);
for (; idx < end; idx++) {
const it = candidates[idx];
try {
const img = new Image();
img.src = it.src;
if (img.decode) {
img.decode().then(() => { if (!cancelled) preloadCache.current.add(it.id); }).catch(() => { if (!cancelled) preloadCache.current.add(it.id); });
} else {
img.onload = () => { if (!cancelled) preloadCache.current.add(it.id); };
}
} catch (e) { /* ignore */ }
}
if (idx < candidates.length) {
if ('requestIdleCallback' in window) requestIdleCallback(runBatch, { timeout: 500 });
else setTimeout(runBatch, 50);
}
};
if (candidates.length) {
if ('requestIdleCallback' in window) requestIdleCallback(runBatch, { timeout: 500 });
else setTimeout(runBatch, 50);
}
} catch (e) { /* ignore */ }
return () => { cancelled = true; };
}, [stackOffsets, groupedByCategory, images]);
/* Filter-Zustand fuer den Diashow-Manager. Bewusst auf constants und NICHT
auf window: die BD-Richtlinien untersagen das Veraendern globaler Objekte,
und constants verschwindet mit dem Plugin. */
useEffect(() => {
constants._slideshowFavoritesOnly = slideshowFavoritesOnly;
}, [slideshowFavoritesOnly]);
// Synchronisiere Kategorie-Filter-State für Slideshow persistent in Settings
useEffect(() => {
// Support both legacy single-value and new multi-value filters
const filters = settings.slideshow?.categoryFilters?.length ? settings.slideshow.categoryFilters : (settings.slideshow?.categoryFilter ? [settings.slideshow.categoryFilter] : null);
constants._slideshowCategoryFilters = filters;
}, [settings.slideshow?.categoryFilters, settings.slideshow?.categoryFilter]);
// Wenn die Einstellung extern (oder beim Laden) geändert wurde, synchronisiere den lokalen Toggle
useEffect(() => {
const fav = !!settings?.slideshow?.favoritesOnly;
if (fav !== slideshowFavoritesOnly) setSlideshowFavoritesOnly(fav);
}, [settings?.slideshow?.favoritesOnly]);
const contextMenuObj = useMemo(() => {
const saveAndCopy = givenItem => [givenItem.image.type !== 'image/gif' && {
label: "Copy image",
action: async () => {
try {
if (givenItem.image.type === 'image/png' || givenItem.image.type === 'image/jpeg') {
const arrayBuffer = await givenItem.image.arrayBuffer()
DiscordNative.clipboard.copyImage(new Uint8Array(arrayBuffer), givenItem.src)
} else {
const imageBitmap = await createImageBitmap(givenItem.image);
const Canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
const ctx = Canvas.getContext('2d');
ctx.drawImage(imageBitmap, 0, 0);
const pngBlob = await Canvas.convertToBlob({ type: 'image/png' });
const arrayBuffer = await pngBlob.arrayBuffer()
DiscordNative.clipboard.copyImage(new Uint8Array(arrayBuffer), givenItem.src)
}
UI.showToast("Image copied to clipboard.", { type: 'success' });
} catch (err) {
UI.showToast("Image could not be copied. " + err, { type: 'error' });
}
}
}, {
label: "Save image",
action: async () => {
try {
const arrayBuffer = new Uint8Array(await givenItem.image.arrayBuffer());
let url = givenItem.image.name
if (!url) {
url = (new URL(givenItem.src)).pathname.split('/').pop() || 'unknown';
const FileExtension = {
jpeg: [[0xFF, 0xD8, 0xFF, 0xEE]],
jpg: [[0xFF, 0xD8, 0xFF, 0xDB], [0xFF, 0xD8, 0xFF, 0xE0], [0xFF, 0xD8, 0xFF, 0xE1]],
png: [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]],
bmp: [[0x42, 0x4D]],
gif: [[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
heic: [[0x00, 0x00, 0x00, null, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63]],
avif: [[0x00, 0x00, 0x00, null, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66]],
webp: [[0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50]],
svg: [[0x3C, 0x73, 0x76, 0x67]],
ico: [[0x00, 0x00, 0x01, 0x00]],
}
loop: for (const [ext, signs] of Object.entries(FileExtension)) {
for (const sign of signs) {
if (sign.every((e, i) => e === null || e === arrayBuffer[i])) {
url += '.' + ext;
break loop;
}
}
}
}
DiscordNative.fileManager.saveWithDialog(arrayBuffer, url).then(() => {
UI.showToast("Saved Image!", { type: 'success' });
});
} catch (err) {
UI.showToast("Failed to save Image. " + err, { type: 'error' });
}
}
}].filter(Boolean);
return {
saveAndCopy,
lazyCarousel: constants.lazyCarousel ? (givenItem) => {
try {
constants.lazyCarousel({
items: images.map(img => ({
url: img.src, original: "",
zoomThumbnailPlaceholder: img.src,
contentType: img.image.type,
srcIsAnimated: img.image.type === 'image/gif',
type: 'IMAGE',
width: img.width, height: img.height,
sourceMetadata: {
identifier: {
filename: img.image.name,
size: img.image.size,
type: "attachment"
}
},
})),
location: "Media Mosaic",
startingIndex: givenItem.id - 1,
onContextMenu: e => {
const src = e.target.closest(`img`)?.src;
if (!src) return;
ContextMenu.open(e, ContextMenu.buildMenu(saveAndCopy(images.find(e => e.src === src))))
},
})
} catch (err) { console.error(err) }
} : null
}
}, [images]);
const handleSelect = useCallback(index => {
setImages(prev => {
prev.forEach(e => {
e.selected = e.id === index;
});
return [...prev];
});
}, [setImages]);
const onNextShuffle = useCallback(() => {
/* Dieselbe Reihenfolge wie im Takt der Diashow, siehe
vergleicheNachKategorie. Ohne die Sortierung lief dieser Knopf in
Hochladereihenfolge, waehrend der Takt bereits alphabetisch ging. */
const reihenfolge = images.slice().sort(vergleicheNachKategorie);
const currentIndex = reihenfolge.reduce((p, c, i) => c.selected ? i : p, null);
let x, it = 0;
do x = constants.settings.slideshow.shuffle || currentIndex === null ? Math.floor(Math.random() * reihenfolge.length) : (currentIndex + 1) % reihenfolge.length
while (x === currentIndex && it++ < 25)
const item = reihenfolge[x];
handleSelect(item.id);
constants.settings.slideshow.enabled ? slideShowManager.start() : slideShowManager.stop();
viewTransition.setImage(item.src);
}, [images, handleSelect]);
// Kategorie-Funktionen
const handleCategoryChange = useCallback((imageId, category) => {
setImages(prev => prev.map(img => img.id === imageId ? { ...img, category } : img));
}, [setImages]);
const handleAddCategory = useCallback(() => {
if (newCategoryName.trim() && !settings.categories.includes(newCategoryName.trim())) {
setSettings(prev => ({ ...prev, categories: [...prev.categories, newCategoryName.trim()] }));
setNewCategoryName('');
setShowCategoryInput(false);
}
}, [newCategoryName, settings.categories, setSettings]);
const handleDeleteCategory = useCallback((categoryToDelete) => {
if (categoryToDelete === FALLBACK_CATEGORY) return;
/* Rueckfrage vor dem Loeschen. Die Bilder bleiben erhalten und wandern
nach FALLBACK_CATEGORY - das steht bewusst im Text, sonst rechnet man
mit Datenverlust und traut sich nicht. Bei leeren Kategorien entfaellt
die Rueckfrage, da gibt es nichts zu verlieren. */
const affected = images.filter(img => (img.category || FALLBACK_CATEGORY) === categoryToDelete).length;
if (affected > 0) {
const message = affected === 1
? `Delete the category "${categoryToDelete}"?\n\n1 image will be moved to "${FALLBACK_CATEGORY}". No images are deleted.`
: `Delete the category "${categoryToDelete}"?\n\n${affected} images will be moved to "${FALLBACK_CATEGORY}". No images are deleted.`;
if (!confirm(message)) return;
}
// Bilder in dieser Kategorie auf die Auffang-Kategorie setzen
setImages(prev => prev.map(img => img.category === categoryToDelete ? { ...img, category: FALLBACK_CATEGORY } : img));
// Kategorie aus Liste entfernen
setSettings(prev => {
const nextFilters = (prev.slideshow?.categoryFilters || []).filter(c => c !== categoryToDelete);
const nextSingleFilter = prev.slideshow?.categoryFilter === categoryToDelete ? null : prev.slideshow?.categoryFilter;
return {
...prev,
categories: prev.categories.filter(c => c !== categoryToDelete),
selectedCategory: prev.selectedCategory === categoryToDelete ? null : prev.selectedCategory,
// Otherwise a deleted category could stay stuck as the slideshow filter, silently leaving
// the slideshow with zero matching images and no indication why.
slideshow: { ...prev.slideshow, categoryFilters: nextFilters, categoryFilter: nextSingleFilter }
};
});
// images muss in die Abhaengigkeiten: die Rueckfrage zaehlt daraus die
// betroffenen Bilder, sonst arbeitet sie mit einem veralteten Stand.
}, [images, setImages, setSettings]);
// Export Handler
const handleDownloadImage = useCallback(async (img) => {
try {
let blob;
if (img.image && img.image instanceof Blob) {
// Der Blob aus der IndexedDB direkt, ohne Umweg
blob = img.image;
} else if (img.src) {
// Rueckfall: ueber die Objekt-URL nachladen
const response = await fetch(img.src);
blob = await response.blob();
} else {
console.error('No image data available for download');
UI.showToast("Image cannot be downloaded - no data available", { type: 'error' });
return;
}
const fileName = img.name || `background_${img.id}.jpg`;
/* Bewusst der Browser-Weg ueber ein <a download>: Der Renderer hat
keinen verlaesslichen Dateizugriff, und dieser Weg loest Discords
eigenen Speichern-Dialog aus. */
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
UI.showToast(`"${fileName}" downloaded`, { type: 'success' });
} catch (error) {
console.error('Download failed:', error);
UI.showToast("Download failed: " + error.message, { type: 'error' });
}
}, []);
const handleDownloadAllZip = useCallback(async () => {
try {
const entries = [];
const usedNames = new Set();
for (const img of filteredImages) {
let blob;
if (img.image) {
blob = img.image;
} else if (img.src) {
const response = await fetch(img.src);
blob = await response.blob();
} else {
continue;
}
// Bilder in Ordner nach Kategorie. Kategorien und Favoriten liegen nur
// in der IndexedDB - ohne Ordner waere die Einteilung nach einem
// Wiederherstellen verloren.
const folder = String(img.category || FALLBACK_CATEGORY).replace(/[\\/:*?"<>|]/g, '_');
let name = folder + '/' + (img.name || `background_${img.id}.jpg`);
// Gleiche Dateinamen in derselben Kategorie durchnummerieren, sonst
// ueberschreiben sich Eintraege beim Entpacken gegenseitig.
if (usedNames.has(name)) {
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let n = 2;
while (usedNames.has(`${base} (${n})${ext}`)) n++;
name = `${base} (${n})${ext}`;
}
usedNames.add(name);
entries.push({ name, data: new Uint8Array(await blob.arrayBuffer()) });
}
if (!entries.length) return UI.showToast('No images to export', { type: 'info' });
const content = createZipBlob(entries);
const url = URL.createObjectURL(content);
const a = document.createElement('a');
a.href = url;
a.download = 'backgrounds.zip';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
UI.showToast("ZIP file downloaded", { type: 'success' });
} catch (error) {
console.error('ZIP Download failed:', error);
UI.showToast("Could not create the ZIP file: " + error.message, { type: 'error' });
}
}, [filteredImages]);
return jsx(Fragment, {
children: [
jsx('div', {
className: [constants.messagesPopoutClasses?.messageGroupWrapper, constants.markupStyles?.markup, constants.messagesPopoutClasses?.messagesPopout].join(' '),
style: { display: "grid", gridTemplateRows: 'auto auto auto 1fr', overflowX: 'visible', overflowY: 'hidden', border: '0' },
children: [
jsx(InputComponent, { setImages, currentCategory: settings.selectedCategory }),
/* WERKZEUGLEISTE
Zwei beschriftete Zeilen statt einer Reihe gleich aussehender
Knoepfe. "Favorites" allein sagt niemandem, ob damit gefiltert,