Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ game is paused. Use the folder button next to the file picker.
Videos can be used as the pause icon instead of a static image. Supported
formats: mp4, webm, ogg.

> **Note:** The Foundry desktop app (Electron) requires **WebM format** for
> video playback due to codec limitations. MP4 works when accessing Foundry via
> a web browser.
> **Note:** The Foundry desktop app (Electron) cannot decode **HEVC/H.265**
> video. Use **H.264 (MP4)** or **WebM** for videos that play in both the
> desktop app and web browsers. Browsers can play HEVC via the operating
> system's decoder; the desktop app cannot.

## How to Use

Expand Down
2 changes: 1 addition & 1 deletion languages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,6 @@
"RESET_DEFAULTS": "Auf Standardwerte zurücksetzen",
"RESET_DEFAULTS_DONE": "Pause Customizer Einstellungen auf Standardwerte zurückgesetzt.",
"SELECT_IMAGE": "Pause-Icon",
"SELECT_IMAGE_HINT": "Bild oder Video für das Pause-Icon. Über den Ordner-Button einen Ordner für zufällige Bilder auswählen. Videos benötigen WebM-Format in der Foundry Desktop-App."
"SELECT_IMAGE_HINT": "Bild oder Video für das Pause-Icon. Über den Ordner-Button einen Ordner für zufällige Bilder auswählen. Für Videos in der Foundry Desktop-App H.264 (MP4) oder WebM verwenden — HEVC/H.265 wird nicht unterstützt."
}
}
2 changes: 1 addition & 1 deletion languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,6 @@
"RESET_DEFAULTS": "Reset to Defaults",
"RESET_DEFAULTS_DONE": "Pause Customizer settings reset to defaults.",
"SELECT_IMAGE": "Pause icon",
"SELECT_IMAGE_HINT": "Image or video used as the pause icon. Use the folder button to select a folder for random images. Videos require WebM format in the Foundry desktop app."
"SELECT_IMAGE_HINT": "Image or video used as the pause icon. Use the folder button to select a folder for random images. For videos in the Foundry desktop app, use H.264 (MP4) or WebM — HEVC/H.265 is not supported."
}
}
2 changes: 1 addition & 1 deletion languages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,6 @@
"RESET_DEFAULTS": "Restablecer valores predeterminados",
"RESET_DEFAULTS_DONE": "Configuración de Pause Customizer restablecida a los valores predeterminados.",
"SELECT_IMAGE": "Icono de pausa",
"SELECT_IMAGE_HINT": "Imagen o video utilizado como icono de pausa. Use el botón de carpeta para seleccionar una carpeta de imágenes aleatorias. Los videos requieren formato WebM en la aplicación de escritorio de Foundry."
"SELECT_IMAGE_HINT": "Imagen o video utilizado como icono de pausa. Use el botón de carpeta para seleccionar una carpeta de imágenes aleatorias. Para videos en la aplicación de escritorio de Foundry, use H.264 (MP4) o WebM — HEVC/H.265 no es compatible."
}
}
2 changes: 1 addition & 1 deletion languages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,6 @@
"RESET_DEFAULTS": "Réinitialiser les valeurs par défaut",
"RESET_DEFAULTS_DONE": "Paramètres de Pause Customizer réinitialisés aux valeurs par défaut.",
"SELECT_IMAGE": "Icône de pause",
"SELECT_IMAGE_HINT": "Image ou vidéo utilisée comme icône de pause. Utilisez le bouton dossier pour sélectionner un dossier d'images aléatoires. Les vidéos nécessitent le format WebM dans l'application de bureau Foundry."
"SELECT_IMAGE_HINT": "Image ou vidéo utilisée comme icône de pause. Utilisez le bouton dossier pour sélectionner un dossier d'images aléatoires. Pour les vidéos dans l'application de bureau Foundry, utilisez H.264 (MP4) ou WebM — HEVC/H.265 n'est pas pris en charge."
}
}
2 changes: 1 addition & 1 deletion languages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,6 @@
"RESET_DEFAULTS": "デフォルトに戻す",
"RESET_DEFAULTS_DONE": "Pause Customizerの設定がデフォルトにリセットされました。",
"SELECT_IMAGE": "一時停止アイコン",
"SELECT_IMAGE_HINT": "一時停止アイコンとして使用する画像または動画。フォルダボタンでランダム画像用のフォルダを選択できます。Foundryデスクトップアプリでは動画にWebM形式が必要です。"
"SELECT_IMAGE_HINT": "一時停止アイコンとして使用する画像または動画。フォルダボタンでランダム画像用のフォルダを選択できます。Foundryデスクトップアプリで動画を使用する場合はH.264(MP4)またはWebMを使用してください。HEVC/H.265はサポートされていません。"
}
}
2 changes: 1 addition & 1 deletion languages/pt-br.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,6 @@
"RESET_DEFAULTS": "Restaurar padrões",
"RESET_DEFAULTS_DONE": "Configurações do Pause Customizer restauradas para os valores padrão.",
"SELECT_IMAGE": "Ícone de pausa",
"SELECT_IMAGE_HINT": "Imagem ou vídeo utilizado como ícone de pausa. Use o botão de pasta para selecionar uma pasta de imagens aleatórias. Vídeos requerem formato WebM no aplicativo desktop do Foundry."
"SELECT_IMAGE_HINT": "Imagem ou vídeo utilizado como ícone de pausa. Use o botão de pasta para selecionar uma pasta de imagens aleatórias. Para vídeos no aplicativo desktop do Foundry, use H.264 (MP4) ou WebM — HEVC/H.265 não é compatível."
}
}
6 changes: 6 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export const SETTINGS = {
PAUSE_TEXT_MARGIN_TOP: 'pauseTextMarginTop',
PAUSE_BACKGROUND: 'pauseBackground',
PAUSE_BACKGROUND_IMAGE: 'pauseBackgroundImage',
/**
* Hidden world setting holding the currently selected image. Resolved by the
* GM from chooseFile (a random pick for a directory) on pause / directory
* change, so that every client renders the same image.
*/
SELECTED_IMAGE: 'selectedImage',
} as const;

/**
Expand Down
128 changes: 93 additions & 35 deletions src/pause-customizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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>;
}

/**
Expand All @@ -25,28 +26,51 @@
*/
const systemDefaults: { icon?: string; text?: string; cssSnapshot?: Record<string, string> } = {};

/** Cached list of image files when chooseFile points to a directory. */
let directoryImages: 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;

/**
* Load image files from a directory for random pause image selection.
* If the path is a file (has image extension) or empty, the cache is cleared.
* 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 loadDirectoryImages(path: string): Promise<void> {
directoryImages = [];
if (!path || /\.(jpg|jpeg|png|gif|svg|webp|avif|bmp|tiff?|mp4|webm|ogg)$/i.test(path)) return;
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);
directoryImages = (result.files as string[]).filter((f: string) =>
/\.(jpg|jpeg|png|gif|svg|webp|avif|bmp|tiff?|mp4|webm|ogg)$/i.test(f)
);
return (result.files as string[]).filter((f: string) => MEDIA_EXTENSION.test(f));
} catch {
directoryImages = [];
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)]!;

Check warning on line 67 in src/pause-customizer.ts

View workflow job for this annotation

GitHub Actions / validate

Forbidden non-null assertion
}
}

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;
Expand All @@ -66,6 +90,17 @@
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) ?? '',
Expand Down Expand Up @@ -318,33 +353,41 @@

// Re-render the pause overlay whenever our settings change
Hooks.on('updateSetting', async (setting: { key?: string }) => {
if (setting.key?.startsWith(`${MODULE_ID}.`)) {
// Refresh directory image cache when the icon path changes
if (setting.key === `${MODULE_ID}.${SETTINGS.CHOOSE_FILE}`) {
const settings = game.settings as unknown as FoundrySettings;
const pauseImage = settings.get(MODULE_ID, SETTINGS.CHOOSE_FILE) as string;
await loadDirectoryImages(pauseImage);
}
// 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 });
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 });
});

// Load directory image cache on startup
// 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 pauseImage = settings.get(MODULE_ID, SETTINGS.CHOOSE_FILE) as string;
if (pauseImage) {
await loadDirectoryImages(pauseImage);
if (directoryImages.length > 0) {
// 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 });
}
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.
*
Expand Down Expand Up @@ -743,14 +786,18 @@

// --- 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 = '';
if (directoryImages.length > 0) {
resolvedSrc = directoryImages[Math.floor(Math.random() * directoryImages.length)]!;
} else if (pauseImage) {
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 && /\.(mp4|webm|ogg)$/i.test(resolvedSrc)) {
if (resolvedSrc && VIDEO_EXTENSION.test(resolvedSrc)) {
const video = document.createElement('video');
// Set muted before src — required for autoplay policy
video.setAttribute('muted', '');
Expand All @@ -770,7 +817,18 @@
video.style.opacity = cs.opacity;
img.replaceWith(video);
video.src = resolvedSrc;
video.play().catch(() => {});
// 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);
Expand Down
Loading