// @ts-nocheck /* global 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 * @param {object|null} [portraitFallbackHandler] - PortraitFallbackHandler for custom portrait lookup * @returns {Array} */ export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false, portraitFallbackHandler = null) { return userIds.map(userId => { const user = adapter.users.get(userId) ?? { name: userId, avatar: null }; const state = stateStore.getState(userId) ?? 'active'; // Priority: custom fallback portrait → user.avatar → character portrait → mystery-man const avatarSrc = portraitFallbackHandler?.getFallbackImageURL(userId) || user.avatar || user.character?.img || 'icons/svg/mystery-man.svg'; return { userId, name: user.name ?? userId, avatarSrc, 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; } // 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; this.position = { left: 0, top: 0 }; } 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() {} setPosition() {} }; /** * GM-only floating control strip showing all connected participants. * Extends ApplicationV2 via HandlebarsApplicationMixin. */ export class ScryingPoolStrip extends _AppBase { static DEFAULT_OPTIONS = { id: 'scrying-pool-strip', classes: ['scrying-pool-strip'], window: { title: 'Scrying Pool', resizable: false }, position: { width: 240 }, }; static PARTS = { strip: { template: 'modules/scrying-pool/templates/roster-strip.hbs', }, }; /** * @param {object} stateStore * @param {object} controller * @param {object} avTileAdapter * @param {object} adapter * @param {object} [options] */ constructor(stateStore, controller, avTileAdapter, adapter, options = {}) { const { portraitFallbackHandler = null, ...appOptions } = options; super(appOptions); this._stateStore = stateStore; this._controller = controller; this._avTileAdapter = avTileAdapter; this._adapter = adapter; this._portraitFallbackHandler = portraitFallbackHandler; /** @type {ActionPopover|null} */ this._activePopover = null; /** @type {StripOverlayLayer|null} */ this._stripOverlayLayer = null; /** @type {Map} */ this._videoStreamRetries = new Map(); /** @type {number|null} */ this._healthCheckInterval = null; /** @type {number|string|null} */ this._userConnectedHookId = null; /** @type {string|null} */ this._focusedUserId = null; /** @type {number|null} */ this._positionSaveTimer = null; // Load saved position from user flags this._loadPosition(); /** Bound keydown handler for Escape-to-exit-focus */ this._onDocumentKeydown = (e) => { if (e.key === 'Escape' && this._focusedUserId) { this._focusedUserId = null; if (typeof this.render === 'function') { this.render({ force: false }); } } }; if (typeof document?.addEventListener === 'function') { document.addEventListener('keydown', this._onDocumentKeydown); } } /** Loads saved window position from GM user flag. */ _loadPosition() { try { const saved = game.user?.getFlag?.('scrying-pool', 'stripState'); if (saved?.left != null && saved?.top != null) { if (saved.left < 0 || saved.top < 0) return; const screenW = typeof window !== 'undefined' ? (window.screen?.availWidth ?? Infinity) : Infinity; const screenH = typeof window !== 'undefined' ? (window.screen?.availHeight ?? Infinity) : Infinity; if (saved.left < screenW - 50 && saved.top < screenH - 50) { if (this.options?.position) { Object.assign(this.options.position, { left: saved.left, top: saved.top }); } } } } catch (_e) { /* no-op in test environment */ } } /** * 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 */ async _prepareContext(options) { const showFirstOpenTip = typeof game !== 'undefined' && !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); // Re-order participants per GM's saved order (drag-and-drop) try { const savedOrder = game.user?.getFlag?.('scrying-pool', 'participantOrder'); if (Array.isArray(savedOrder) && savedOrder.length > 0) { const orderMap = new Map(savedOrder.map((id, idx) => [id, idx])); filteredUserIds.sort((a, b) => { const ai = orderMap.get(a); const bi = orderMap.get(b); if (ai !== undefined && bi !== undefined) return ai - bi; if (ai !== undefined) return -1; if (bi !== undefined) return 1; return 0; }); } } catch (_e) { /* no-op in test environment */ } // Check if we have stream access for video replacement (full AV replacement mode) const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined; const participants = buildParticipantList( filteredUserIds, this._stateStore, this._controller, this._adapter, hasStreamAccess, this._portraitFallbackHandler ); // Mark focused participant for gold ring visual participants.forEach(p => { p.isFocused = this._focusedUserId === p.userId; }); // Remove hidden participants from the strip — they are managed via the Directors Board. let visibleParticipants = participants.filter(p => p.state !== 'hidden'); // Spotlight: if a participant is focused, only show that one if (this._focusedUserId) { visibleParticipants = visibleParticipants.filter(p => p.userId === this._focusedUserId); } // Dock layout: world setting gives direction+canonical size; client override only applies if user toggled const rawLayout = this._adapter.settings?.get?.('dockLayout'); const baseLayout = typeof rawLayout === 'string' ? rawLayout : 'vertical-sm'; const sizeOverride = this._adapter.settings?.get?.('dockLayoutExpanded'); // '' | 'sm' | 'md' const parts = baseLayout.split('-'); const dir = parts.slice(0, -1).join('-'); const canonicalSize = parts[parts.length - 1]; // size from world setting (GM's choice) const effectiveSize = (sizeOverride === 'sm' || sizeOverride === 'md') ? sizeOverride : canonicalSize; const dockLayout = `${dir}-${effectiveSize}`; const isExpanded = dockLayout === 'vertical-md'; const showName = dockLayout.endsWith('-md'); // Story 5.2: Video widget width customization // Defaults match settings registration in module.js: 83px for sm, 150px for md const widgetWidthSm = this._adapter.settings?.get?.('widgetWidthSm') ?? '83'; const widgetWidthMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150'; const isLarge = effectiveSize === 'md'; const effectiveWidth = isLarge ? widgetWidthMd : widgetWidthSm; const isSpotlightActive = !!this._focusedUserId; const widgetWidth = isSpotlightActive ? String(parseInt(effectiveWidth, 10) * 2) : effectiveWidth; const isGM = this._adapter.users.isGM?.() ?? false; // Tile shape for participant avatars const tileShape = this._adapter.settings?.get?.('tileShape') ?? 'circle'; // Tile border around video shapes const tileBorderWidth = this._adapter.settings?.get?.('tileBorderWidth') ?? 0; const tileBorderColor = this._adapter.settings?.get?.('tileBorderColor') ?? '#ffffff'; return { participants: visibleParticipants, isExpanded, showName, dockLayout, isEmpty: visibleParticipants.length === 0, showFirstOpenTip: showFirstOpenTip && isGM, hasStreamAccess, isGM, // Story 5.2: Video widget width customization isSpotlightActive, widgetWidth, // Tile shape tileShape, // Tile border tileBorderWidth, tileBorderColor, }; } /** @inheritdoc */ _onRender(context, options) { super._onRender?.(context, options); const el = this.element; if (!el) return; el.querySelectorAll('[data-action="open-popover"]').forEach(btn => { const userId = btn.dataset.userId; btn.addEventListener('click', e => { if (e.shiftKey) { e.stopPropagation(); this._toggleFocus(userId); return; } e.stopPropagation(); this._openPopover(userId, btn); }); btn.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); this._openContextMenu(userId, btn, e); }); }); // Drag-and-drop reordering of participants // Remove old DnD listeners first to prevent duplicates on re-render el.querySelectorAll('.sp-strip__participant-item').forEach(item => { const existing = item._dndHandlers; if (existing) { item.removeEventListener('dragstart', existing.dragstart); item.removeEventListener('dragover', existing.dragover); item.removeEventListener('drop', existing.drop); item.removeEventListener('dragend', existing.dragend); item.removeEventListener('dragleave', existing.dragleave); } const handlers = { dragstart: e => this._onDragStart(e), dragover: e => this._onDragOver(e), drop: e => this._onDrop(e), dragend: e => this._onDragEnd(e), dragleave: e => this._onDragLeave(e), }; item._dndHandlers = handlers; item.draggable = true; item.addEventListener('dragstart', handlers.dragstart); item.addEventListener('dragover', handlers.dragover); item.addEventListener('drop', handlers.drop); item.addEventListener('dragend', handlers.dragend); item.addEventListener('dragleave', handlers.dragleave); }); 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(); } }); } // 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) // Double-click grip resets participant order to connection order const grip = el.querySelector('[data-action="drag-grip"]'); if (grip) { grip.addEventListener('dblclick', e => { e.preventDefault(); this._resetParticipantOrder(); }); 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); this._savePosition(); }; 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?.('scrying-pool', 'firstStripOpen'); if (isFirstOpen) { 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); } // Start stream monitoring on first render if (this._healthCheckInterval === null) { this._initStreamMonitoring(); } // Set CSS custom properties — Foundry's setPosition() overwrites inline template styles const setVar = (name, value) => { if (value !== undefined && value !== null) el.style.setProperty(name, value); }; const widgetWidth = context?.widgetWidth ?? '150'; setVar('--sp-widget-width', `${widgetWidth}px`); const bw = context?.tileBorderWidth ?? 0; setVar('--sp-tile-border-width', `${bw}px`); setVar('--sp-tile-border-color', context?.tileBorderColor ?? '#ffffff'); setVar('--sp-tile-border-active', bw > 0 ? '1' : '0'); // Sync the outer Application window width with the selected dock layout. // Spotlight mode doubles the strip window to fit the enlarged tile. if (typeof this.setPosition === 'function') { const layout = context?.dockLayout ?? 'vertical-sm'; const n = context?.participants?.length ?? 0; const spotlightMultiplier = context?.isSpotlightActive ? 2 : 1; const width = this._computeStripWidth(layout, n) * spotlightMultiplier; const height = this._computeStripHeight(layout, n); const adjustedHeight = height === 'auto' ? 'auto' : height * spotlightMultiplier; this.setPosition(adjustedHeight === 'auto' ? { width, height: 'auto' } : { width, height: adjustedHeight }); } } /** * Gets the widget width for a given layout size. * Story 5.2: Video widget width customization * @param {'sm'|'md'} size - The size variant * @returns {number} Width in pixels. */ _getWidgetWidth(size) { const value = this._adapter.settings?.get?.(`widgetWidth${size === 'md' ? 'Md' : 'Sm'}`); // Defaults match settings defaults: 83px for sm, 150px for md return typeof value === 'string' ? parseInt(value, 10) : (size === 'md' ? 150 : 83); } /** * Computes the strip window width for a given dock layout and participant count. * @param {string} layout - The dock layout key. * @param {number} participantCount - Number of visible participants. * @returns {number} Width in pixels. */ _computeStripWidth(layout, participantCount) { const n = Math.max(1, participantCount); const GAP = 4, PAD = 8, BORDER_W = 3; // 2px real border + 1px safety const maxCols = 4; const rowWidth = (tileSize, cols) => cols * tileSize + Math.max(0, cols - 1) * GAP + PAD + BORDER_W; const smWidth = this._getWidgetWidth('sm'); const mdWidth = this._getWidgetWidth('md'); switch (layout) { case 'vertical-sm': return rowWidth(smWidth, 1); case 'vertical-md': return rowWidth(mdWidth, 1); case 'horizontal-sm': return rowWidth(smWidth, Math.min(maxCols, n)); case 'horizontal-md': return rowWidth(mdWidth, Math.min(maxCols, n)); case 'mosaic-sm': return rowWidth(smWidth, Math.min(maxCols, Math.ceil(Math.sqrt(n)))); case 'mosaic-md': return rowWidth(mdWidth, Math.min(maxCols, Math.ceil(Math.sqrt(n)))); default: return smWidth + 2; } } /** * Computes the strip window height for horizontal and mosaic layouts so the * window snaps exactly to its content (grip + toolbar + tile grid). * Returns 'auto' for vertical layouts where CSS handles the height naturally. * * Measurements from CSS: * .sp-strip__grip → 16px * .sp-strip__toolbar → 28px * .sp-strip__participants padding: 4px each side → 8px * tile gap: 4px * * @param {string} layout - The dock layout key. * @param {number} participantCount - Number of visible participants. * @returns {number|'auto'} Height in pixels, or 'auto' for vertical layouts. */ _computeStripHeight(layout, participantCount) { const n = Math.max(1, participantCount); const CHROME = 16 + 29; // grip 16px + toolbar 28px content + 1px border-bottom const BORDER_H = 2; // 1px border top + 1px border bottom on app element const GAP = 4, TILE_PAD = 8; // 4px padding each side in .sp-strip__participants const maxCols = 4; const smWidth = this._getWidgetWidth('sm'); const mdWidth = this._getWidgetWidth('md'); const gridHeight = (tileSize, cols) => { const rows = Math.ceil(n / cols); return rows * tileSize + Math.max(0, rows - 1) * GAP + TILE_PAD; }; switch (layout) { case 'horizontal-sm': return CHROME + gridHeight(smWidth, Math.min(maxCols, n)) + BORDER_H; case 'horizontal-md': return CHROME + gridHeight(mdWidth, Math.min(maxCols, n)) + BORDER_H; case 'mosaic-sm': return CHROME + gridHeight(smWidth, Math.min(maxCols, Math.ceil(Math.sqrt(n)))) + BORDER_H; case 'mosaic-md': return CHROME + gridHeight(mdWidth, Math.min(maxCols, Math.ceil(Math.sqrt(n)))) + BORDER_H; default: return 'auto'; } } /** @inheritdoc */ async close(options = {}) { if (this._activePopover) { this._activePopover.close('superseded'); this._activePopover = null; } // Remove document-level keydown listener if (typeof document?.removeEventListener === 'function') { document.removeEventListener('keydown', this._onDocumentKeydown); } // Tear down stream monitoring this._teardownStreamMonitoring(); // Clean up video elements and streams when closing this._cleanupVideoStreams(); if (typeof game !== 'undefined') { game.user?.setFlag?.('scrying-pool', 'stripState', { left: this.position?.left, top: this.position?.top, open: false, }); } return super.close(options); } /** * Removes video elements from the DOM when the strip closes. * Does NOT stop the underlying MediaStream tracks — those belong to * FoundryVTT's WebRTC system (`game.webrtc.client`) and must stay alive * so they can be re-attached if the strip re-opens later. */ _cleanupVideoStreams() { if (typeof document === 'undefined') return; // Clear all retry timers for (const [, retryInfo] of this._videoStreamRetries) { if (retryInfo.retryTimer !== null) { clearTimeout(retryInfo.retryTimer); } } this._videoStreamRetries.clear(); const videoElements = this.element?.querySelectorAll?.('.sp-participant-video__element') ?? []; videoElements.forEach(videoEl => { videoEl.srcObject = null; videoEl.remove(); }); } /** * Toggles between small and large size for the current dock layout direction. * Writes to the client-scoped dockLayoutExpanded setting ('' | 'sm' | 'md') so * players can toggle without needing world-setting write permission. * '' means no personal override — follow the GM's world setting. */ _toggleExpanded() { const raw = this._adapter.settings?.get?.('dockLayout') ?? 'vertical-sm'; const canonicalSize = typeof raw === 'string' ? raw.split('-').pop() : 'sm'; const sizeOverride = this._adapter.settings?.get?.('dockLayoutExpanded'); // '' | 'sm' | 'md' const currentSize = (sizeOverride === 'sm' || sizeOverride === 'md') ? sizeOverride : canonicalSize; const newSize = currentSize === 'md' ? 'sm' : 'md'; this._adapter.settings?.set?.('dockLayoutExpanded', newSize).catch(err => { console.error('[ScryingPool] Failed to toggle dock layout:', err); }); // Strip re-renders via the onChange callback registered in module.js } /** * 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); this._onStreamError(userId); }); videoContainer.appendChild(videoElement); // Monitor this stream for loss/failure this._monitorStream(userId, videoElement, stream); } /** * 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({ force: false }); } } // ── Stream monitoring and recovery ───────────────────────────────────────── /** * Initialises periodic stream health checks and hooks for user connection changes. * Safe to call multiple times — guards against double-init via _healthCheckInterval. */ _initStreamMonitoring() { // Periodic health check every 15 seconds this._healthCheckInterval = setInterval(() => this._checkVideoStreamHealth(), 15000); // Auto-save strip position every 30 seconds this._positionSaveTimer = setInterval(() => this._savePosition(), 30000); // Watch for user connection changes to refresh streams if (typeof Hooks !== 'undefined') { this._userConnectedHookId = Hooks.on('userConnected', (userId, connected) => { if (connected) { setTimeout(() => this._refreshVideoStreams(), 1500); } }); } } /** * Tears down stream monitoring: clears interval, removes hooks, cancels retries. */ _teardownStreamMonitoring() { // Clear health check interval if (this._healthCheckInterval !== null) { clearInterval(this._healthCheckInterval); this._healthCheckInterval = null; } // Clear position save timer if (this._positionSaveTimer !== null) { clearInterval(this._positionSaveTimer); this._positionSaveTimer = null; } // Remove userConnected hook if (this._userConnectedHookId !== null && typeof Hooks !== 'undefined') { Hooks.off('userConnected', this._userConnectedHookId); this._userConnectedHookId = null; } // Cancel all pending retries for (const [, retryInfo] of this._videoStreamRetries) { if (retryInfo.retryTimer !== null) { clearTimeout(retryInfo.retryTimer); } } this._videoStreamRetries.clear(); } /** * Monitors a video stream for loss or failure events. * Sets up listeners on the track lifecycle and starts health tracking. * @param {string} userId * @param {HTMLVideoElement} videoEl * @param {MediaStream} stream */ _monitorStream(userId, videoEl, stream) { // Watch for video track ended (stream truly lost) const videoTracks = stream.getVideoTracks(); for (const track of videoTracks) { track.addEventListener('ended', () => this._onVideoTrackEnded(userId), { once: true }); } // Watch for track removal (mute event may indicate transient issue) for (const track of videoTracks) { track.addEventListener('mute', () => { // Brief delay to see if unmute fires naturally setTimeout(() => { if (track.readyState === 'ended') { this._onVideoTrackEnded(userId); } }, 2000); }, { once: true }); } // Watch for playback stall videoEl.addEventListener('stalled', () => { setTimeout(() => { if (videoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { this._scheduleStreamRetry(userId); } }, 3000); }, { once: true }); } /** * Called when a video track is ended (stream disconnected). * Shows a warning and schedules a retry. * @param {string} userId */ _onVideoTrackEnded(userId) { this._notify('warn', 'scrying-pool.stream.lost', userId); this._scheduleStreamRetry(userId); } /** * Called when a video element fires an error event. * @param {string} userId */ _onStreamError(userId) { this._scheduleStreamRetry(userId); } /** * Schedules a retry for a user's video stream with exponential backoff. * @param {string} userId * @param {number} [delay=2000] - Initial delay in ms */ _scheduleStreamRetry(userId, delay = 2000) { // Cancel any existing retry for this user this._clearStreamRetry(userId); const retryInfo = this._videoStreamRetries.get(userId) ?? { retryCount: 0, retryTimer: null }; if (retryInfo.retryCount >= 3) { this._notify('error', 'scrying-pool.stream.failed', userId); return; } this._notify('info', 'scrying-pool.stream.recovering', userId); retryInfo.retryTimer = setTimeout(() => { this._retryStream(userId); }, delay); retryInfo.retryCount++; this._videoStreamRetries.set(userId, retryInfo); } /** * Attempts to re-acquire and re-attach a user's video stream. * On success, clears retry state and notifies. On failure, schedules next backoff. * @param {string} userId */ _retryStream(userId) { const stream = this._adapter.webrtc?.getMediaStreamForUser?.(userId); const avatar = this.element?.querySelector(`[data-user-id="${userId}"]`); if (stream instanceof MediaStream && avatar) { // Clean up the old video container state const videoContainer = avatar.querySelector('.sp-participant-video'); if (videoContainer) { const oldVideo = videoContainer.querySelector('video'); if (oldVideo) { oldVideo.srcObject = null; oldVideo.remove(); } } // Re-attach with the (possibly new) stream this._attachVideoStream(userId, avatar); this._clearStreamRetry(userId); this._notify('info', 'scrying-pool.stream.recovered', userId); } else { // Exponential backoff: 2s, 4s, 8s const retryInfo = this._videoStreamRetries.get(userId); const nextDelay = Math.pow(2, (retryInfo?.retryCount ?? 1)) * 1000; this._scheduleStreamRetry(userId, nextDelay); } } /** * Cancels any pending retry timer for a user and resets retry state. * @param {string} userId */ _clearStreamRetry(userId) { const retryInfo = this._videoStreamRetries.get(userId); if (retryInfo) { if (retryInfo.retryTimer !== null) { clearTimeout(retryInfo.retryTimer); } this._videoStreamRetries.delete(userId); } } /** * Periodic health check — inspects all participant video elements and * triggers retries for any that are in a failed or stuck state. */ _checkVideoStreamHealth() { if (!this.element) return; this.element.querySelectorAll('.sp-participant-avatar').forEach(avatar => { const userId = avatar.dataset.userId; if (!userId) return; const videoContainer = avatar.querySelector('.sp-participant-video'); const videoEl = avatar.querySelector('.sp-participant-video__element'); // Expected video container but no video element → needs attach if (videoContainer && !videoEl) { const stream = this._adapter.webrtc?.getMediaStreamForUser?.(userId); if (stream instanceof MediaStream) { this._attachVideoStream(userId, avatar); } return; } // Video element exists but has no stream or ended tracks → trigger retry if (videoEl) { const stream = videoEl.srcObject; if (stream instanceof MediaStream) { const hasEndedTrack = stream.getVideoTracks().some(t => t.readyState === 'ended'); if (hasEndedTrack && !this._videoStreamRetries.has(userId)) { this._onVideoTrackEnded(userId); } } } }); } // ── Re-order participants (Drag & Drop) ───────────────────────────────── /** * Handles drag start on a participant item. * @param {DragEvent} e */ _onDragStart(e) { const item = e.target.closest('.sp-strip__participant-item'); if (!item) return; const userId = item.querySelector('[data-user-id]')?.dataset?.userId; if (!userId) return; e.dataTransfer.setData('text/plain', userId); e.dataTransfer.effectAllowed = 'move'; item.classList.add('sp-dragging'); } /** * Handles drag over on a participant item — shows drop indicator. * @param {DragEvent} e */ _onDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const item = e.target.closest('.sp-strip__participant-item'); if (!item) return; item.classList.add('sp-drag-over'); } /** * Handles drop — reorders participants and persists the order. * @param {DragEvent} e */ _onDrop(e) { e.preventDefault(); const fromUserId = e.dataTransfer.getData('text/plain'); const toItem = e.target.closest('.sp-strip__participant-item'); if (!toItem || !fromUserId) return; const toUserId = toItem.querySelector('[data-user-id]')?.dataset?.userId; if (!toUserId || fromUserId === toUserId) return; const el = this.element; if (!el) return; const items = [...el.querySelectorAll('.sp-strip__participant-item')]; const userIds = items.map(item => item.querySelector('[data-user-id]')?.dataset?.userId).filter(Boolean); const fromIdx = userIds.indexOf(fromUserId); const toIdx = userIds.indexOf(toUserId); if (fromIdx === -1 || toIdx === -1) return; userIds.splice(fromIdx, 1); const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx; userIds.splice(adjustedTo, 0, fromUserId); this._saveParticipantOrder(userIds); if (typeof this.render === 'function') { this.render({ force: false }); } } /** * Handles drag end — removes visual indicators. */ _onDragEnd() { this.element?.querySelectorAll('.sp-strip__participant-item')?.forEach(item => { item.classList.remove('sp-dragging', 'sp-drag-over'); }); } /** * Handles drag leave — removes drop indicator from the exited item. * @param {DragEvent} e */ _onDragLeave(e) { const item = e.target.closest('.sp-strip__participant-item'); if (item) item.classList.remove('sp-drag-over'); } /** * Persists participant order to GM user flag. * @param {string[]} orderedUserIds */ _saveParticipantOrder(orderedUserIds) { if (typeof game === 'undefined') return; try { game.user?.setFlag?.('scrying-pool', 'participantOrder', orderedUserIds); } catch (_e) { /* no-op */ } } /** * Clears saved order — next render uses connection order. */ _resetParticipantOrder() { if (typeof game === 'undefined') return; try { game.user?.unsetFlag?.('scrying-pool', 'participantOrder'); } catch (_e) { /* no-op */ } if (typeof this.render === 'function') { this.render({ force: false }); } } // ── Spotlight / Focus ──────────────────────────────────────────────────── /** * Toggles focus on a participant. If already focused, exits focus mode. * @param {string} userId */ _toggleFocus(userId) { if (this._focusedUserId === userId) { this._focusedUserId = null; } else { this._focusedUserId = userId; } if (typeof this.render === 'function') { this.render({ force: false }); } } // ── Auto-save position ─────────────────────────────────────────────────── /** * Saves current strip position to GM user flag. */ _savePosition() { if (typeof game === 'undefined') return; try { const pos = this.position; if (pos?.left == null || pos?.top == null) return; game.user?.setFlag?.('scrying-pool', 'stripState', { left: pos.left, top: pos.top, width: pos.width ?? 240, height: pos.height ?? 'auto', savedAt: Date.now(), }); } catch (_e) { /* no-op */ } } /** * Shows a notification with i18n support and safe fallback for test environments. * @param {'info'|'warn'|'error'} level * @param {string} i18nKey * @param {string} userId */ _notify(level, i18nKey, userId) { const userName = this._getUserName(userId); let msg; try { msg = game.i18n?.format?.(i18nKey, { name: userName }) ?? `${i18nKey}: ${userName}`; } catch { msg = `${i18nKey}: ${userName}`; } this._adapter.notifications?.[level]?.(msg); } /** * Resolves a display name for a user ID. * @param {string} userId * @returns {string} */ _getUserName(userId) { try { return this._adapter.users.get?.(userId)?.name ?? userId; } catch { return userId; } } } /** * 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; } }