Files
scrying-pool/src/ui/gm/ScryingPoolStrip.js
T
2026-05-24 09:39:53 +02:00

593 lines
18 KiB
JavaScript

// @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<object>}
*/
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 = `<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);
});
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 <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;
}
}