Files
scrying-pool/src/ui/gm/ScryingPoolStrip.js
T
uberwald 156f786448
CI / ci (push) Successful in 38s
Release Creation / build (release) Successful in 42s
Spotlight: double focused participant size (2x widget width + window)
- _prepareContext doubles widgetWidth when _focusedUserId is set
- _onRender multiplies strip window dimensions by 2 in spotlight mode
- --sp-widget-width CSS var uses doubled value so tile renders at 2x
2026-05-27 11:57:34 +02:00

1301 lines
44 KiB
JavaScript

// @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<object>}
*/
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<string, {retryCount: number, retryTimer: number|null}>} */
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 = `<i class="${iconClass}" aria-hidden="true"></i> ${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 <dialog>-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 <dialog> 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;
}
}