Module cleanup and tests
CI / ci (push) Failing after 7s

This commit is contained in:
2026-05-24 23:13:45 +02:00
parent 63d83e999a
commit 5dc9b3b8d4
72 changed files with 2545 additions and 1220 deletions
+2 -13
View File
@@ -4,8 +4,8 @@
* Privacy settings control player opt-in/out for automation features that affect
* their on-screen presence. Settings are stored as user flags on the user document.
*
* Storage key: game.user.setFlag('video-view-manager', key, value)
* Shape: { reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean, customPortraitFallback: string | null }
* Storage key: game.user.setFlag('scrying-pool', key, value)
* Shape: { reactionCamEnabled: boolean, customPortraitFallback: string | null }
*
* @module contracts/privacy-settings
*/
@@ -31,7 +31,6 @@ export const VALID_PORTRAIT_FORMATS = Object.freeze([
/**
* @typedef {Object} PrivacySettings
* @property {boolean} reactionCamEnabled - Whether Reaction Cam automation is enabled for this user.
* @property {boolean} hpReactiveCamStylingEnabled - Whether HP-Reactive Cam Styling is enabled for this user.
* @property {string|null} customPortraitFallback - DataURL string for custom portrait fallback image, or null if not set.
*/
@@ -43,7 +42,6 @@ export const PRIVACY_SETTINGS_VERSION = 1;
*/
export const PRIVACY_SETTINGS_DEFAULT = {
reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null,
};
@@ -53,7 +51,6 @@ export const PRIVACY_SETTINGS_DEFAULT = {
*/
export const PRIVACY_SETTING_KEYS = Object.freeze([
"reactionCamEnabled",
"hpReactiveCamStylingEnabled",
"customPortraitFallback",
]);
@@ -63,7 +60,6 @@ export const PRIVACY_SETTING_KEYS = Object.freeze([
*/
export const FEATURE_NAME_MAP = Object.freeze({
reactionCam: "reactionCamEnabled",
hpReactiveCamStyling: "hpReactiveCamStylingEnabled",
});
/**
@@ -164,13 +160,6 @@ export function isValidPrivacySettings(data) {
);
}
}
if ("hpReactiveCamStylingEnabled" in obj) {
if (typeof obj.hpReactiveCamStylingEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: hpReactiveCamStylingEnabled must be a boolean, got ${typeof obj.hpReactiveCamStylingEnabled}`
);
}
}
if ("customPortraitFallback" in obj) {
if (obj.customPortraitFallback !== null && typeof obj.customPortraitFallback !== "string") {
throw new TypeError(
+1 -1
View File
@@ -4,7 +4,7 @@
* A Scene Preset is a named snapshot of the Visibility Matrix, stored as a
* flag on a FoundryVTT Scene document. Up to 50 presets per world.
*
* Storage key: scene.getFlag('video-view-manager', 'preset')
* Storage key: scene.getFlag('scrying-pool', 'preset')
* Shape: { _version: 1, presets: { [name: string]: ScenePreset } }
*
* @module contracts/scene-preset
+8 -9
View File
@@ -26,8 +26,7 @@ import {
* Manages player privacy settings for automation opt-ins.
*
* Settings are stored as world-level user flags:
* - game.user.setFlag('video-view-manager', 'reactionCamEnabled', boolean)
* - game.user.setFlag('video-view-manager', 'hpReactiveCamStylingEnabled', boolean)
* - game.user.setFlag('scrying-pool', 'reactionCamEnabled', boolean)
*
* Players can only edit their own settings.
* GM can read (but not edit) all players' settings.
@@ -87,7 +86,7 @@ export class PlayerPrivacyManager {
/**
* Retrieves privacy settings for a specific user.
*
* Reads from user flags with module scope 'video-view-manager'.
* Reads from user flags with module scope 'scrying-pool'.
* Missing settings are merged with defaults (all false).
*
* @param {string} userId - The user ID to retrieve settings for.
@@ -104,7 +103,7 @@ export class PlayerPrivacyManager {
const settings = { ...PRIVACY_SETTINGS_DEFAULT };
for (const key of PRIVACY_SETTING_KEYS) {
const value = user.getFlag("video-view-manager", key);
const value = user.getFlag("scrying-pool", key);
if (value !== undefined && value !== null) {
settings[key] = value;
}
@@ -157,7 +156,7 @@ export class PlayerPrivacyManager {
// Persist the setting via user flag
// Note: FoundryVTT user.setFlag returns a Promise
await user.setFlag("video-view-manager", key, value);
await user.setFlag("scrying-pool", key, value);
// Notify subscribers
this._notifySubscribers(userId, key, value, previousValue);
@@ -167,7 +166,7 @@ export class PlayerPrivacyManager {
* Checks if a user has opted in to a specific automation feature.
*
* @param {string} userId - The user ID to check.
* @param {string} feature - The feature name ('reactionCam' or 'hpReactiveCamStyling').
* @param {string} feature - The feature name ('reactionCam').
* @returns {boolean} True if the user has opted in, false otherwise.
* @throws {TypeError} If feature name is invalid.
*/
@@ -306,7 +305,7 @@ export class PlayerPrivacyManager {
const previousValue = this.getPortraitFallback(userId);
// Persist the setting via user flag
await user.setFlag("video-view-manager", "customPortraitFallback", dataURL);
await user.setFlag("scrying-pool", "customPortraitFallback", dataURL);
// Notify subscribers with special portrait type
this._notifyPortraitChange(userId, dataURL, previousValue);
@@ -326,7 +325,7 @@ export class PlayerPrivacyManager {
return null;
}
const dataURL = user.getFlag("video-view-manager", "customPortraitFallback");
const dataURL = user.getFlag("scrying-pool", "customPortraitFallback");
// Validate the stored DataURL (defensive programming)
if (dataURL !== null && dataURL !== undefined) {
@@ -392,7 +391,7 @@ export class PlayerPrivacyManager {
const previousValue = this.getPortraitFallback(userId);
// Remove the setting via user flag
await user.unsetFlag("video-view-manager", "customPortraitFallback");
await user.unsetFlag("scrying-pool", "customPortraitFallback");
// Notify subscribers with special portrait type
this._notifyPortraitChange(userId, null, previousValue);
+8 -8
View File
@@ -123,7 +123,7 @@ export class ScenePresetManager {
// Emit notification
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.saved')
this._adapter.i18n.localize('scrying-pool.presets.notifications.saved')
.replace('{name}', name)
);
@@ -177,7 +177,7 @@ export class ScenePresetManager {
// Emit notification
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.applied')
this._adapter.i18n.localize('scrying-pool.presets.notifications.applied')
.replace('{name}', name)
);
}
@@ -295,7 +295,7 @@ export class ScenePresetManager {
}
try {
const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (currentScene).getFlag?.('video-view-manager', 'presets');
const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (currentScene).getFlag?.('scrying-pool', 'presets');
if (!flagData || typeof flagData !== 'object') {
return; // No presets or invalid format
}
@@ -360,7 +360,7 @@ export class ScenePresetManager {
};
try {
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (currentScene).setFlag?.('video-view-manager', 'presets', flagData);
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (currentScene).setFlag?.('scrying-pool', 'presets', flagData);
} catch (err) {
console.error(
'[ScryingPool] ScenePresetManager: failed to save scene presets',
@@ -383,7 +383,7 @@ export class ScenePresetManager {
*/
async onSceneActivate(scene) {
// Check if auto-apply is globally enabled
const globalEnabled = this._adapter.settings.get?.('video-view-manager.autoApplyEnabled') ?? true;
const globalEnabled = this._adapter.settings.get?.('autoApplyEnabled') ?? true;
if (!globalEnabled) {
return; // Global disable
}
@@ -528,7 +528,7 @@ export class ScenePresetManager {
// Persist to scene flag
try {
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (scene).setFlag?.('video-view-manager', 'presets', newFlagData);
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (scene).setFlag?.('scrying-pool', 'presets', newFlagData);
} catch (err) {
console.error(
'[ScryingPool] ScenePresetManager: failed to configure auto-apply',
@@ -579,7 +579,7 @@ export class ScenePresetManager {
// Notify via ui.notifications
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.scene-applied')
this._adapter.i18n.localize('scrying-pool.presets.notifications.scene-applied')
.replace('{name}', presetName)
);
} catch (err) {
@@ -638,7 +638,7 @@ export class ScenePresetManager {
*/
_getSceneFlagData(scene) {
try {
const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (scene).getFlag?.('video-view-manager', 'presets');
const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (scene).getFlag?.('scrying-pool', 'presets');
if (!flagData || typeof flagData !== 'object') {
return null;
}
+12 -8
View File
@@ -173,7 +173,10 @@ export class ScryingPoolController {
if (currentState === targetState) return;
// 5. Register PendingOp
const previousState = currentState ?? 'never-connected';
// Use 'active' (not 'never-connected') as the fallback previousState for users not yet in
// the matrix: 'active' matches the render-time default in buildParticipantList and prevents
// a socket-timeout revert from persisting 'never-connected' into the world settings.
const previousState = currentState ?? 'active';
const pendingOp = createPendingOp(opId, participantId, targetState, previousState);
this._pendingOps.set(participantId, pendingOp);
@@ -186,19 +189,20 @@ export class ScryingPoolController {
return;
}
// 7. Socket emit
// 7. Socket emit (best-effort broadcast to player clients)
const msg = createSocketIntentMessage(opId, participantId, targetState, baseRevision);
this._socketHandler.emit(msg.event, msg.payload);
// 8. Start acknowledgement timer
this._socketHandler.registerPendingOp(pendingOp, msg.event, msg.payload);
// 9. Notify UI subscribers
try {
this._adapter.hooks.callAll('scrying-pool:controllerAction', { participantId, targetState, source, opId });
} catch (hookErr) {
console.error('[ScryingPool] ScryingPoolController.action: hook emission failed', hookErr);
}
// 9. Self-confirm: the GM is the source of truth.
// The state was already applied and persisted optimistically (step 6).
// Waiting for a socket echo would require a server-side relay that doesn't
// exist, resulting in a 6-second timeout + spurious revert warning.
// Confirm immediately to close the pending op lifecycle cleanly.
// _onEcho also fires scrying-pool:controllerAction, so skip step 10.
this._onEcho({ opId, userId: participantId, state: targetState, revision: baseRevision + 1 });
}
/**
+9
View File
@@ -48,6 +48,15 @@ export class StateStore {
this._matrix = { ...validated.matrix };
this._version = validated._version ?? 1;
this._revision = validated._revision ?? 0;
// Migration: 'never-connected' was incorrectly written to the matrix by socket-timeout
// reverts when currentState was null (the fallback should have been 'active').
// Replace persisted 'never-connected' entries with 'active' at hydration time.
for (const userId of Object.keys(this._matrix)) {
if (this._matrix[userId] === 'never-connected') {
this._matrix[userId] = 'active';
}
}
}
} catch (err) {
if (err instanceof TypeError) {
+2 -2
View File
@@ -35,7 +35,7 @@ export class FoundryAdapter {
static SETTING_WEBRTC_MODE = 'webrtcMode';
/** Flag scope/namespace for module-specific user flags. */
static FLAG_SCOPE = 'video-view-manager';
static FLAG_SCOPE = 'scrying-pool';
/**
* Creates a FoundryAdapter. Side-effect-free — no hooks or listeners registered.
@@ -294,7 +294,7 @@ export class FoundryAdapter {
this.i18n = {
/**
* Localize a string using the module's translation keys.
* @param {string} key - The translation key (e.g., 'video-view-manager.notifications.gmHid')
* @param {string} key - The translation key (e.g., 'scrying-pool.notifications.gmHid')
* @param {object} [data] - Optional data for string interpolation
* @returns {string} The localized string
*/
+4 -4
View File
@@ -116,8 +116,8 @@ export class NotificationBus {
*/
_notifyPersonal(newState) {
const key = newState === 'hidden'
? 'video-view-manager.notifications.personalHidden'
: 'video-view-manager.notifications.personalShowed';
? 'scrying-pool.notifications.personalHidden'
: 'scrying-pool.notifications.personalShowed';
const msg = this._adapter.i18n.localize(key);
this._adapter.notifications.info(msg);
}
@@ -170,8 +170,8 @@ export class NotificationBus {
const name = this._adapter.users.get(userId)?.name ?? userId;
const count = entry.changeCount > 1 ? ` (${entry.changeCount} changes)` : '';
const key = entry.lastState === 'hidden'
? 'video-view-manager.notifications.gmHid'
: 'video-view-manager.notifications.gmShowed';
? 'scrying-pool.notifications.gmHid'
: 'scrying-pool.notifications.gmShowed';
// Note: changeCount is included in the message suffix for AC-3 compliance
const msg = this._adapter.i18n.localize(key, { name }) + count;
+4 -4
View File
@@ -288,14 +288,14 @@ export class ConfirmationBar {
* @private
*/
_buildMessage(presetName, counts, variant) {
const baseMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.applied')
const baseMsg = this._adapter.i18n.localize('scrying-pool.presets.confirmation.applied')
.replace('{name}', presetName);
const countMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.counts')
const countMsg = this._adapter.i18n.localize('scrying-pool.presets.confirmation.counts')
.replace('{hidden}', counts.hidden)
.replace('{visible}', counts.visible);
if (variant === 'amber') {
const suffix = this._adapter.i18n.localize('video-view-manager.presets.confirmation.partial-fail');
const suffix = this._adapter.i18n.localize('scrying-pool.presets.confirmation.partial-fail');
return `${baseMsg} ${countMsg} ${suffix}`;
}
@@ -313,7 +313,7 @@ export class ConfirmationBar {
*/
_buildHtml(message, variant) {
const variantClass = variant === 'amber' ? 'sp-confirmation-bar--amber' : 'sp-confirmation-bar--default';
const undoLabel = this._adapter.i18n.localize('video-view-manager.presets.confirmation.undo');
const undoLabel = this._adapter.i18n.localize('scrying-pool.presets.confirmation.undo');
// Use data-action for event delegation via StripOverlayLayer
// The onclick handler is set up in _setupEventListeners
+82 -13
View File
@@ -47,12 +47,12 @@ export class DirectorsBoard extends _AppBase {
id: 'scrying-pool-directors-board',
classes: ['scrying-pool', 'directors-board'],
window: { title: "Director's Board", resizable: true },
position: { width: 400, height: 300 },
position: { width: 420, height: 480 },
};
static PARTS = {
board: {
template: 'modules/video-view-manager/templates/directors-board.hbs',
template: 'modules/scrying-pool/templates/directors-board.hbs',
},
};
@@ -116,7 +116,7 @@ export class DirectorsBoard extends _AppBase {
/** Loads saved window position from GM user flag. */
_loadPosition() {
try {
const saved = game.user?.getFlag('video-view-manager', 'directorsBoardState');
const saved = game.user?.getFlag('scrying-pool', 'directorsBoardState');
if (saved?.open === true && saved.left != null && saved.top != null) {
// Ensure options.position exists and is mutable
if (this.options?.position) {
@@ -124,8 +124,8 @@ export class DirectorsBoard extends _AppBase {
Object.assign(this.options.position, {
left: saved.left,
top: saved.top,
width: saved.width ?? 400,
height: saved.height ?? 300,
width: saved.width ?? 420,
height: saved.height ?? 480,
});
}
}
@@ -374,6 +374,8 @@ export class DirectorsBoard extends _AppBase {
autoApplyPresetName: autoApplyConfig.presetName,
autoApplyPreDelay: autoApplyConfig.preDelay,
presets: this._scenePresetManager?.list?.() ?? [],
// A/V mode — reflects current world AV state (0 = disabled, 3 = audio+video)
avModeEnabled: (game.webrtc?.settings?.world?.mode ?? 0) !== 0,
};
}
@@ -416,6 +418,9 @@ export class DirectorsBoard extends _AppBase {
case 'import-presets': this._onImportPresets(); break;
// Story 3.2: Scene auto-apply panel toggle
case 'toggle-preset-panel': this._togglePresetPanel(); break;
case 'toggle-av-mode': this._onToggleAVMode(); break;
case 'open-av-config': this._onOpenAVConfig(); break;
case 'close': this.close(); break;
}
};
this._focusinHandler = (e) => {
@@ -428,6 +433,28 @@ export class DirectorsBoard extends _AppBase {
root.addEventListener('click', this._clickHandler);
root.addEventListener('focusin', this._focusinHandler);
root.addEventListener('keydown', this._keydownHandler);
// Drag grip — custom drag (ApplicationV2 header is hidden)
const grip = root.querySelector('[data-action="drag-grip"]');
if (grip) {
grip.addEventListener('mousedown', e => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const { left: startLeft, top: startTop } = this.position;
const onMove = mv => this.setPosition({
left: startLeft + (mv.clientX - startX),
top: startTop + (mv.clientY - startY),
});
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// Story 3.2: Append ScenePresetPanel to DOM and refresh
this._appendPresetPanel(root);
@@ -542,8 +569,8 @@ export class DirectorsBoard extends _AppBase {
const localize = (key) => game.i18n?.localize(key) ?? key;
const getBinding = (actionKey) => {
// Check both namespaces due to migration from video-view-manager to scrying-pool
const namespaces = ['scrying-pool', 'video-view-manager'];
// Check both namespaces due to migration from scrying-pool to scrying-pool
const namespaces = ['scrying-pool', 'scrying-pool'];
for (const ns of namespaces) {
const bindings = game.keybindings?.bindings?.get(`${ns}.${actionKey}`);
if (bindings?.[0]) {
@@ -556,10 +583,10 @@ export class DirectorsBoard extends _AppBase {
};
const shortcuts = [
{ label: localize('video-view-manager.directorsBoard.shortcuts.openBoard'), binding: getBinding('openDirectorsBoard') ?? 'Ctrl+Shift+V' },
{ label: localize('video-view-manager.directorsBoard.shortcuts.showAll'), binding: getBinding('showAll') ?? 'Ctrl+Shift+S' },
{ label: localize('video-view-manager.directorsBoard.shortcuts.hideAll'), binding: getBinding('hideAll') ?? 'Ctrl+Shift+H' },
{ label: localize('video-view-manager.directorsBoard.shortcuts.spotlight'), binding: getBinding('spotlightParticipant') ?? 'Ctrl+Shift+P' },
{ label: localize('scrying-pool.directorsBoard.shortcuts.openBoard'), binding: getBinding('openDirectorsBoard') ?? 'Ctrl+Shift+V' },
{ label: localize('scrying-pool.directorsBoard.shortcuts.showAll'), binding: getBinding('showAll') ?? 'Ctrl+Shift+S' },
{ label: localize('scrying-pool.directorsBoard.shortcuts.hideAll'), binding: getBinding('hideAll') ?? 'Ctrl+Shift+H' },
{ label: localize('scrying-pool.directorsBoard.shortcuts.spotlight'), binding: getBinding('spotlightParticipant') ?? 'Ctrl+Shift+P' },
];
// Escape HTML to prevent injection via localised strings or keybinding labels
@@ -570,7 +597,7 @@ export class DirectorsBoard extends _AppBase {
if (typeof Dialog !== 'undefined') {
new Dialog({
title: localize('video-view-manager.directorsBoard.shortcuts.title'),
title: localize('scrying-pool.directorsBoard.shortcuts.title'),
content,
buttons: { close: { label: 'Close' } },
default: 'close',
@@ -592,6 +619,48 @@ export class DirectorsBoard extends _AppBase {
}
}
/**
* Toggles Foundry A/V mode between AUDIO_VIDEO (3) and DISABLED (0).
* The module is the single point of control for A/V activation — Foundry's
* native AVConfig dialog is redirected here by initScryingPoolCameraViews.
*
* Uses reestablish() rather than explicit connect()/disconnect() to avoid
* racing with Foundry's internal mode-change listeners (AVMaster hooks into
* the settings change event itself).
*/
async _onToggleAVMode() {
if (!game.webrtc) {
console.warn('[ScryingPool] DirectorsBoard: game.webrtc not available');
return;
}
const currentMode = game.webrtc.settings?.world?.mode ?? 0;
// AV_MODES: DISABLED=0, AUDIO=1, VIDEO=2, AUDIO_VIDEO=3
const newMode = currentMode === 0 ? 3 : 0;
try {
await game.webrtc.settings.set('world', 'mode', newMode);
// reestablish() tears down and rebuilds the WebRTC connection honouring the
// new mode — same approach used by Foundry's own AVConfig save handler.
await game.webrtc.reestablish?.();
} catch (err) {
console.error('[ScryingPool] DirectorsBoard: failed to toggle A/V mode:', err);
}
if (this.rendered) this.render({ force: true });
}
/**
* Opens Foundry's native AVConfig dialog for signaling server configuration.
* This is separate from the A/V mode toggle — AVConfig is where you set up the
* LiveKit/WebRTC server address, username, password, etc.
* The module controls A/V mode (on/off); Foundry's dialog handles infrastructure.
*/
_onOpenAVConfig() {
if (!game.webrtc?.config) {
console.warn('[ScryingPool] DirectorsBoard: game.webrtc.config not available');
return;
}
game.webrtc.config.render({ force: true });
}
/**
* Opens the PresetSaveDialog for saving the current visibility matrix as a preset.
*/
@@ -749,7 +818,7 @@ export class DirectorsBoard extends _AppBase {
*/
_savePosition(state) {
try {
game.user?.setFlag('video-view-manager', 'directorsBoardState', state);
game.user?.setFlag('scrying-pool', 'directorsBoardState', state);
} catch (err) {
console.error('[ScryingPool] Failed to save directors board position:', err);
}
+3 -1
View File
@@ -190,7 +190,9 @@ export class GMPlayerPrivacySelectorMenu extends _AppBase {
const id = escapeHtml(user.id ?? '');
const role = user.isGM ? 'GM' : 'Player';
return `
<div class="sp-user-item" data-user-id="${id}">
<div class="sp-user-item"
data-user-id="${id}"
data-tooltip="${name}${role}">
<span class="sp-user-name">${name}</span>
<span class="sp-user-role">${role}</span>
</div>
+1 -1
View File
@@ -77,7 +77,7 @@ export class PresetExportDialog extends _AppBase {
static PARTS = {
dialog: {
template: 'modules/video-view-manager/templates/preset-export.hbs',
template: 'modules/scrying-pool/templates/preset-export.hbs',
},
};
+1 -1
View File
@@ -92,7 +92,7 @@ export class PresetImportDialog extends _AppBase {
static PARTS = {
dialog: {
template: 'modules/video-view-manager/templates/preset-import.hbs',
template: 'modules/scrying-pool/templates/preset-import.hbs',
},
};
+6 -6
View File
@@ -40,7 +40,7 @@ export class PresetLoadDialog extends _AppBase {
static PARTS = {
dialog: {
template: 'modules/video-view-manager/templates/preset-load-dialog.hbs',
template: 'modules/scrying-pool/templates/preset-load-dialog.hbs',
},
};
@@ -80,10 +80,10 @@ export class PresetLoadDialog extends _AppBase {
return {
presets: this._presets,
hasPresets: this._presets.length > 0,
loadLabel: i18n.localize('video-view-manager.presets.load.loadButton'),
cancelLabel: i18n.localize('video-view-manager.presets.load.cancelButton'),
title: i18n.localize('video-view-manager.presets.load.title'),
emptyMessage: i18n.localize('video-view-manager.presets.load.emptyMessage'),
loadLabel: i18n.localize('scrying-pool.presets.load.loadButton'),
cancelLabel: i18n.localize('scrying-pool.presets.load.cancelButton'),
title: i18n.localize('scrying-pool.presets.load.title'),
emptyMessage: i18n.localize('scrying-pool.presets.load.emptyMessage'),
};
}
@@ -157,7 +157,7 @@ export class PresetLoadDialog extends _AppBase {
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.applied')
this._adapter.i18n.localize('scrying-pool.presets.notifications.applied')
.replace('{name}', presetName)
);
+9 -8
View File
@@ -35,12 +35,12 @@ export class PresetSaveDialog extends _AppBase {
id: 'scrying-pool-preset-save-dialog',
classes: ['scrying-pool', 'preset-save-dialog'],
window: { title: 'Save Scene Preset', resizable: false },
position: { width: 320, height: 'auto' },
position: { width: 360, height: 'auto' },
};
static PARTS = {
dialog: {
template: 'modules/video-view-manager/templates/preset-save-dialog.hbs',
template: 'modules/scrying-pool/templates/preset-save-dialog.hbs',
},
};
@@ -76,11 +76,12 @@ export class PresetSaveDialog extends _AppBase {
return {
defaultName: '',
saveLabel: i18n.localize('video-view-manager.presets.save.saveButton'),
cancelLabel: i18n.localize('video-view-manager.presets.save.cancelButton'),
title: i18n.localize('video-view-manager.presets.save.title'),
nameLabel: i18n.localize('video-view-manager.presets.save.nameLabel'),
namePlaceholder: i18n.localize('video-view-manager.presets.save.namePlaceholder'),
saveLabel: i18n.localize('scrying-pool.presets.save.saveButton'),
cancelLabel: i18n.localize('scrying-pool.presets.save.cancelButton'),
title: i18n.localize('scrying-pool.presets.save.title'),
nameLabel: i18n.localize('scrying-pool.presets.save.nameLabel'),
namePlaceholder: i18n.localize('scrying-pool.presets.save.namePlaceholder'),
descriptionHint: i18n.localize('scrying-pool.presets.save.descriptionHint'),
};
}
@@ -173,7 +174,7 @@ export class PresetSaveDialog extends _AppBase {
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.saved')
this._adapter.i18n.localize('scrying-pool.presets.notifications.saved')
.replace('{name}', name)
);
+15 -15
View File
@@ -66,7 +66,7 @@ export class ScenePresetPanel {
this._element = document.createElement('div');
this._element.className = 'directors-board__preset-panel';
this._element.setAttribute('role', 'region');
this._element.setAttribute('aria-label', this._adapter.i18n.localize('video-view-manager.scenePresetPanel.title'));
this._element.setAttribute('aria-label', this._adapter.i18n.localize('scrying-pool.scenePresetPanel.title'));
this._element.setAttribute('aria-expanded', 'false');
// Initially hidden
@@ -158,11 +158,11 @@ export class ScenePresetPanel {
* @private
*/
_buildEmptyHtml() {
const message = this._adapter.i18n.localize('video-view-manager.scenePresetPanel.noScene');
const message = this._adapter.i18n.localize('scrying-pool.scenePresetPanel.noScene');
return `
<div class="directors-board__preset-panel-header">
<h3 class="directors-board__preset-panel-title">
${this._escapeHtml(this._adapter.i18n.localize('video-view-manager.scenePresetPanel.title'))}
${this._escapeHtml(this._adapter.i18n.localize('scrying-pool.scenePresetPanel.title'))}
</h3>
</div>
<p class="directors-board__preset-panel-message">${this._escapeHtml(message)}</p>
@@ -195,14 +195,14 @@ export class ScenePresetPanel {
// Add default option
const defaultOption = `
<option value="" ${!presetName ? 'selected' : ''}>
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.selectPreset'))}
${this._escapeHtml(localize('scrying-pool.scenePresetPanel.selectPreset'))}
</option>
`;
return `
<div class="directors-board__preset-panel-header">
<h3 class="directors-board__preset-panel-title">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.title'))}
${this._escapeHtml(localize('scrying-pool.scenePresetPanel.title'))}
</h3>
</div>
@@ -214,18 +214,18 @@ export class ScenePresetPanel {
data-action="toggle-auto-apply"
${enabled ? 'checked' : ''}
role="switch"
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.enableAutoApply'))}">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.enableAutoApply'))}
aria-label="${this._escapeHtml(localize('scrying-pool.scenePresetPanel.enableAutoApply'))}">
${this._escapeHtml(localize('scrying-pool.scenePresetPanel.enableAutoApply'))}
</label>
</div>
<div class="directors-board__preset-panel-row">
<label class="directors-board__preset-panel-label">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preset'))}
${this._escapeHtml(localize('scrying-pool.scenePresetPanel.preset'))}
<select class="directors-board__preset-panel-select"
data-action="select-preset"
${!presets.length ? 'disabled' : ''}
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.selectPreset'))}">
aria-label="${this._escapeHtml(localize('scrying-pool.scenePresetPanel.selectPreset'))}">
${defaultOption}
${presetOptions}
</select>
@@ -234,7 +234,7 @@ export class ScenePresetPanel {
<div class="directors-board__preset-panel-row">
<label class="directors-board__preset-panel-label">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preDelay'))}
${this._escapeHtml(localize('scrying-pool.scenePresetPanel.preDelay'))}
<span class="directors-board__preset-panel-delay-value">${preDelay}ms</span>
<input type="range"
class="directors-board__preset-panel-slider"
@@ -243,7 +243,7 @@ export class ScenePresetPanel {
max="${this._MAX_PREDELAY}"
value="${preDelay}"
step="100"
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preDelay'))}"
aria-label="${this._escapeHtml(localize('scrying-pool.scenePresetPanel.preDelay'))}"
aria-valuemin="${this._MIN_PREDELAY}"
aria-valuemax="${this._MAX_PREDELAY}"
aria-valuenow="${preDelay}">
@@ -251,7 +251,7 @@ export class ScenePresetPanel {
</div>
<div class="directors-board__preset-panel-row directors-board__preset-panel-row--hint">
<span>${this._escapeHtml(localize('video-view-manager.scenePresetPanel.globalSettingsHint'))}</span>
<span>${this._escapeHtml(localize('scrying-pool.scenePresetPanel.globalSettingsHint'))}</span>
</div>
</div>
`;
@@ -338,8 +338,8 @@ export class ScenePresetPanel {
// Notify
this._adapter.notifications.info(
isChecked
? this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.enabled')
: this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.disabled')
? this._adapter.i18n.localize('scrying-pool.scenePresetPanel.notifications.enabled')
: this._adapter.i18n.localize('scrying-pool.scenePresetPanel.notifications.disabled')
);
} catch (err) {
console.error('[ScryingPool] ScenePresetPanel: failed to toggle auto-apply', err);
@@ -373,7 +373,7 @@ export class ScenePresetPanel {
// Notify
if (presetName) {
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.presetSelected')
this._adapter.i18n.localize('scrying-pool.scenePresetPanel.notifications.presetSelected')
.replace('{name}', presetName)
);
}
+53 -8
View File
@@ -96,7 +96,7 @@ export class ScryingPoolStrip extends _AppBase {
: super.defaultOptions ?? {};
return Object.assign({}, base, {
id: 'scrying-pool-strip',
template: 'modules/video-view-manager/templates/roster-strip.hbs',
template: 'modules/scrying-pool/templates/roster-strip.hbs',
popOut: true,
resizable: false,
title: 'Scrying Pool',
@@ -140,7 +140,7 @@ export class ScryingPoolStrip extends _AppBase {
getData() {
const savedState =
typeof game !== 'undefined'
? game.user?.getFlag?.('video-view-manager', 'stripState')
? game.user?.getFlag?.('scrying-pool', 'stripState')
: null;
if (savedState?.expanded !== undefined) {
this._isExpanded = savedState.expanded;
@@ -148,17 +148,24 @@ export class ScryingPoolStrip extends _AppBase {
const showFirstOpenTip =
typeof game !== 'undefined' &&
!game.user?.getFlag?.('video-view-manager', 'firstStripOpen');
!game.user?.getFlag?.('scrying-pool', 'firstStripOpen');
const userIds = this._adapter.users.all
? this._adapter.users.all().map(u => u.id)
: [];
// Respect the showGMSelfFeed setting: if false, exclude the current GM user
const showGMSelfFeed = this._adapter.settings?.get?.('showGMSelfFeed') ?? true;
const currentUserId = this._adapter.users.current?.()?.id;
const filteredUserIds = showGMSelfFeed
? userIds
: userIds.filter(id => id !== currentUserId);
// Check if we have stream access for video replacement (full AV replacement mode)
const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined;
const participants = buildParticipantList(
userIds,
filteredUserIds,
this._stateStore,
this._controller,
this._adapter,
@@ -208,18 +215,56 @@ export class ScryingPoolStrip extends _AppBase {
});
}
// Custom close button (replaces Foundry window header close)
const closeBtn = el.querySelector('[data-action="close-strip"]');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.close());
}
// Drag grip — custom drag implementation (Foundry v14 ApplicationV1 does not expose its drag handler)
const grip = el.querySelector('[data-action="drag-grip"]');
if (grip) {
grip.addEventListener('mousedown', e => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const { left: startLeft, top: startTop } = this.position;
const onMove = mv => {
this.setPosition({
left: startLeft + (mv.clientX - startX),
top: startTop + (mv.clientY - startY),
});
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// First open tip: set flag so it doesn't show again
const isFirstOpen =
typeof game !== 'undefined' &&
!game.user?.getFlag?.('video-view-manager', 'firstStripOpen');
!game.user?.getFlag?.('scrying-pool', 'firstStripOpen');
if (isFirstOpen) {
game.user?.setFlag?.('video-view-manager', 'firstStripOpen', true);
game.user?.setFlag?.('scrying-pool', 'firstStripOpen', true);
}
// Attach video streams if we have stream access (full AV replacement mode)
if (this._adapter.webrtc?.getMediaStreamForUser !== undefined) {
this._attachVideoStreams(el);
}
// Sync the outer Application window width with the expanded/collapsed state.
// The LESS max-width only applies to the inner template div (.scrying-pool.scrying-pool-strip);
// the outer window must be explicitly resized so it doesn't clip the expanded content.
if (typeof this.setPosition === 'function') {
this.setPosition({ width: this._isExpanded ? 240 : 44, height: 'auto' });
}
}
/** @inheritdoc */
@@ -233,7 +278,7 @@ export class ScryingPoolStrip extends _AppBase {
this._cleanupVideoStreams();
if (typeof game !== 'undefined') {
game.user?.setFlag?.('video-view-manager', 'stripState', {
game.user?.setFlag?.('scrying-pool', 'stripState', {
left: this.position?.left,
top: this.position?.top,
open: false,
@@ -267,7 +312,7 @@ export class ScryingPoolStrip extends _AppBase {
_toggleExpanded() {
this._isExpanded = !this._isExpanded;
if (typeof game !== 'undefined') {
game.user?.setFlag?.('video-view-manager', 'stripState', {
game.user?.setFlag?.('scrying-pool', 'stripState', {
left: this.position?.left,
top: this.position?.top,
open: true,
+49 -17
View File
@@ -58,7 +58,7 @@ export class PlayerPrivacyPanel extends _AppBase {
static PARTS = {
dialog: {
template: 'modules/video-view-manager/templates/player-privacy-panel.hbs',
template: 'modules/scrying-pool/templates/player-privacy-panel.hbs',
},
};
@@ -157,13 +157,6 @@ export class PlayerPrivacyPanel extends _AppBase {
enabled: settings.reactionCamEnabled,
settingKey: 'reactionCamEnabled',
},
{
key: 'hpReactiveCamStyling',
label: i18n.localize('SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingLabel'),
description: i18n.localize('SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingDescription'),
enabled: settings.hpReactiveCamStylingEnabled,
settingKey: 'hpReactiveCamStylingEnabled',
},
],
// Toggle labels
@@ -187,19 +180,28 @@ export class PlayerPrivacyPanel extends _AppBase {
/**
* Sets up event handlers after rendering.
* @param {HTMLElement|JQuery|object} html - The dialog element, jQuery object, or plain object with querySelector.
* @param {HTMLElement|JQuery|object} context - ApplicationV2 context, jQuery object, or plain object with querySelector.
*/
_onRender(html) {
// Normalize html to element with querySelector
// FoundryVTT ApplicationV2 passes jQuery object, tests pass plain objects
const element = html instanceof HTMLElement
? html
: (html?.[0] ?? html);
_onRender(context) {
// ApplicationV2 passes the template context as the first argument, not the element.
// Prefer this.element (the rendered root). Fall back to treating the argument as an element
// (for jQuery objects or test mocks that expose querySelector directly).
let element;
if (this.element instanceof HTMLElement) {
element = this.element;
} else if (context instanceof HTMLElement) {
element = context;
} else {
element = context?.[0] ?? context;
}
if (!element || typeof element.querySelector !== 'function') return;
// Cache toggle elements
this._reactionCamToggle = element.querySelector('[data-setting="reactionCamEnabled"]');
this._hpReactiveCamToggle = element.querySelector('[data-setting="hpReactiveCamStylingEnabled"]');
// Sync badge text with actual checkbox state (Handlebars renders from context which may
// be the default false state, while the real setting is already true in the DB).
this._syncToggleBadges(element);
// Story 4.2: Set up portrait section event handlers
this._setupPortraitHandlers(element);
@@ -208,6 +210,27 @@ export class PlayerPrivacyPanel extends _AppBase {
this._setupToggleHandlers(element);
}
/**
* Syncs toggle badge text/icon with current checkbox state.
* Needed because Handlebars renders from context at render time, which may not
* match the actual persisted setting if it was already enabled.
* @param {HTMLElement} element - The panel root element.
* @private
*/
_syncToggleBadges(element) {
const onLabel = this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOn');
const offLabel = this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOff');
const toggles = element.querySelectorAll('.player-privacy-panel__toggle-input');
for (const toggle of toggles) {
const span = toggle.nextElementSibling;
if (span?.classList.contains('player-privacy-panel__toggle-text')) {
span.innerHTML = toggle.checked
? `<i class="fas fa-check" aria-hidden="true"></i> ${onLabel}`
: `<i class="fas fa-times" aria-hidden="true"></i> ${offLabel}`;
}
}
}
/**
* Sets up event handlers for toggle switches.
* @param {HTMLElement} element - The dialog element.
@@ -428,6 +451,16 @@ export class PlayerPrivacyPanel extends _AppBase {
this._currentSettings[settingKey] = newValue;
}
// Update toggle badge text+icon dynamically
const span = checkbox.nextElementSibling;
if (span?.classList.contains('player-privacy-panel__toggle-text')) {
const onLabel = this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOn');
const offLabel = this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOff');
span.innerHTML = newValue
? `<i class="fas fa-check" aria-hidden="true"></i> ${onLabel}`
: `<i class="fas fa-times" aria-hidden="true"></i> ${offLabel}`;
}
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.savedNotification')
@@ -466,7 +499,6 @@ export class PlayerPrivacyPanel extends _AppBase {
_onClose() {
// Clear cached elements
this._reactionCamToggle = null;
this._hpReactiveCamToggle = null;
this._fileInput = null;
this._portraitPreview = null;
this._currentSettings = null;
+2 -2
View File
@@ -501,7 +501,7 @@ export class VisibilityBadge {
* @returns {boolean}
*/
_getFirstBadgeEncountered() {
return this._adapter.users.current()?.getFlag('video-view-manager', 'firstBadgeEncounter') ?? false;
return this._adapter.users.current()?.getFlag('scrying-pool', 'firstBadgeEncounter') ?? false;
}
/**
@@ -509,7 +509,7 @@ export class VisibilityBadge {
* @returns {Promise<void>}
*/
async _setFirstBadgeEncountered() {
await this._adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true);
await this._adapter.users.current()?.setFlag('scrying-pool', 'firstBadgeEncounter', true);
}
/**
+90
View File
@@ -0,0 +1,90 @@
/**
* ScryingPoolCameraViews — replaces Foundry's CameraViews as CONFIG.ui.webrtc.
*
* Two responsibilities:
* 1. Redirect the "configure" camera action to the Scrying Pool Directors Board
* instead of Foundry's native AVConfig dialog.
* 2. Inject the Scrying Pool visibility state (sp-cam-hidden) into each user's
* camera context so the dock reflects the same hidden/active state as the
* module's state machine.
*
* Set as CONFIG.ui.webrtc in the 'init' hook (before Foundry instantiates ui.webrtc).
* Dependencies are injected after 'ready' via initScryingPoolCameraViews().
*/
// Lazy base class — avoids ReferenceError at module load time in tests where
// foundry globals are not defined.
function _getCameraViewsBase() {
if (typeof foundry !== 'undefined') {
const cls = foundry.applications?.apps?.av?.CameraViews;
if (cls) return cls;
}
// Minimal test-environment fallback — mirrors the pattern in DirectorsBoard.js
return class _FallbackCameraViews {
static DEFAULT_OPTIONS = {};
static PARTS = {};
constructor(options = {}) { this.options = options; }
async render() {}
async close() {}
_prepareUserContext(_id) { return {}; }
_onConfigure() {}
};
}
/** @type {object|null} DirectorsBoard instance — set via initScryingPoolCameraViews */
let _directorsBoard = null;
/** @type {object|null} StateStore instance — set via initScryingPoolCameraViews */
let _stateStore = null;
/**
* Inject module dependencies. Called from module.js after 'ready' resolves.
* @param {object|null} directorsBoard - The singleton DirectorsBoard (GM only, else null)
* @param {object} stateStore - The module StateStore
*/
export function initScryingPoolCameraViews(directorsBoard, stateStore) {
_directorsBoard = directorsBoard;
_stateStore = stateStore;
}
export class ScryingPoolCameraViews extends _getCameraViewsBase() {
/**
* Intercept the configure camera button.
* Opens the Scrying Pool Directors Board instead of Foundry's AVConfig dialog.
* For non-GM players, shows an informational notification since A/V config
* is GM-controlled in this module.
* @override
*/
_onConfigure(event, target) {
if (_directorsBoard) {
_directorsBoard.render({ force: true });
} else if (typeof ui !== 'undefined') {
ui.notifications?.info(
game?.i18n?.localize('scrying-pool.notifications.avConfigGMOnly') ??
'A/V settings are managed by the GM.'
);
}
}
/**
* Inject Scrying Pool visibility state into each user's camera tile context.
* Adds the 'sp-cam-hidden' CSS class when the SP state machine considers
* this user hidden, allowing the dock to visually reflect module state.
* @override
* @param {string} id - User ID
* @returns {object|undefined}
*/
_prepareUserContext(id) {
const ctx = super._prepareUserContext(id);
if (!ctx) return ctx;
const spState = _stateStore?.getState?.(id) ?? 'active';
const spHidden = spState === 'hidden';
if (spHidden) {
ctx.css = [ctx.css, 'sp-cam-hidden'].filter(Boolean).join(' ');
}
return ctx;
}
}
+17 -3
View File
@@ -23,16 +23,30 @@ export function resolveToggleTarget(currentState) {
* @param {string} userId
* @param {object} [user] - Optional user object for additional data
* @param {object} [privacyManager] - Optional PlayerPrivacyManager for privacy settings
* @param {object} [controller] - Optional ScryingPoolController for pending-op state
* @returns {object} Participant context
*/
export function buildSimpleParticipantContext(stateStore, userId, user, privacyManager) {
export function buildSimpleParticipantContext(stateStore, userId, user, privacyManager, controller) {
const state = stateStore.getState(userId);
const resolvedState = state ?? 'active';
const context = {
userId,
state: state ?? 'active',
state: resolvedState,
isGhost: state === 'ghost',
isHidden: resolvedState === 'hidden',
hasPendingOp: controller?._pendingOps?.has?.(userId) ?? false,
};
// Name, avatar and aria labels from the user object
if (user && typeof user === 'object') {
context.name = user.name ?? '';
context.avatarSrc = user.avatar ?? 'icons/svg/mystery-man.svg';
context.cardAriaLabel = user.name ?? userId;
context.toggleAriaLabel = resolvedState === 'hidden'
? `Show ${user.name}`
: `Hide ${user.name}`;
}
// Add privacy settings if privacyManager is provided
if (privacyManager && user) {
try {
@@ -60,7 +74,7 @@ export function buildBoardContext(stateStore, controller, adapter, privacyManage
const users = adapter.users.all?.() ?? [];
const participants = users.map(u => {
const userId = u.id ?? u;
return buildSimpleParticipantContext(stateStore, userId, u, privacyManager);
return buildSimpleParticipantContext(stateStore, userId, u, privacyManager, controller);
});
return { participants, isEmpty: participants.length === 0 };
} catch (err) {