// @ts-nocheck /* global Application, directorsBoard */ import { generateOpId } from '../../utils/uuid.js'; import { StripOverlayLayer } from '../shared/StripOverlayLayer.js'; /** * Canonical action labels — never use inline string literals. * @type {Readonly<{HIDE_FROM_TABLE: string, SHOW_TO_TABLE: string, FIRST_TOOLTIP: string}>} */ export const LABELS = Object.freeze({ HIDE_FROM_TABLE: 'Hide from table', SHOW_TO_TABLE: 'Show to table', FIRST_TOOLTIP: 'Hide this participant from other players.', }); /** * Resolves the target state for a hide/show toggle action. * @param {string} currentState * @returns {'hidden'|'active'} */ export function resolveTargetState(currentState) { return currentState === 'hidden' ? 'active' : 'hidden'; } /** * Builds a participant list array for getData(). * @param {string[]} userIds * @param {object} stateStore * @param {object} controller * @param {object} adapter * @param {boolean} hasStreamAccess - Whether stream access is available for video replacement * @returns {Array} */ export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false) { return userIds.map(userId => { const user = adapter.users.get(userId) ?? { name: userId, avatar: null }; const state = stateStore.getState(userId) ?? 'active'; return { userId, name: user.name ?? userId, avatarSrc: user.avatar ?? 'icons/svg/mystery-man.svg', state, stateLabel: _stateLabel(state), hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false, isCurrentUser: adapter.users.current?.()?.id === userId, hasStreamAccess, }; }); } /** * Returns a human-readable label for a state string. * @param {string} state * @returns {string} */ function _stateLabel(state) { const LABELS_MAP = { active: 'Active', hidden: 'Hidden', 'self-muted': 'Self-muted', offline: 'Offline', 'cam-lost': 'Camera lost', reconnecting: 'Reconnecting', 'never-connected': 'Never connected', ghost: 'Ghost', }; return LABELS_MAP[state] ?? state; } // Use conditional base class for test compatibility (typeof check is no-undef safe) const _AppBase = typeof Application !== 'undefined' ? Application : class _FallbackApplication { static get defaultOptions() { return {}; } render() {} close() {} get rendered() { return false; } }; /** * GM-only floating control strip showing all connected participants. * Extends Foundry's Application base class. * Uses Application (not ApplicationV2) for simplicity in FoundryVTT v14. */ export class ScryingPoolStrip extends _AppBase { /** @inheritdoc */ static get defaultOptions() { const base = typeof foundry !== 'undefined' && foundry.utils?.mergeObject ? foundry.utils.mergeObject(super.defaultOptions, {}) : super.defaultOptions ?? {}; return Object.assign({}, base, { id: 'scrying-pool-strip', template: 'modules/video-view-manager/templates/roster-strip.hbs', popOut: true, resizable: false, title: 'Scrying Pool', classes: ['scrying-pool-strip'], }); } /** * @param {object} stateStore * @param {object} controller * @param {object} avTileAdapter * @param {object} adapter * @param {object} [options] */ constructor(stateStore, controller, avTileAdapter, adapter, options = {}) { super(options); this._stateStore = stateStore; this._controller = controller; this._avTileAdapter = avTileAdapter; this._adapter = adapter; this._isExpanded = true; /** @type {ActionPopover|null} */ this._activePopover = null; /** @type {StripOverlayLayer|null} */ this._stripOverlayLayer = null; } /** * Returns the StripOverlayLayer instance, creating it lazily if needed. * @returns {StripOverlayLayer} The overlay layer instance. */ get stripOverlayLayer() { if (!this._stripOverlayLayer) { this._stripOverlayLayer = new StripOverlayLayer(this._adapter); this._stripOverlayLayer.init(); } return this._stripOverlayLayer; } /** @inheritdoc */ getData() { const savedState = typeof game !== 'undefined' ? game.user?.getFlag?.('video-view-manager', 'stripState') : null; if (savedState?.expanded !== undefined) { this._isExpanded = savedState.expanded; } const showFirstOpenTip = typeof game !== 'undefined' && !game.user?.getFlag?.('video-view-manager', 'firstStripOpen'); const userIds = this._adapter.users.all ? this._adapter.users.all().map(u => u.id) : []; // Check if we have stream access for video replacement (full AV replacement mode) const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined; const participants = buildParticipantList( userIds, this._stateStore, this._controller, this._adapter, hasStreamAccess ); return { participants, isExpanded: this._isExpanded, isEmpty: participants.length === 0, showFirstOpenTip, hasStreamAccess, }; } /** @inheritdoc */ activateListeners(html) { super.activateListeners(html); const el = html instanceof HTMLElement ? html : html?.[0]; if (!el) return; el.querySelectorAll('[data-action="open-popover"]').forEach(btn => { const userId = btn.dataset.userId; btn.addEventListener('click', e => { e.stopPropagation(); this._openPopover(userId, btn); }); btn.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); this._openContextMenu(userId, btn, e); }); }); const toggle = el.querySelector('[data-action="toggle-expanded"]'); if (toggle) { toggle.addEventListener('click', () => this._toggleExpanded()); } // Director's Board CTA button (fallback for sidebar API) const directorsBoardBtn = el.querySelector('[data-action="open-directors-board"]'); if (directorsBoardBtn) { directorsBoardBtn.addEventListener('click', () => { if (typeof directorsBoard !== 'undefined' && directorsBoard) { directorsBoard.toggle(); } }); } // First open tip: set flag so it doesn't show again const isFirstOpen = typeof game !== 'undefined' && !game.user?.getFlag?.('video-view-manager', 'firstStripOpen'); if (isFirstOpen) { game.user?.setFlag?.('video-view-manager', 'firstStripOpen', true); } // Attach video streams if we have stream access (full AV replacement mode) if (this._adapter.webrtc?.getMediaStreamForUser !== undefined) { this._attachVideoStreams(el); } } /** @inheritdoc */ async close(options = {}) { if (this._activePopover) { this._activePopover.close('superseded'); this._activePopover = null; } // Clean up video elements and streams when closing this._cleanupVideoStreams(); if (typeof game !== 'undefined') { game.user?.setFlag?.('video-view-manager', 'stripState', { left: this.position?.left, top: this.position?.top, open: false, expanded: this._isExpanded, }); } return super.close(options); } /** * Cleans up all video elements and their associated streams. * Called when the strip is closed to prevent memory leaks. */ _cleanupVideoStreams() { if (typeof document === 'undefined') return; const videoElements = document.querySelectorAll?.('.sp-participant-video__element') ?? []; videoElements.forEach(videoEl => { // Stop all tracks in the stream if (videoEl.srcObject instanceof MediaStream) { videoEl.srcObject.getTracks().forEach(track => track.stop()); } videoEl.srcObject = null; videoEl.remove(); }); } /** * Toggles the expanded/collapsed state of the strip. */ _toggleExpanded() { this._isExpanded = !this._isExpanded; if (typeof game !== 'undefined') { game.user?.setFlag?.('video-view-manager', 'stripState', { left: this.position?.left, top: this.position?.top, open: true, expanded: this._isExpanded, }); } this.render(true); } /** * Opens an ActionPopover for the given participant. Supersedes any open popover. * @param {string} participantId * @param {HTMLElement} anchorEl */ _openPopover(participantId, anchorEl) { if (this._activePopover) { this._activePopover.close('superseded'); } const state = this._stateStore.getState(participantId); const hasPending = this._controller.hasPendingOp?.(participantId) ?? false; const popover = new ActionPopover( participantId, state, anchorEl, hasPending, (pid, targetState) => this._dispatchAction(pid, targetState) ); popover.open(); this._activePopover = popover; anchorEl.setAttribute('aria-pressed', 'true'); popover.onClose(() => { anchorEl.setAttribute('aria-pressed', 'false'); anchorEl.focus?.(); if (this._activePopover === popover) this._activePopover = null; }); } /** * Opens a native context menu for hide/show actions. * @param {string} participantId * @param {HTMLElement} anchorEl * @param {MouseEvent} event */ _openContextMenu(participantId, anchorEl, event) { const currentState = this._stateStore.getState(participantId); const isHidden = currentState === 'hidden'; const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE; const iconClass = isHidden ? 'fas fa-eye' : 'fas fa-eye-slash'; const menu = document.createElement('div'); menu.className = 'sp-context-menu'; menu.setAttribute('role', 'menu'); const item = document.createElement('button'); item.className = 'sp-context-menu__item'; item.setAttribute('role', 'menuitem'); item.innerHTML = ` ${label}`; item.addEventListener('click', () => { this._dispatchAction(participantId, isHidden ? 'active' : 'hidden'); menu.remove(); }); menu.appendChild(item); document.body.appendChild(menu); menu.style.position = 'fixed'; menu.style.left = `${event.clientX}px`; menu.style.top = `${event.clientY}px`; const dismiss = e => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', dismiss); } }; setTimeout(() => document.addEventListener('click', dismiss), 0); } /** * Dispatches a visibility change action through the controller. * @param {string} participantId * @param {string} [explicitTargetState] - if omitted, toggle from current state */ _dispatchAction(participantId, explicitTargetState) { const currentState = this._stateStore.getState(participantId); const targetState = explicitTargetState ?? resolveTargetState(currentState); const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(participantId) ?? 0; this._controller.action('strip', participantId, targetState, opId, baseRevision); } /** * Attaches video stream elements for all participants in the strip. * Called from activateListeners() when stream access is available. * @param {HTMLElement} container - The container element (html from activateListeners) */ _attachVideoStreams(container) { if (!container) return; const participantItems = container.querySelectorAll?.('.sp-strip__participant-item') ?? []; for (const item of participantItems) { if (!item) continue; const userId = item.querySelector?.('[data-user-id]')?.dataset?.userId; if (userId) { this._attachVideoStream(userId, item); } } } /** * Attaches a video element with the WebRTC stream for a specific user. * Creates a video element with srcObject set to the user's MediaStream. * The video element is appended to the video container within the participant item. * @param {string} userId - The user ID to attach video for * @param {HTMLElement} participantItem - The participant list item element */ _attachVideoStream(userId, participantItem) { // Guard: check webrtc is available if (!this._adapter?.webrtc?.getMediaStreamForUser) { return; } // Guard: check participantItem is valid if (!participantItem) { console.warn('[ScryingPool] _attachVideoStream: participantItem is null/undefined'); return; } const stream = this._adapter.webrtc.getMediaStreamForUser(userId); // Check if video container exists const videoContainer = participantItem.querySelector?.('.sp-participant-video'); if (!videoContainer) { console.warn('[ScryingPool] No video container found for user:', userId); return; } // Remove any existing video element const existingVideo = videoContainer.querySelector?.('video'); if (existingVideo) { existingVideo.remove(); } // If no stream available, don't create video element (will show avatar fallback) if (!stream) { return; } // Guard: ensure we're in a browser environment if (typeof document === 'undefined' || !document.createElement) { console.warn('[ScryingPool] _attachVideoStream: document or createElement not available'); return; } // Validate stream is a MediaStream if (!(stream instanceof MediaStream)) { console.warn('[ScryingPool] _attachVideoStream: stream is not a MediaStream:', typeof stream); return; } // Create new video element const videoElement = document.createElement('video'); videoElement.srcObject = stream; videoElement.autoplay = true; videoElement.playsInline = true; // Guard: ensure current user check is safe const currentUserId = this._adapter?.users?.current?.()?.id; videoElement.muted = userId === currentUserId; // Add CSS class for styling videoElement.className = 'sp-participant-video__element'; // Set up error handling videoElement.addEventListener('error', () => { console.warn('[ScryingPool] Video element error for user:', userId); }); videoContainer.appendChild(videoElement); } /** * Refreshes all video streams for participants. * Called when stream state changes (user joins/leaves, mute/unmute). */ _refreshVideoStreams() { if (!this._adapter.webrtc?.getMediaStreamForUser) { return; } // Remove existing video elements before re-rendering to prevent duplicates const existingVideos = this.element?.querySelectorAll('.sp-participant-video__element') ?? []; existingVideos.forEach(v => v.remove()); // Re-render to ensure DOM is up to date if (typeof this.render === 'function') { this.render(false); } } } /** * Native -based per-participant action popover. * Not exported — internal to the gm/ layer. */ class ActionPopover { /** * @param {string} participantId * @param {string} currentState * @param {HTMLElement} anchorEl * @param {boolean} hasPendingOp * @param {function(string, string): void} onAction */ constructor(participantId, currentState, anchorEl, hasPendingOp, onAction) { this._participantId = participantId; this._currentState = currentState; this._anchorEl = anchorEl; this._hasPendingOp = hasPendingOp; this._onAction = onAction; this._onCloseCb = null; this._dialog = this._build(); } /** * Builds the DOM element. * @returns {HTMLDialogElement} */ _build() { const isHidden = this._currentState === 'hidden'; const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE; const dialog = document.createElement('dialog'); dialog.className = 'sp-action-popover'; dialog.setAttribute('aria-modal', 'true'); const cta = document.createElement('button'); cta.className = 'sp-action-popover__cta'; cta.dataset.action = 'primary-cta'; cta.textContent = label; if (this._hasPendingOp) { cta.disabled = true; cta.setAttribute('aria-disabled', 'true'); } // First-time tooltip (localStorage-based) const hasSeenTooltip = typeof localStorage !== 'undefined' ? localStorage.getItem('scrying-pool.firstHideTooltip') : '1'; if (!hasSeenTooltip) { cta.title = LABELS.FIRST_TOOLTIP; cta.addEventListener('mouseenter', () => { if (typeof localStorage !== 'undefined') { localStorage.setItem('scrying-pool.firstHideTooltip', '1'); } }, { once: true }); } cta.addEventListener('click', () => { const targetState = isHidden ? 'active' : 'hidden'; this._onAction(this._participantId, targetState); this.close('action'); }); dialog.appendChild(cta); dialog.addEventListener('cancel', e => { e.preventDefault(); this.close('escape'); }); dialog.addEventListener('click', e => { if (e.target === dialog) { this.close('backdrop'); } }); return dialog; } /** * Opens the popover dialog, positions it near the anchor, and focuses the CTA. */ open() { document.body.appendChild(this._dialog); const rect = this._anchorEl.getBoundingClientRect(); this._dialog.style.position = 'fixed'; this._dialog.style.left = `${rect.right + 4}px`; this._dialog.style.top = `${rect.top}px`; this._dialog.style.margin = '0'; if (typeof this._dialog.showModal === 'function') { this._dialog.showModal(); } const cta = this._dialog.querySelector('[data-action="primary-cta"]'); cta?.focus?.(); } /** * Closes the popover dialog and invokes the onClose callback. * @param {string} [reason] */ close(reason) { if (this._dialog.parentNode) { if (typeof this._dialog.close === 'function') { this._dialog.close(reason ?? ''); } this._dialog.remove(); } this._onCloseCb?.(); } /** * Registers a callback for when the popover closes. * @param {function(): void} cb */ onClose(cb) { this._onCloseCb = cb; } }