// @ts-nocheck /* global Dialog */ import { buildBoardContext, resolveToggleTarget } from '../../utils/boardUtils.js'; import { generateOpId } from '../../utils/uuid.js'; import { PresetSaveDialog } from './PresetSaveDialog.js'; import { PresetLoadDialog } from './PresetLoadDialog.js'; import { PresetExportDialog } from './PresetExportDialog.js'; import { PresetImportDialog } from './PresetImportDialog.js'; import { ScenePresetPanel } from './ScenePresetPanel.js'; // Conditional base class — test environment lacks foundry globals. // At module load time in tests, foundry is undefined → fallback class is used. /** @private */ const _AppBase = typeof foundry !== 'undefined' && foundry.applications?.api?.HandlebarsApplicationMixin && foundry.applications?.api?.ApplicationV2 ? foundry.applications.api.HandlebarsApplicationMixin( foundry.applications.api.ApplicationV2 ) : class _FallbackApp { static DEFAULT_OPTIONS = {}; static PARTS = {}; constructor(options = {}) { this.options = options; } get rendered() { return this._rendered ?? false; } set rendered(v) { this._rendered = v; } get element() { return this._element ?? null; } set element(v) { this._element = v; } async render() { this._rendered = true; } async close() { this._rendered = false; } async _prepareContext() { return {}; } _onRender() {} _onClose() {} _onPosition() {} }; /** * Floating GM-only Director's Board window. * Displays all connected participants as a seating-chart grid with per-participant * visibility toggle. Extends ApplicationV2 via HandlebarsApplicationMixin. */ export class DirectorsBoard extends _AppBase { static DEFAULT_OPTIONS = { id: 'scrying-pool-directors-board', classes: ['scrying-pool', 'directors-board'], window: { title: "Director's Board", resizable: true }, position: { width: 420, height: 480, left: 20, top: 100 }, }; static PARTS = { board: { template: 'modules/scrying-pool/templates/directors-board.hbs', }, }; /** * @param {object} stateStore * @param {object} controller * @param {object} adapter * @param {import('../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager * @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager * @param {object} [options] */ constructor(stateStore, controller, adapter, scenePresetManager, playerPrivacyManager, options = {}) { super(options); this._stateStore = stateStore; this._controller = controller; this._adapter = adapter; this._scenePresetManager = scenePresetManager; this._playerPrivacyManager = playerPrivacyManager; this._hookId = null; /** @type {Map|null} Pre-bulk-action snapshot for single-step undo */ this._undoSnapshot = null; /** @type {Map|null} Pre-spotlight snapshot for restore */ this._spotlightSnapshot = null; /** @type {string|null} Currently keyboard-focused participant userId */ this._focusedUserId = null; /** @type {Function|null} Click handler reference for cleanup */ this._clickHandler = null; /** @type {Function|null} Focusin handler reference for cleanup */ this._focusinHandler = null; /** @type {Function|null} Keydown handler reference for cleanup */ this._keydownHandler = null; /** @type {PresetSaveDialog|null} Reference to open save dialog for cleanup */ this._saveDialog = null; /** @type {PresetLoadDialog|null} Reference to open load dialog for cleanup */ this._loadDialog = null; /** @type {PresetExportDialog|null} Reference to open export dialog for cleanup */ this._exportDialog = null; /** @type {PresetImportDialog|null} Reference to open import dialog for cleanup */ this._importDialog = null; /** @type {ScenePresetPanel|null} Reference to scene preset panel for cleanup */ this._presetPanel = null; // Load saved position from user flags this._loadPosition(); } /** * Returns the ScenePresetPanel instance, creating it lazily if needed. * Story 3.2: Scene Auto-Apply & ConfirmationBar * @returns {ScenePresetPanel} The panel instance. * @private */ _getPresetPanel() { if (!this._presetPanel) { this._presetPanel = new ScenePresetPanel(this._adapter, this._scenePresetManager); this._presetPanel.init(); } return this._presetPanel; } /** Loads saved window position from GM user flag. */ _loadPosition() { try { 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) { // Use Object.assign to avoid TypeError when options is frozen (Foundry ApplicationV2) Object.assign(this.options.position, { left: saved.left, top: saved.top, width: saved.width ?? 420, height: saved.height ?? 480, }); } } } catch (err) { console.error('[ScryingPool] Failed to load directors board position:', err); } } /** Registers the stateChanged hook listener. Call once from module.js ready hook. */ init() { this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data)); } /** Unregisters the stateChanged hook listener. */ teardown() { if (this._hookId !== null) { Hooks.off('scrying-pool:stateChanged', this._hookId); this._hookId = null; } // Story 3.2: Tear down ScenePresetPanel if (this._presetPanel) { this._presetPanel.teardown(); this._presetPanel = null; } } /** Opens the board if closed; closes it if open (singleton toggle behaviour). */ async toggle() { if (this.rendered) { await this.close(); } else { await this.render({ force: true }); } } /** * Sets all non-ghost participants to `active`. Stores pre-action snapshot for undo. * FR-12: ghost participants excluded. */ async showAll() { await this._executeBulk('active'); } /** * Sets all non-ghost participants to `hidden`. Stores pre-action snapshot for undo. * FR-12: ghost participants excluded. */ async hideAll() { await this._executeBulk('hidden'); } /** * Internal bulk-action executor for showAll/hideAll. * Captures a pre-action snapshot then dispatches per-participant actions. * Uses single getState call per user to avoid race conditions. * @param {'active'|'hidden'} targetState * @private */ async _executeBulk(targetState) { const users = this._adapter.users.all(); // Get all user states in a single pass to avoid race conditions const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)])); // Filter to non-ghost users and capture snapshot atomically const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost'); // Capture pre-action snapshot (single-step undo) - use the states we already fetched this._undoSnapshot = new Map(nonGhost.map(u => [u.id, userStates.get(u.id)])); // Bulk supersedes spotlight restore this._spotlightSnapshot = null; const promises = []; for (const u of nonGhost) { if (this._controller.hasPendingOp?.(u.id)) continue; const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(u.id) ?? 0; // Ensure we await controller actions when they return promises and catch errors const p = Promise.resolve(this._controller.action('board', u.id, targetState, opId, baseRevision)) .catch(err => ({ __err: err, userId: u.id })); promises.push(p); } const results = await Promise.all(promises); const failures = results.filter(r => r && r.__err); if (failures.length > 0) { console.error('[ScryingPool] Bulk action encountered failures:', failures); // Keep the undo snapshot so the GM can retry or manually inspect; do not silently clear it. } if (this.rendered) this.render({ force: true }); } /** * Single-step undo: restores participants to their pre-bulk-action states. * No-op if no snapshot exists. Ghost participants are skipped. */ async undo() { if (!this._undoSnapshot) return; const snapshot = this._undoSnapshot; // Do not clear the snapshot until actions have completed successfully const promises = []; for (const [userId, targetState] of snapshot) { // Check current state to avoid restoring ghost users that have transitioned if (this._stateStore.getState(userId) === 'ghost') continue; if (this._controller.hasPendingOp?.(userId)) continue; const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(userId) ?? 0; const p = Promise.resolve(this._controller.action('board', userId, targetState, opId, baseRevision)) .catch(err => ({ __err: err, userId })); promises.push(p); } const results = await Promise.all(promises); const failures = results.filter(r => r && r.__err); if (failures.length > 0) { console.error('[ScryingPool] Undo encountered failures:', failures); // Keep the snapshot in case the GM wants to retry or investigate } else { // All succeeded — clear the undo snapshot this._undoSnapshot = null; } if (this.rendered) this.render({ force: true }); } /** * Spotlights a single participant: sets them `active`, all others `hidden`. * Captures a pre-spotlight snapshot and clears any undo snapshot. * Ghost participants are excluded from all operations. * @param {string} userId - The participant to spotlight */ async spotlight(userId) { // Guard: validate userId exists and is not null/undefined if (!userId) return; const users = this._adapter.users.all(); // Get all user states in a single pass to avoid race conditions const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)])); // Filter to non-ghost users const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost'); // Check if the requested userId is valid (exists in non-ghost list) const validUserIds = new Set(nonGhost.map(u => u.id)); if (!validUserIds.has(userId)) { console.warn(`[ScryingPool] spotlight: userId "${userId}" not found or is ghost`); return; } // Capture pre-spotlight snapshot for ALL users (including ghost for completeness) this._spotlightSnapshot = new Map(users.map(u => [u.id, userStates.get(u.id)])); this._undoSnapshot = null; const promises = []; for (const u of nonGhost) { if (this._controller.hasPendingOp?.(u.id)) continue; const targetState = u.id === userId ? 'active' : 'hidden'; const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(u.id) ?? 0; const p = Promise.resolve(this._controller.action('board', u.id, targetState, opId, baseRevision)) .catch(err => ({ __err: err, userId: u.id })); promises.push(p); } const results = await Promise.all(promises); const failures = results.filter(r => r && r.__err); if (failures.length > 0) { console.error('[ScryingPool] Spotlight encountered failures:', failures); // Keep snapshot in case GM wants to retry or investigate } if (this.rendered) this.render({ force: true }); } /** * Restores participants to their pre-spotlight states. * No-op if no spotlight snapshot exists. Ghost participants are skipped. */ async restoreSpotlight() { if (!this._spotlightSnapshot) return; const snapshot = this._spotlightSnapshot; // Do not clear the snapshot until actions succeed const promises = []; for (const [userId, targetState] of snapshot) { // Check current state to avoid restoring ghost users that have transitioned if (this._stateStore.getState(userId) === 'ghost') continue; if (this._controller.hasPendingOp?.(userId)) continue; const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(userId) ?? 0; const p = Promise.resolve(this._controller.action('board', userId, targetState, opId, baseRevision)) .catch(err => ({ __err: err, userId })); promises.push(p); } const results = await Promise.all(promises); const failures = results.filter(r => r && r.__err); if (failures.length > 0) { console.error('[ScryingPool] RestoreSpotlight encountered failures:', failures); // Keep the snapshot in case the GM wants to retry } else { this._spotlightSnapshot = null; } if (this.rendered) this.render({ force: true }); } /** * Spotlights the currently focused participant (keyboard shortcut target). * No-op if no participant is focused. */ spotlightFocused() { if (!this._focusedUserId) return; this.spotlight(this._focusedUserId); } /** @inheritdoc */ async _prepareContext() { const base = buildBoardContext(this._stateStore, this._controller, this._adapter, this._playerPrivacyManager); const presetCount = this._scenePresetManager?.list?.().length ?? 0; // Get auto-apply config for current scene (Story 3.2) let autoApplyConfig = { enabled: false, presetName: null, preDelay: 0 }; try { const scenes = this._adapter.scenes; const currentScene = scenes?.current?.(); if (currentScene) { const flagData = this._scenePresetManager?._getSceneFlagData?.(currentScene); autoApplyConfig = this._scenePresetManager?._getAutoApplyConfig?.(flagData) ?? autoApplyConfig; } } catch (err) { console.warn('[ScryingPool] Failed to get auto-apply config for context:', err); } // Dock layout selector const currentDockLayout = this._adapter.settings?.get?.('dockLayout') ?? 'vertical-sm'; const DOCK_LAYOUTS = [ { key: 'vertical-sm', icon: 'fa-grip-vertical', size: 'S', sepAfter: false }, { key: 'vertical-md', icon: 'fa-grip-vertical', size: 'L', sepAfter: true }, { key: 'horizontal-sm', icon: 'fa-grip-horizontal', size: 'S', sepAfter: false }, { key: 'horizontal-md', icon: 'fa-grip-horizontal', size: 'L', sepAfter: true }, { key: 'mosaic-sm', icon: 'fa-border-all', size: 'S', sepAfter: false }, { key: 'mosaic-md', icon: 'fa-border-all', size: 'L', sepAfter: false }, ]; const dockLayouts = DOCK_LAYOUTS.map(l => ({ ...l, isActive: l.key === currentDockLayout, label: (typeof game !== 'undefined' ? game.i18n?.localize?.(`scrying-pool.directorsBoard.dockLayout.${l.key}`) : null) ?? l.key, })); // Story 5.2: Video widget width customization // Defaults match the settings registration in module.js const widgetWidthSm = this._adapter.settings?.get?.('widgetWidthSm') ?? '83'; const widgetWidthMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150'; const SM_WIDTH_OPTIONS = [ { value: '60', label: '60px' }, { value: '70', label: '70px' }, { value: '80', label: '80px' }, { value: '90', label: '90px' }, { value: '100', label: '100px' }, { value: '120', label: '120px' }, { value: '140', label: '140px' }, { value: '160', label: '160px' }, { value: '180', label: '180px' }, { value: '200', label: '200px' }, ]; const MD_WIDTH_OPTIONS = [ { value: '60', label: '60px' }, { value: '80', label: '80px' }, { value: '100', label: '100px' }, { value: '120', label: '120px' }, { value: '150', label: '150px' }, { value: '200', label: '200px' }, { value: '250', label: '250px' }, { value: '300', label: '300px' }, { value: '350', label: '350px' }, { value: '400', label: '400px' }, ]; // Tile shape selector const currentTileShape = this._adapter.settings?.get?.('tileShape') ?? 'circle'; const TILE_SHAPES = [ { key: 'rounded', icon: 'fa-square', isActive: currentTileShape === 'rounded', label: 'Rounded' }, { key: 'circle', icon: 'fa-circle', isActive: currentTileShape === 'circle', label: 'Circle' }, { key: 'hexagon', icon: 'fa-hexagon', isActive: currentTileShape === 'hexagon', label: 'Hexagon' }, { key: 'octagon', icon: 'fa-shapes', isActive: currentTileShape === 'octagon', label: 'Octagon' }, ]; // Tile border around video shapes const currentTileBorderWidth = this._adapter.settings?.get?.('tileBorderWidth') ?? 0; const currentTileBorderColor = this._adapter.settings?.get?.('tileBorderColor') ?? '#ffffff'; const TILE_BORDER_WIDTHS = [ { value: 0, label: 'None', isActive: currentTileBorderWidth === 0 }, { value: 1, label: '1px', isActive: currentTileBorderWidth === 1 }, { value: 2, label: '2px', isActive: currentTileBorderWidth === 2 }, { value: 4, label: '4px', isActive: currentTileBorderWidth === 4 }, ]; return { ...base, hasUndo: this._undoSnapshot !== null, hasRestore: this._spotlightSnapshot !== null, presetCount, hasPresets: presetCount > 0, hasScene: !!this._adapter.scenes?.current?.(), autoApplyEnabled: autoApplyConfig.enabled, 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, // Dock layout selector dockLayouts, // Tile shape selector tileShape: currentTileShape, tileShapes: TILE_SHAPES, // Tile border tileBorderWidth: currentTileBorderWidth, tileBorderColor: currentTileBorderColor, tileBorderWidths: TILE_BORDER_WIDTHS, // Story 5.2: Video widget width customization smWidthOptions: SM_WIDTH_OPTIONS, mdWidthOptions: MD_WIDTH_OPTIONS, widgetWidthSm, widgetWidthMd, }; } /** * ApplicationV2 lifecycle — sets up event delegation on every render. * Removes old listeners first to prevent memory leaks. * @inheritdoc */ _onRender(context, options) { super._onRender?.(context, options); const root = this.element; if (!root) return; // Remove old listeners if they exist (fixes memory leak and broken listeners after reopen) if (this._clickHandler) { root.removeEventListener('click', this._clickHandler); } if (this._focusinHandler) { root.removeEventListener('focusin', this._focusinHandler); } if (this._keydownHandler) { root.removeEventListener('keydown', this._keydownHandler); } // Create new bound handlers this._clickHandler = (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; e.stopPropagation(); // Handle select element changes (Story 5.2) if (e.target.tagName === 'SELECT') { const action = e.target.dataset.action; if (action === 'set-widget-width-sm') { this._onSetWidgetWidth(e.target.value, 'sm'); return; } else if (action === 'set-widget-width-md') { this._onSetWidgetWidth(e.target.value, 'md'); return; } } switch (btn.dataset.action) { case 'toggle-participant': this._dispatchToggle(btn.dataset.userId); break; case 'show-all': this.showAll(); break; case 'hide-all': this.hideAll(); break; case 'undo': this.undo(); break; case 'restore-spotlight': this.restoreSpotlight(); break; case 'open-shortcut-panel': this._openShortcutPanel(); break; case 'save-preset': this._onSavePreset(); break; case 'load-preset': this._onLoadPreset(); break; case 'export-presets': this._onExportPresets(); break; 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 'set-dock-layout': this._onSetDockLayout(btn.dataset.layout); break; case 'set-tile-shape': this._onSetTileShape(btn.dataset.shape); break; case 'set-tile-border-width': this._onSetTileBorderWidth(parseInt(btn.dataset.width, 10)); break; case 'reset-strip-position': this._onResetStripPosition(); break; case 'close': this.close(); break; } }; this._focusinHandler = (e) => { const card = e.target.closest('[data-user-id]'); this._focusedUserId = card?.dataset?.userId ?? null; }; this._keydownHandler = (e) => this._onKeydown(e); // Add new listeners root.addEventListener('click', this._clickHandler); root.addEventListener('focusin', this._focusinHandler); root.addEventListener('keydown', this._keydownHandler); // Story 5.2: Set selected values on widget width dropdowns // Get current values from settings to ensure they're up to date const currentSm = this._adapter.settings?.get?.('widgetWidthSm') ?? '83'; const currentMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150'; const smSelect = root.querySelector('select[data-action="set-widget-width-sm"]'); if (smSelect) { smSelect.value = currentSm; // Add change handler for direct select interaction smSelect.addEventListener('change', () => this._onSetWidgetWidth(smSelect.value, 'sm')); } const mdSelect = root.querySelector('select[data-action="set-widget-width-md"]'); if (mdSelect) { mdSelect.value = currentMd; // Add change handler for direct select interaction mdSelect.addEventListener('change', () => this._onSetWidgetWidth(mdSelect.value, 'md')); } // Tile border color picker — Foundry overwrites inline template styles via setPosition, // but the color input's value attribute IS preserved. However, click fires when opening // the picker (before the user chooses), so listen for 'change' instead. const borderColorInput = root.querySelector('input[data-action="set-tile-border-color"]'); if (borderColorInput) { borderColorInput.value = this._adapter.settings?.get?.('tileBorderColor') ?? '#ffffff'; borderColorInput.addEventListener('change', () => this._onSetTileBorderColor(borderColorInput.value)); } // 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); this._refreshPresetPanel(); } /** * Appends the ScenePresetPanel to the DirectorsBoard DOM. * Story 3.2: Scene Auto-Apply & ConfirmationBar * @param {HTMLElement} root - The DirectorsBoard root element. * @private */ _appendPresetPanel(root) { const panel = this._getPresetPanel(); if (!panel || !panel.element) return; // Find where to insert the panel (after content, before footer) const content = root.querySelector('.directors-board__content'); if (content) { // Insert after content content.after(panel.element); } else { // Fallback: prepend to root root.prepend(panel.element); } } /** * Refreshes the ScenePresetPanel content. * Story 3.2: Scene Auto-Apply & ConfirmationBar * @private */ _refreshPresetPanel() { const panel = this._getPresetPanel(); if (panel) { panel._refresh?.(); } } /** * Keyboard navigation within the participant grid. * ArrowLeft/Right/Up/Down move focus; Space/Enter toggles the focused card. * `?` opens the shortcut panel; Ctrl+Shift+P spotlights focused card. * @param {KeyboardEvent} e */ _onKeydown(e) { const cards = [...(this.element?.querySelectorAll('[data-user-id]') ?? [])]; if (cards.length === 0) return; const current = document.activeElement; const idx = cards.indexOf(current); // Guard against negative index (focus from non-card element) if (idx < 0) return; if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); cards[(idx + 1) % cards.length]?.focus(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); cards[(idx - 1 + cards.length) % cards.length]?.focus(); } else if ((e.key === 'Enter' || e.key === ' ') && current?.dataset?.userId) { e.preventDefault(); this._dispatchToggle(current.dataset.userId); } else if (e.key === '?') { e.preventDefault(); this._openShortcutPanel(); } else if (e.ctrlKey && e.shiftKey && e.code === 'KeyP') { e.preventDefault(); this.spotlightFocused(); } } /** * Dispatches a visibility toggle for a participant through the controller. * Matches FR-1: always goes through controller.action(), never direct setState. * @param {string} userId */ async _dispatchToggle(userId) { if (!userId) return; if (this._controller.hasPendingOp?.(userId)) return; const currentState = this._stateStore.getState(userId) ?? 'active'; const targetState = resolveToggleTarget(currentState); const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(userId) ?? 0; try { await Promise.resolve(this._controller.action('board', userId, targetState, opId, baseRevision)); } catch (err) { console.error('[ScryingPool] toggle action failed for', userId, err); } finally { if (this.rendered) this.render({ force: true }); } } /** * Hook handler — re-renders the board when a participant state changes. * @param {object} data */ _onStateChanged(data) { // Suppress unused parameter warning - data is intentionally unused void data; if (this.rendered) { this.render({ force: true }); } } /** * Opens a Dialog showing all Director's Board keyboard shortcuts and their current bindings. * Reads from game.keybindings.bindings when available, falling back to defaults. */ _openShortcutPanel() { try { const localize = (key) => game.i18n?.localize(key) ?? key; const getBinding = (actionKey) => { // 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]) { const b = bindings[0]; const mods = (b.modifiers ?? []).join('+'); return mods ? `${mods}+${b.key}` : b.key; } } return null; }; const shortcuts = [ { 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 const escapeHtml = (str) => String(str ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":"'"}[c])); const rows = shortcuts.map(s => `${escapeHtml(s.label)}${escapeHtml(s.binding)}`).join(''); const content = `${rows}
`; if (typeof Dialog !== 'undefined') { new Dialog({ title: localize('scrying-pool.directorsBoard.shortcuts.title'), content, buttons: { close: { label: 'Close' } }, default: 'close', }).render(true); } } catch (err) { console.error('[ScryingPool] Failed to open shortcut panel:', err); } } /** * Toggles the ScenePresetPanel visibility. * Story 3.2: Scene Auto-Apply & ConfirmationBar */ _togglePresetPanel() { const panel = this._getPresetPanel(); if (panel) { panel.toggle(); } } /** * 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 }); } /** * Saves the selected dock layout and re-renders the board. * The strip re-renders automatically via the setting's onChange callback. * @param {string} layoutKey */ async _onSetDockLayout(layoutKey) { if (!layoutKey) return; try { await this._adapter.settings?.set?.('dockLayout', layoutKey); // Reset per-user size override so the world layout takes effect await this._adapter.settings?.set?.('dockLayoutExpanded', ''); } catch (err) { console.error('[ScryingPool] Failed to set dockLayout:', err); } if (this.rendered) this.render({ force: true }); } /** * Sets the widget width for small or large tiles. * Story 5.2: Video widget width customization * @param {string} value - The width value (e.g., '80', '120') * @param {'sm'|'md'} size - The size variant */ async _onSetWidgetWidth(value, size) { if (!value) return; const settingKey = size === 'sm' ? 'widgetWidthSm' : 'widgetWidthMd'; try { await this._adapter.settings?.set?.(settingKey, value); } catch (err) { console.error('[ScryingPool] Failed to set widget width:', err); } if (this.rendered) this.render({ force: true }); } /** * Sets the tile shape for participant avatars. * @param {string} shape - 'rounded', 'circle', 'hexagon', or 'octagon' */ async _onSetTileShape(shape) { if (!shape) return; try { await this._adapter.settings?.set?.('tileShape', shape); } catch (err) { console.error('[ScryingPool] Failed to set tile shape:', err); } if (this.rendered) this.render({ force: true }); } /** * Sets the border width around video shapes. * @param {number} width - 0, 1, 2, or 4 */ async _onSetTileBorderWidth(width) { if (width == null) return; try { await this._adapter.settings?.set?.('tileBorderWidth', width); } catch (err) { console.error('[ScryingPool] Failed to set tile border width:', err); } if (this.rendered) this.render({ force: true }); } /** * Sets the border color around video shapes. * @param {string} color - hex color string e.g. '#ff0000' */ async _onSetTileBorderColor(color) { if (!color) return; try { await this._adapter.settings?.set?.('tileBorderColor', color); } catch (err) { console.error('[ScryingPool] Failed to set tile border color:', err); } if (this.rendered) this.render({ force: true }); } /** * Clears the saved strip position flag so the strip defaults to its original position. */ _onResetStripPosition() { try { game.user?.setFlag?.('scrying-pool', 'stripState', null); } catch (err) { console.error('[ScryingPool] Failed to reset strip position:', err); } } /** * Opens the PresetSaveDialog for saving the current visibility matrix as a preset. */ _onSavePreset() { if (!this._scenePresetManager || !this._adapter) { console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available'); return; } // Close any existing dialog first this._closePresetDialogs(); // Create and render the save dialog this._saveDialog = new PresetSaveDialog(this._scenePresetManager, this._adapter); this._saveDialog.render(true); } /** * Opens the PresetLoadDialog for loading a saved preset. */ _onLoadPreset() { if (!this._scenePresetManager || !this._adapter) { console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available'); return; } // Close any existing dialog first this._closePresetDialogs(); // Create and render the load dialog this._loadDialog = new PresetLoadDialog(this._scenePresetManager, this._adapter); this._loadDialog.render(true); } /** * Opens the PresetExportDialog for exporting all presets. */ _onExportPresets() { if (!this._scenePresetManager || !this._adapter) { console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available'); return; } // Close any existing dialog first this._closePresetDialogs(); // Create and render the export dialog this._exportDialog = new PresetExportDialog({ adapter: this._adapter, scenePresetManager: this._scenePresetManager, }); this._exportDialog.render(true); } /** * Opens the PresetImportDialog for importing presets. */ _onImportPresets() { if (!this._scenePresetManager || !this._adapter) { console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available'); return; } // Close any existing dialog first this._closePresetDialogs(); // Create and render the import dialog this._importDialog = new PresetImportDialog({ adapter: this._adapter, scenePresetManager: this._scenePresetManager, }); this._importDialog.render(true); } /** * Closes any open preset dialogs. * @private */ _closePresetDialogs() { if (this._saveDialog) { try { this._saveDialog.close(); } catch { // Ignore errors during cleanup } this._saveDialog = null; } if (this._loadDialog) { try { this._loadDialog.close(); } catch { // Ignore errors during cleanup } this._loadDialog = null; } if (this._exportDialog) { try { this._exportDialog.close(); } catch { // Ignore errors during cleanup } this._exportDialog = null; } if (this._importDialog) { try { this._importDialog.close(); } catch { // Ignore errors during cleanup } this._importDialog = null; } } /** * ApplicationV2 lifecycle — clean up event listeners when closed. * @inheritdoc */ async _onClose(options) { await super._onClose?.(options); // Close any open preset dialogs this._closePresetDialogs(); // Clean up event listeners to prevent memory leaks if (this._clickHandler) { this.element?.removeEventListener('click', this._clickHandler); this._clickHandler = null; } if (this._focusinHandler) { this.element?.removeEventListener('focusin', this._focusinHandler); this._focusinHandler = null; } if (this._keydownHandler) { this.element?.removeEventListener('keydown', this._keydownHandler); this._keydownHandler = null; } this._savePosition({ open: false }); } /** * ApplicationV2 lifecycle — save window position when repositioned. * @inheritdoc */ _onPosition(position) { super._onPosition?.(position); const { left, top, width, height } = position; this._savePosition({ left, top, width, height, open: true }); } /** * Persists position/open state to GM user flag. * @private * @param {object} state */ _savePosition(state) { try { game.user?.setFlag('scrying-pool', 'directorsBoardState', state); } catch (err) { console.error('[ScryingPool] Failed to save directors board position:', err); } } }