-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathpause-customizer.ts
More file actions
920 lines (814 loc) · 33.4 KB
/
Copy pathpause-customizer.ts
File metadata and controls
920 lines (814 loc) · 33.4 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
import { MODULE_ID, SETTINGS, DEFAULTS, I18N_KEYS } from './constants.js';
/**
* Minimal typed interface for Foundry's ClientSettings.
*/
interface FoundrySettings {
register(module: string, key: string, data: Record<string, unknown>): void;
get(module: string, key: string): unknown;
set(module: string, key: string, value: unknown): Promise<unknown>;
}
/**
* Context passed to GamePause render hook in v14.
*/
interface GamePauseContext {
cssClass: string;
icon: string;
text: string;
spin: boolean;
}
/**
* Cached system defaults captured before our module applies any overrides.
* Populated on the first renderApplicationV2 call for the pause element
* (other modules/systems run their renderGamePause hooks first).
*/
const systemDefaults: { icon?: string; text?: string; cssSnapshot?: Record<string, string> } = {};
/** Matches supported image and video file extensions. */
const MEDIA_EXTENSION = /\.(jpg|jpeg|png|gif|svg|webp|avif|bmp|tiff?|mp4|webm|ogg)$/i;
/** Matches supported video file extensions (subset of MEDIA_EXTENSION). */
const VIDEO_EXTENSION = /\.(mp4|webm|ogg)$/i;
/**
* Browse a directory and return the contained image/video files.
* Returns an empty array for a file path, an empty directory, or when browsing
* fails (e.g. the user lacks permission).
*/
async function browseDirectoryImages(path: string): Promise<string[]> {
if (!path || MEDIA_EXTENSION.test(path)) return [];
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const FP = (foundry as any).applications.apps.FilePicker.implementation;
const result = await FP.browse('data', path);
return (result.files as string[]).filter((f: string) => MEDIA_EXTENSION.test(f));
} catch {
return [];
}
}
/**
* GM-only: resolve the configured chooseFile path to a concrete image and store
* it in the SELECTED_IMAGE world setting, so that every client renders the same
* file. For a directory a random image is rolled; single files render directly
* from chooseFile (handled in the render hook), so the selection is cleared.
*/
async function updateSelectedImage(): Promise<void> {
if (!game.user?.isGM) return;
const settings = game.settings as unknown as FoundrySettings;
const chooseFile = settings.get(MODULE_ID, SETTINGS.CHOOSE_FILE) as string;
let selected = '';
if (chooseFile && !MEDIA_EXTENSION.test(chooseFile)) {
const files = await browseDirectoryImages(chooseFile);
if (files.length > 0) {
selected = files[Math.floor(Math.random() * files.length)]!;
}
}
await settings.set(MODULE_ID, SETTINGS.SELECTED_IMAGE, selected);
}
// Register all settings that don't depend on runtime data
Hooks.once('init', () => {
const settings = game.settings as unknown as FoundrySettings;
const defaultLabel = game.i18n?.localize(I18N_KEYS.DEFAULT) ?? 'Default';
const noneLabel = game.i18n?.localize(I18N_KEYS.NONE) ?? 'None';
// --- Image Settings ---
settings.register(MODULE_ID, SETTINGS.CHOOSE_FILE, {
name: game.i18n?.localize(I18N_KEYS.SELECT_IMAGE) ?? 'Select image',
hint: game.i18n?.localize(I18N_KEYS.SELECT_IMAGE_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
filePicker: 'imagevideo',
requiresReload: false,
});
// Hidden shared selection so every client renders the same image. Holds the
// GM-resolved random pick for a directory; empty for single files (rendered
// directly from chooseFile) or when no directory image is available.
settings.register(MODULE_ID, SETTINGS.SELECTED_IMAGE, {
scope: 'world',
config: false,
type: String,
default: '',
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.IMAGE_WIDTH, {
name: game.i18n?.localize(I18N_KEYS.IMAGE_WIDTH) ?? 'Image width',
hint: game.i18n?.localize(I18N_KEYS.IMAGE_WIDTH_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.IMAGE_HEIGHT, {
name: game.i18n?.localize(I18N_KEYS.IMAGE_HEIGHT) ?? 'Image height',
hint: game.i18n?.localize(I18N_KEYS.IMAGE_HEIGHT_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.OPACITY, {
name: game.i18n?.localize(I18N_KEYS.OPACITY) ?? 'Opacity',
hint: game.i18n?.localize(I18N_KEYS.OPACITY_HINT) ?? '',
scope: 'world',
config: true,
type: Number,
range: { min: 0, max: 1, step: 0.05 },
default: 1,
requiresReload: false,
});
// --- Text Settings ---
settings.register(MODULE_ID, SETTINGS.PAUSE_TEXT, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT) ?? 'Pause text',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.PAUSE_TEXT_COLOR, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_COLOR) ?? 'Pause text color',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_COLOR_HINT) ?? '',
scope: 'world',
config: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: new (foundry.data.fields as any).ColorField(),
default: DEFAULTS[SETTINGS.PAUSE_TEXT_COLOR],
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.PAUSE_TEXT_FONT_SIZE, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_FONT_SIZE) ?? 'Pause text font size',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_FONT_SIZE_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.PAUSE_TEXT_FONT_WEIGHT, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_FONT_WEIGHT) ?? 'Font weight',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_FONT_WEIGHT_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: 'default',
choices: {
default: defaultLabel,
normal: 'normal',
bold: 'bold',
lighter: 'lighter',
bolder: 'bolder',
'100': '100',
'200': '200',
'300': '300',
'400': '400',
'500': '500',
'600': '600',
'700': '700',
'800': '800',
'900': '900',
},
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.PAUSE_TEXT_TRANSFORM, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_TRANSFORM) ?? 'Text transform',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_TRANSFORM_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: 'default',
choices: {
default: defaultLabel,
none: noneLabel,
uppercase: 'UPPERCASE',
lowercase: 'lowercase',
capitalize: 'Capitalize',
},
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.PAUSE_TEXT_LETTER_SPACING, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_LETTER_SPACING) ?? 'Letter spacing',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_LETTER_SPACING_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.PAUSE_TEXT_SHADOW, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_SHADOW) ?? 'Pause text shadow',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_SHADOW_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
// --- Position Settings ---
settings.register(MODULE_ID, SETTINGS.IMAGE_MARGIN_TOP, {
name: game.i18n?.localize(I18N_KEYS.IMAGE_MARGIN_TOP) ?? 'Icon vertical offset',
hint: game.i18n?.localize(I18N_KEYS.IMAGE_MARGIN_TOP_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.PAUSE_TEXT_MARGIN_TOP, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_MARGIN_TOP) ?? 'Text vertical offset',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_MARGIN_TOP_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
// --- Animation Settings ---
settings.register(MODULE_ID, SETTINGS.ANIMATION_DIRECTION, {
name: game.i18n?.localize(I18N_KEYS.ANIMATION_DIRECTION) ?? 'Animation direction',
hint: game.i18n?.localize(I18N_KEYS.ANIMATION_DIRECTION_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: 'default',
choices: {
default: defaultLabel,
none: noneLabel,
normal: game.i18n?.localize(I18N_KEYS.DIRECTION_ROTATE) ?? 'Rotate',
reverse: game.i18n?.localize(I18N_KEYS.DIRECTION_ROTATE_REVERSE) ?? 'Rotate reverse',
alternate: game.i18n?.localize(I18N_KEYS.DIRECTION_ALTERNATE) ?? 'Alternate',
'alternate-reverse':
game.i18n?.localize(I18N_KEYS.DIRECTION_ALTERNATE_REVERSE) ?? 'Alternate reverse',
},
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.ANIMATION_DURATION, {
name: game.i18n?.localize(I18N_KEYS.ANIMATION_DURATION) ?? 'Animation duration',
hint: game.i18n?.localize(I18N_KEYS.ANIMATION_DURATION_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.ANIMATION_TIMING, {
name: game.i18n?.localize(I18N_KEYS.ANIMATION_TIMING) ?? 'Animation timing',
hint: game.i18n?.localize(I18N_KEYS.ANIMATION_TIMING_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: 'default',
choices: {
default: defaultLabel,
linear: 'linear',
ease: 'ease',
'ease-in': 'ease-in',
'ease-out': 'ease-out',
'ease-in-out': 'ease-in-out',
},
requiresReload: false,
});
// --- Background ---
settings.register(MODULE_ID, SETTINGS.PAUSE_BACKGROUND, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_BACKGROUND) ?? 'Background',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_BACKGROUND_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
requiresReload: false,
});
settings.register(MODULE_ID, SETTINGS.PAUSE_BACKGROUND_IMAGE, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_BACKGROUND_IMAGE) ?? 'Background image',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_BACKGROUND_IMAGE_HINT) ?? '',
scope: 'world',
config: true,
type: String,
default: '',
filePicker: 'image',
requiresReload: false,
});
});
// Register font family setting in setup hook (fonts are available at this point)
Hooks.once('setup', () => {
const settings = game.settings as unknown as FoundrySettings;
const fontKeys = [
...Object.keys(CONFIG.fontDefinitions),
...Object.keys((settings.get('core', 'fonts') as Record<string, unknown>) ?? {}),
].sort();
const fontChoices: Record<string, string> = {
default: game.i18n?.localize(I18N_KEYS.DEFAULT) ?? 'Default',
};
for (const key of fontKeys) {
fontChoices[key] = key;
}
settings.register(MODULE_ID, SETTINGS.PAUSE_TEXT_FONT_FAMILY, {
name: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_FONT_FAMILY) ?? 'Pause text font family',
hint: game.i18n?.localize(I18N_KEYS.PAUSE_TEXT_FONT_FAMILY_HINT) ?? '',
scope: 'world',
config: true,
type: String,
choices: fontChoices,
default: 'default',
requiresReload: false,
});
});
// Re-render the pause overlay whenever our settings change
Hooks.on('updateSetting', async (setting: { key?: string }) => {
if (!setting.key?.startsWith(`${MODULE_ID}.`)) return;
// Icon path changed while paused: the GM immediately re-resolves the shared
// image so the visible overlay updates at once (an unpaused change is
// resolved on the next pauseGame instead).
if (setting.key === `${MODULE_ID}.${SETTINGS.CHOOSE_FILE}` && game.user?.isGM && game.paused) {
await updateSelectedImage();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pause = (ui as any).pause as { render?: (opts?: object) => void } | undefined;
pause?.render?.({ force: true });
});
// On startup of an already-paused world, the GM resolves the shared image only
// if none is set yet (e.g. a directory configured while unpaused, or a world
// upgraded to this version). A valid existing selection is kept, so a GM simply
// logging in does not change the image players are already seeing.
Hooks.once('ready', async () => {
if (!game.user?.isGM || !game.paused) return;
const settings = game.settings as unknown as FoundrySettings;
const selected = settings.get(MODULE_ID, SETTINGS.SELECTED_IMAGE) as string;
const chooseFile = settings.get(MODULE_ID, SETTINGS.CHOOSE_FILE) as string;
if (!selected && chooseFile && !MEDIA_EXTENSION.test(chooseFile)) {
await updateSelectedImage();
}
});
// Roll a new shared random image each time the game is paused. Only the GM
// writes it; the resulting SELECTED_IMAGE change re-renders every client with
// the same image. Non-GM clients simply render the GM's selection.
Hooks.on('pauseGame', async (paused: boolean) => {
if (paused) await updateSelectedImage();
});
/**
* Read the system/Foundry pause overlay defaults.
*
* Uses the systemDefaults cache (populated on first renderApplicationV2 before our
* overrides are applied). This captures system-specific values like DnD5e's
* custom icon. Falls back to DEFAULTS if the cache is not yet populated.
*/
function readFoundryPauseDefaults(): Record<string, string> {
const result: Record<string, string> = { ...DEFAULTS };
// Icon + text from cache (captured before our overrides in renderApplicationV2)
if (systemDefaults.icon) result[SETTINGS.CHOOSE_FILE] = systemDefaults.icon;
if (systemDefaults.text) result[SETTINGS.PAUSE_TEXT] = systemDefaults.text;
// CSS values from cache
if (systemDefaults.cssSnapshot) {
const css = systemDefaults.cssSnapshot;
if (css.color) result[SETTINGS.PAUSE_TEXT_COLOR] = css.color;
if (css.fontSize) result[SETTINGS.PAUSE_TEXT_FONT_SIZE] = css.fontSize;
if (css.letterSpacing) result[SETTINGS.PAUSE_TEXT_LETTER_SPACING] = css.letterSpacing;
if (css.textShadow !== undefined) result[SETTINGS.PAUSE_TEXT_SHADOW] = css.textShadow;
if (css.imageWidth) result[SETTINGS.IMAGE_WIDTH] = css.imageWidth;
if (css.imageHeight) result[SETTINGS.IMAGE_HEIGHT] = css.imageHeight;
if (css.opacity) result[SETTINGS.OPACITY] = css.opacity;
if (css.background) result[SETTINGS.PAUSE_BACKGROUND] = css.background;
// Animation values only relevant when the system uses rotation
if (css.hasRotation === 'false') {
result[SETTINGS.ANIMATION_DURATION] = '';
result[SETTINGS.ANIMATION_TIMING] = '';
}
}
// Font family: always reset to "default" (the select option), not the resolved CSS value
result[SETTINGS.PAUSE_TEXT_FONT_FAMILY] = 'default';
// Fallback for text if cache not populated yet
if (!result[SETTINGS.PAUSE_TEXT]) {
result[SETTINGS.PAUSE_TEXT] = game.i18n?.localize('GAME.Paused') ?? '';
}
return result;
}
/**
* Convert an rgb(r, g, b) string to a hex color string.
*/
function rgbToHex(rgb: string | undefined | null): string | null {
if (!rgb) return null;
const match = rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (!match) return rgb.startsWith('#') ? rgb : null;
const [, r, g, b] = match;
return '#' + [r, g, b].map((v) => Number(v).toString(16).padStart(2, '0')).join('');
}
// Add "Reset to Defaults" button and placeholders to the settings config
Hooks.on('renderSettingsConfig', (_app: unknown, html: HTMLElement) => {
if (!html) return;
// Find all settings for this module and get the last one
const allModuleInputs = html.querySelectorAll(`[name^="${MODULE_ID}."]`);
if (allModuleInputs.length === 0) return;
const lastInput = allModuleInputs[allModuleInputs.length - 1];
const lastFormGroup = lastInput?.closest('.form-group');
if (!lastFormGroup) return;
// Read system/Foundry defaults (from cache populated during first render)
const foundryDefaults = readFoundryPauseDefaults();
const defaultLabel = game.i18n?.localize(I18N_KEYS.DEFAULT) ?? 'Default';
// Update "Default" option labels in dropdowns to show the actual system value
const hasRotation = systemDefaults.cssSnapshot?.hasRotation !== 'false';
const noneLabel = game.i18n?.localize(I18N_KEYS.NONE) ?? 'None';
const dropdownDefaults: Record<string, string | undefined> = {
[SETTINGS.ANIMATION_DIRECTION]: hasRotation
? systemDefaults.cssSnapshot?.animationDirection
: undefined,
[SETTINGS.ANIMATION_TIMING]: hasRotation
? systemDefaults.cssSnapshot?.animationTimingFunction
: undefined,
[SETTINGS.PAUSE_TEXT_FONT_WEIGHT]: systemDefaults.cssSnapshot?.fontWeight,
[SETTINGS.PAUSE_TEXT_TRANSFORM]: systemDefaults.cssSnapshot?.textTransform,
};
for (const [key, systemValue] of Object.entries(dropdownDefaults)) {
const select = html.querySelector(`[name="${MODULE_ID}.${key}"]`) as HTMLSelectElement | null;
const defaultOption = select?.querySelector(
'option[value="default"]'
) as HTMLOptionElement | null;
if (!defaultOption) continue;
if (systemValue && systemValue !== 'default') {
// Use the localized label from the matching option if available
const matchingOption = select?.querySelector(
`option[value="${systemValue}"]`
) as HTMLOptionElement | null;
const displayValue = matchingOption?.textContent ?? systemValue;
defaultOption.textContent = `${defaultLabel} (${displayValue})`;
}
}
// Animation direction: if system has no rotation, show "Default (none)"
if (!hasRotation) {
const dirSelect = html.querySelector(
`[name="${MODULE_ID}.${SETTINGS.ANIMATION_DIRECTION}"]`
) as HTMLSelectElement | null;
const dirDefault = dirSelect?.querySelector(
'option[value="default"]'
) as HTMLOptionElement | null;
if (dirDefault) {
dirDefault.textContent = `${defaultLabel} (${noneLabel})`;
}
}
// Font family: show resolved font name in the "Default" option
if (systemDefaults.cssSnapshot?.fontFamily) {
const fontSelect = html.querySelector(
`[name="${MODULE_ID}.${SETTINGS.PAUSE_TEXT_FONT_FAMILY}"]`
) as HTMLSelectElement | null;
const fontDefault = fontSelect?.querySelector(
'option[value="default"]'
) as HTMLOptionElement | null;
if (fontDefault) {
// Show first font from the font-family stack (strip quotes)
const primaryFont = systemDefaults.cssSnapshot.fontFamily
.split(',')[0]
?.trim()
.replace(/['"]/g, '');
if (primaryFont) {
fontDefault.textContent = `${defaultLabel} (${primaryFont})`;
}
}
}
// Set placeholders on text inputs to show system/Foundry default values
const placeholderSettings = [
SETTINGS.PAUSE_TEXT,
SETTINGS.IMAGE_WIDTH,
SETTINGS.IMAGE_HEIGHT,
SETTINGS.PAUSE_TEXT_FONT_SIZE,
SETTINGS.PAUSE_TEXT_LETTER_SPACING,
SETTINGS.PAUSE_TEXT_SHADOW,
SETTINGS.IMAGE_MARGIN_TOP,
SETTINGS.PAUSE_TEXT_MARGIN_TOP,
SETTINGS.ANIMATION_DURATION,
SETTINGS.PAUSE_BACKGROUND,
];
for (const key of placeholderSettings) {
const input = html.querySelector(`[name="${MODULE_ID}.${key}"]`) as HTMLInputElement | null;
const defaultVal = foundryDefaults[key];
if (input && defaultVal) {
input.placeholder = defaultVal;
}
}
// Set placeholder on file picker inputs (custom elements with inner <input>)
const filePickerSettings = [SETTINGS.CHOOSE_FILE, SETTINGS.PAUSE_BACKGROUND_IMAGE];
for (const key of filePickerSettings) {
const defaultVal = foundryDefaults[key];
if (!defaultVal) continue;
const picker = html.querySelector(`[name="${MODULE_ID}.${key}"]`) as HTMLElement | null;
const innerInput = picker?.querySelector('input[type="text"]') as HTMLInputElement | null;
if (innerInput) innerInput.placeholder = defaultVal;
}
// Add folder browse button next to the chooseFile file picker
const chooseFilePicker = html.querySelector(
`[name="${MODULE_ID}.${SETTINGS.CHOOSE_FILE}"]`
) as HTMLElement | null;
if (chooseFilePicker) {
const fileButton = chooseFilePicker.querySelector('button') as HTMLButtonElement | null;
if (fileButton) {
const folderButton = document.createElement('button');
folderButton.type = 'button';
folderButton.innerHTML = '<i class="fa-solid fa-folder-open"></i>';
fileButton.setAttribute(
'data-tooltip',
game.i18n?.localize(I18N_KEYS.BROWSE_FILES) ?? 'Browse files'
);
folderButton.setAttribute(
'data-tooltip',
game.i18n?.localize(I18N_KEYS.BROWSE_FOLDER) ?? 'Browse folder'
);
folderButton.addEventListener('click', (event: MouseEvent) => {
event.preventDefault();
const innerInput = chooseFilePicker.querySelector(
'input[type="text"]'
) as HTMLInputElement | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const FP = (foundry as any).applications.apps.FilePicker.implementation;
new FP({
type: 'folder',
callback: (path: string) => {
if (innerInput) {
innerInput.value = path;
innerInput.dispatchEvent(new Event('change', { bubbles: true }));
}
},
}).browse();
});
fileButton.after(folderButton);
}
}
// Replace background input with textarea for longer CSS values
const bgInput = html.querySelector(
`[name="${MODULE_ID}.${SETTINGS.PAUSE_BACKGROUND}"]`
) as HTMLInputElement | null;
if (bgInput) {
const textarea = document.createElement('textarea');
textarea.name = bgInput.name;
textarea.value = bgInput.value;
textarea.placeholder = bgInput.placeholder;
textarea.rows = 2;
bgInput.replaceWith(textarea);
}
// Set placeholder on color picker text input
const colorDefault = foundryDefaults[SETTINGS.PAUSE_TEXT_COLOR];
if (colorDefault) {
const colorPicker = html.querySelector(
`[name="${MODULE_ID}.${SETTINGS.PAUSE_TEXT_COLOR}"]`
) as HTMLElement | null;
const colorText = colorPicker?.querySelector('input[type="text"]') as HTMLInputElement | null;
if (colorText) colorText.placeholder = colorDefault;
}
// Build reset button
const wrapper = document.createElement('div');
wrapper.className = 'form-group';
wrapper.style.textAlign = 'right';
const button = document.createElement('button');
button.type = 'button';
button.innerHTML = `<i class="fa fa-undo"></i> ${game.i18n?.localize(I18N_KEYS.RESET_DEFAULTS) ?? 'Reset to Defaults'}`;
button.addEventListener('click', (event: MouseEvent) => {
event.preventDefault();
// Reset text inputs to empty (placeholders will show system defaults)
const stringKeys = [
SETTINGS.CHOOSE_FILE,
SETTINGS.IMAGE_WIDTH,
SETTINGS.IMAGE_HEIGHT,
SETTINGS.PAUSE_TEXT,
SETTINGS.PAUSE_TEXT_FONT_SIZE,
SETTINGS.PAUSE_TEXT_LETTER_SPACING,
SETTINGS.PAUSE_TEXT_SHADOW,
SETTINGS.IMAGE_MARGIN_TOP,
SETTINGS.PAUSE_TEXT_MARGIN_TOP,
SETTINGS.ANIMATION_DURATION,
SETTINGS.PAUSE_BACKGROUND,
SETTINGS.PAUSE_BACKGROUND_IMAGE,
];
for (const key of stringKeys) {
const input = html.querySelector(`[name="${MODULE_ID}.${key}"]`) as HTMLInputElement | null;
if (input) input.value = '';
}
// Reset select inputs to "default" (= don't override)
const selectKeys = [
SETTINGS.ANIMATION_DIRECTION,
SETTINGS.ANIMATION_TIMING,
SETTINGS.PAUSE_TEXT_FONT_FAMILY,
SETTINGS.PAUSE_TEXT_FONT_WEIGHT,
SETTINGS.PAUSE_TEXT_TRANSFORM,
];
for (const key of selectKeys) {
const select = html.querySelector(`[name="${MODULE_ID}.${key}"]`) as HTMLSelectElement | null;
if (select) select.value = 'default';
}
// Reset range input (opacity) to system default
const defaults = readFoundryPauseDefaults();
const opacityInput = html.querySelector(
`[name="${MODULE_ID}.${SETTINGS.OPACITY}"]`
) as HTMLInputElement | null;
if (opacityInput) {
opacityInput.value = defaults[SETTINGS.OPACITY] ?? '1';
opacityInput.dispatchEvent(new Event('input', { bubbles: true }));
}
// Reset color picker to system default color
const resetColor =
defaults[SETTINGS.PAUSE_TEXT_COLOR] ?? DEFAULTS[SETTINGS.PAUSE_TEXT_COLOR] ?? '#ada7b8';
const colorPicker = html.querySelector(
`[name="${MODULE_ID}.${SETTINGS.PAUSE_TEXT_COLOR}"]`
) as HTMLElement | null;
if (colorPicker) {
const textInput = colorPicker.querySelector('input[type="text"]') as HTMLInputElement | null;
const colorInput = colorPicker.querySelector(
'input[type="color"]'
) as HTMLInputElement | null;
if (textInput) textInput.value = resetColor;
if (colorInput) colorInput.value = resetColor;
}
ui.notifications?.info(
game.i18n?.localize(I18N_KEYS.RESET_DEFAULTS_DONE) ??
'Pause Customizer settings reset to defaults.'
);
});
wrapper.appendChild(button);
lastFormGroup.after(wrapper);
});
// Apply pause customizations via renderApplicationV2 instead of renderGamePause.
// This is intentional: systems like DnD5e check Hooks.events.renderGamePause.length
// and skip their styling if other modules hook into renderGamePause. By using the
// parent class hook (renderApplicationV2), we run AFTER system hooks have applied
// their defaults (e.g. DnD5e's ampersand icon), without blocking them.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Hooks.on as (hook: string, fn: (...args: any[]) => void) => number)(
'renderApplicationV2',
(_app: unknown, element: HTMLElement, context: GamePauseContext, _options: unknown) => {
// Only handle the GamePause application
if (element.id !== 'pause') return;
if (!context.cssClass?.includes('paused')) return;
const settings = game.settings as unknown as FoundrySettings;
let img = element.querySelector('img') as HTMLElement | null;
const caption = element.querySelector('figcaption') as HTMLElement | null;
if (!img || !caption) return;
// Cache system/Foundry defaults before we apply any overrides.
// System hooks (e.g. DnD5e on renderGamePause) have already run at this point.
if (!systemDefaults.icon) {
systemDefaults.icon = img.getAttribute('src') ?? undefined;
systemDefaults.text = caption.textContent?.trim() ?? undefined;
const cs = getComputedStyle(caption);
const is = getComputedStyle(img);
systemDefaults.cssSnapshot = {
color: rgbToHex(cs.color) ?? '',
fontSize: cs.fontSize,
fontFamily: cs.fontFamily,
fontWeight: cs.fontWeight,
textTransform: cs.textTransform,
letterSpacing: cs.letterSpacing,
textShadow: cs.textShadow === 'none' ? '' : cs.textShadow,
imageWidth: is.width,
imageHeight: is.height,
opacity: is.opacity,
background: getComputedStyle(element).background,
hasRotation: img.classList.contains('fa-spin') ? 'true' : 'false',
animationDirection: is.animationDirection,
animationTimingFunction: is.animationTimingFunction,
};
}
// Clear all inline styles we may have applied previously.
// This ensures that clearing a setting actually removes the override.
element.style.removeProperty('background');
element.style.removeProperty('background-image');
img.style.removeProperty('width');
img.style.removeProperty('height');
img.style.removeProperty('opacity');
img.style.removeProperty('--fa-animation-direction');
img.style.removeProperty('--fa-animation-duration');
img.style.removeProperty('--fa-animation-timing');
caption.style.removeProperty('font-family');
caption.style.removeProperty('color');
caption.style.removeProperty('font-size');
caption.style.removeProperty('font-weight');
caption.style.removeProperty('text-transform');
caption.style.removeProperty('letter-spacing');
caption.style.removeProperty('text-shadow');
img.style.removeProperty('margin-top');
caption.style.removeProperty('margin-top');
const pauseImage = settings.get(MODULE_ID, SETTINGS.CHOOSE_FILE) as string;
const imageWidth = settings.get(MODULE_ID, SETTINGS.IMAGE_WIDTH) as string;
const imageHeight = settings.get(MODULE_ID, SETTINGS.IMAGE_HEIGHT) as string;
const opacity = settings.get(MODULE_ID, SETTINGS.OPACITY) as number;
const pauseText = settings.get(MODULE_ID, SETTINGS.PAUSE_TEXT) as string;
const pauseTextFontFamily = settings.get(MODULE_ID, SETTINGS.PAUSE_TEXT_FONT_FAMILY) as string;
const pauseTextColor = settings.get(MODULE_ID, SETTINGS.PAUSE_TEXT_COLOR) as string;
const pauseTextFontSize = settings.get(MODULE_ID, SETTINGS.PAUSE_TEXT_FONT_SIZE) as string;
const pauseTextFontWeight = settings.get(MODULE_ID, SETTINGS.PAUSE_TEXT_FONT_WEIGHT) as string;
const pauseTextTransform = settings.get(MODULE_ID, SETTINGS.PAUSE_TEXT_TRANSFORM) as string;
const pauseTextLetterSpacing = settings.get(
MODULE_ID,
SETTINGS.PAUSE_TEXT_LETTER_SPACING
) as string;
const pauseTextShadow = settings.get(MODULE_ID, SETTINGS.PAUSE_TEXT_SHADOW) as string;
const animationDirection = settings.get(MODULE_ID, SETTINGS.ANIMATION_DIRECTION) as string;
const animationDuration = settings.get(MODULE_ID, SETTINGS.ANIMATION_DURATION) as string;
const animationTiming = settings.get(MODULE_ID, SETTINGS.ANIMATION_TIMING) as string;
const imageMarginTop = settings.get(MODULE_ID, SETTINGS.IMAGE_MARGIN_TOP) as string;
const pauseTextMarginTop = settings.get(MODULE_ID, SETTINGS.PAUSE_TEXT_MARGIN_TOP) as string;
const pauseBackground = settings.get(MODULE_ID, SETTINGS.PAUSE_BACKGROUND) as string;
const pauseBackgroundImage = settings.get(MODULE_ID, SETTINGS.PAUSE_BACKGROUND_IMAGE) as string;
// --- Image / Video ---
// Prefer the GM-resolved shared selection (random pick for a directory) so
// every client renders the same image. For a single file the selection is
// empty and we render chooseFile directly (no browse permission needed).
let resolvedSrc = '';
const selectedImage = settings.get(MODULE_ID, SETTINGS.SELECTED_IMAGE) as string;
if (selectedImage) {
resolvedSrc = selectedImage;
} else if (pauseImage && MEDIA_EXTENSION.test(pauseImage)) {
resolvedSrc = pauseImage;
}
if (resolvedSrc && VIDEO_EXTENSION.test(resolvedSrc)) {
const video = document.createElement('video');
// Set muted before src — required for autoplay policy
video.setAttribute('muted', '');
video.muted = true;
video.autoplay = true;
video.loop = true;
video.playsInline = true;
video.className = img.className;
video.style.objectFit = 'contain';
// Copy computed styles from img since CSS rules target img, not video
const cs = getComputedStyle(img);
video.style.position = cs.position;
video.style.top = cs.top;
video.style.left = cs.left;
video.style.width = cs.width;
video.style.height = cs.height;
video.style.opacity = cs.opacity;
img.replaceWith(video);
video.src = resolvedSrc;
// Play via Foundry's VideoHelper so playback respects the client autoplay
// policy. The desktop (Electron) client blocks raw autoplay until the
// first user gesture; the helper queues the video and starts it then. The
// `autoplay` attribute still gives immediate playback in browsers.
video.addEventListener(
'loadedmetadata',
() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void (game as any).video?.play?.(video, { volume: 0 });
},
{ once: true }
);
img = video;
} else if (resolvedSrc) {
img.setAttribute('src', resolvedSrc);
}
if (imageWidth) {
img.style.width = imageWidth;
}
if (imageHeight) {
img.style.height = imageHeight;
}
if (opacity !== undefined && opacity !== 1) {
img.style.opacity = String(opacity);
}
// --- Animation ---
if (animationDirection === 'none') {
img.classList.remove('fa-spin');
} else if (animationDirection !== 'default') {
img.classList.add('fa-spin');
img.style.setProperty('--fa-animation-direction', animationDirection);
}
if (animationDuration) {
img.style.setProperty('--fa-animation-duration', animationDuration);
}
if (animationTiming && animationTiming !== 'default') {
img.style.setProperty('--fa-animation-timing', animationTiming);
}
// --- Background ---
if (pauseBackground) {
element.style.background = pauseBackground;
}
if (pauseBackgroundImage) {
element.style.backgroundImage = `url(../${pauseBackgroundImage})`;
}
// --- Text ---
if (pauseText) {
caption.textContent = pauseText;
}
if (pauseTextFontFamily && pauseTextFontFamily !== 'default') {
caption.style.fontFamily = pauseTextFontFamily;
}
if (pauseTextColor) {
caption.style.color = pauseTextColor;
}
if (pauseTextFontSize) {
caption.style.fontSize = pauseTextFontSize;
}
if (pauseTextFontWeight && pauseTextFontWeight !== 'default') {
caption.style.fontWeight = pauseTextFontWeight;
}
if (pauseTextTransform && pauseTextTransform !== 'default') {
caption.style.textTransform = pauseTextTransform;
}
if (pauseTextLetterSpacing) {
caption.style.letterSpacing = pauseTextLetterSpacing;
}
if (pauseTextShadow) {
caption.style.textShadow = pauseTextShadow;
}
// --- Position ---
if (imageMarginTop) {
img.style.marginTop = imageMarginTop;
}
if (pauseTextMarginTop) {
caption.style.marginTop = pauseTextMarginTop;
}
}
);