Story 3.2 done
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
// @ts-nocheck
|
||||
|
||||
/**
|
||||
* Canonical player-state → display label map.
|
||||
* `active` maps to null — no label is shown when the feed is live.
|
||||
* @type {Readonly<Record<string, string|null>>}
|
||||
*/
|
||||
const PLAYER_STATE_LABELS = Object.freeze({
|
||||
hidden: 'Hidden from table',
|
||||
'self-muted': 'Camera paused',
|
||||
offline: 'Not connected',
|
||||
'cam-lost': 'Camera unavailable',
|
||||
reconnecting: 'Rejoining view',
|
||||
'never-connected': 'Not yet connected',
|
||||
ghost: 'Leaving',
|
||||
active: null,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VisibilityDetailsPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Native <dialog>-based panel showing full detail about a player's camera state.
|
||||
* Not exported — internal to the player/ layer.
|
||||
*/
|
||||
class VisibilityDetailsPanel {
|
||||
/**
|
||||
* @param {object|null} controller - ScryingPoolController (may be null if unavailable)
|
||||
*/
|
||||
constructor(controller) {
|
||||
this._controller = controller;
|
||||
/** @type {HTMLDialogElement|null} */
|
||||
this._dialog = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._triggerEl = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows the details panel as a modal dialog.
|
||||
* @param {string} state - Current player visibility state
|
||||
* @param {object|null} actor - User object with .name property (or null)
|
||||
* @param {string|null} reason - Reason for state change (e.g., "Hidden by: GM Name")
|
||||
* @param {HTMLElement} triggerEl - Element to return focus to on close
|
||||
*/
|
||||
show(state, actor, reason, triggerEl) {
|
||||
if (this._dialog) return; // already open
|
||||
|
||||
this._triggerEl = triggerEl;
|
||||
const stateLabel = PLAYER_STATE_LABELS[state] ?? state;
|
||||
const isHidden = state === 'hidden';
|
||||
const isDataStale = !this._controller;
|
||||
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'sp-visibility-details-panel';
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
|
||||
// --- State explanation ---
|
||||
const stateEl = document.createElement('p');
|
||||
stateEl.className = 'sp-visibility-details-panel__state';
|
||||
stateEl.textContent = stateLabel;
|
||||
dialog.appendChild(stateEl);
|
||||
|
||||
// --- Reason/actor display ---
|
||||
if (reason) {
|
||||
const reasonEl = document.createElement('p');
|
||||
reasonEl.className = 'sp-visibility-details-panel__reason';
|
||||
reasonEl.textContent = reason;
|
||||
dialog.appendChild(reasonEl);
|
||||
} else if (actor?.name) {
|
||||
const actorEl = document.createElement('p');
|
||||
actorEl.className = 'sp-visibility-details-panel__actor';
|
||||
actorEl.textContent = `State changed by: ${actor.name}`;
|
||||
dialog.appendChild(actorEl);
|
||||
}
|
||||
|
||||
// --- Audience section ---
|
||||
if (isHidden) {
|
||||
const reassuranceEl = document.createElement('p');
|
||||
reassuranceEl.className = 'sp-visibility-details-panel__reassurance';
|
||||
reassuranceEl.textContent = 'Other players cannot see your feed';
|
||||
dialog.appendChild(reassuranceEl);
|
||||
} else {
|
||||
const audioEl = document.createElement('p');
|
||||
audioEl.className = 'sp-visibility-details-panel__audio-note';
|
||||
audioEl.textContent = 'Your audio is active for all participants.';
|
||||
dialog.appendChild(audioEl);
|
||||
}
|
||||
|
||||
// --- Stale data indicator ---
|
||||
if (isDataStale) {
|
||||
const staleEl = document.createElement('p');
|
||||
staleEl.className = 'sp-visibility-details-panel__stale';
|
||||
staleEl.textContent = 'Data may be outdated';
|
||||
dialog.appendChild(staleEl);
|
||||
}
|
||||
|
||||
// --- Close button ---
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'sp-visibility-details-panel__close';
|
||||
closeBtn.setAttribute('data-action', 'close-details');
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.addEventListener('click', () => dialog.close());
|
||||
dialog.appendChild(closeBtn);
|
||||
|
||||
// --- Dismiss handlers ---
|
||||
// Backdrop click: only when the click target IS the dialog backdrop
|
||||
dialog.addEventListener('click', e => {
|
||||
if (e.target === dialog) dialog.close();
|
||||
});
|
||||
|
||||
// Clean up on close (native Esc + programmatic close)
|
||||
dialog.addEventListener('close', () => this._onClose());
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
this._dialog = dialog;
|
||||
|
||||
if (typeof dialog.showModal === 'function') {
|
||||
dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the dialog from the DOM and returns focus to the trigger element.
|
||||
*/
|
||||
_onClose() {
|
||||
if (this._dialog) {
|
||||
this._dialog.remove();
|
||||
this._dialog = null;
|
||||
}
|
||||
this._triggerEl?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FirstEncounterPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Non-modal explanatory panel shown the first time a player's badge updates.
|
||||
* Collapses after a 10s idle timer into a persistent chip.
|
||||
* Not exported — internal to the player/ layer.
|
||||
*/
|
||||
class FirstEncounterPanel {
|
||||
/**
|
||||
* @param {Function} setEncounteredFn - async fn that sets the firstBadgeEncounter flag
|
||||
* @param {Function} openDetailsFn - fn() that opens VisibilityDetailsPanel
|
||||
*/
|
||||
#collapseTimer = null;
|
||||
|
||||
constructor(setEncounteredFn, openDetailsFn) {
|
||||
this._setEncountered = setEncounteredFn;
|
||||
this._openDetails = openDetailsFn;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._panel = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._chip = null;
|
||||
/** @type {number} */
|
||||
this._remainingMs = 10_000;
|
||||
/** @type {number|null} */
|
||||
this._timerStartedAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows the explanatory panel.
|
||||
* anchorEl is accepted for API consistency but positioning is handled via CSS.
|
||||
* @param {HTMLElement} _anchorEl
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
show(_anchorEl) {
|
||||
if (this._panel) return; // already shown
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'sp-first-encounter-panel';
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.setAttribute('aria-modal', 'false');
|
||||
panel.setAttribute('aria-label', 'Camera visibility explanation');
|
||||
|
||||
const title = document.createElement('p');
|
||||
title.className = 'sp-first-encounter-panel__title';
|
||||
title.textContent = 'Your camera visibility changed.';
|
||||
panel.appendChild(title);
|
||||
|
||||
const body = document.createElement('p');
|
||||
body.className = 'sp-first-encounter-panel__body';
|
||||
body.textContent = 'Audio continues normally.';
|
||||
panel.appendChild(body);
|
||||
|
||||
const gotItBtn = document.createElement('button');
|
||||
gotItBtn.className = 'sp-first-encounter-panel__got-it';
|
||||
gotItBtn.setAttribute('data-action', 'got-it');
|
||||
gotItBtn.textContent = 'Got it';
|
||||
gotItBtn.addEventListener('click', async () => {
|
||||
await this._onGotIt();
|
||||
});
|
||||
panel.appendChild(gotItBtn);
|
||||
|
||||
// Timer pause/resume on hover/focus
|
||||
panel.addEventListener('mouseenter', () => this._pauseTimer());
|
||||
panel.addEventListener('mouseleave', () => this._resumeTimer());
|
||||
panel.addEventListener('focusin', () => this._pauseTimer());
|
||||
panel.addEventListener('focusout', () => this._resumeTimer());
|
||||
|
||||
document.body.appendChild(panel);
|
||||
this._panel = panel;
|
||||
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the collapse timer, recording when it started.
|
||||
*/
|
||||
_startTimer() {
|
||||
this._timerStartedAt = Date.now();
|
||||
this.#collapseTimer = setTimeout(() => this._collapse(), this._remainingMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the collapse timer, storing remaining time.
|
||||
*/
|
||||
_pauseTimer() {
|
||||
if (this.#collapseTimer === null) return;
|
||||
const elapsed = Date.now() - (this._timerStartedAt ?? Date.now());
|
||||
this._remainingMs = Math.max(0, this._remainingMs - elapsed);
|
||||
clearTimeout(this.#collapseTimer);
|
||||
this.#collapseTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the collapse timer with remaining time.
|
||||
*/
|
||||
_resumeTimer() {
|
||||
if (this.#collapseTimer !== null) return; // already running
|
||||
this._timerStartedAt = Date.now();
|
||||
this.#collapseTimer = setTimeout(() => this._collapse(), this._remainingMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Got it" handler — clears timer, sets flag, dismisses panel.
|
||||
* Uses async to ensure flag is persisted before dismissing.
|
||||
*/
|
||||
async _onGotIt() {
|
||||
clearTimeout(this.#collapseTimer); // ghost prevention
|
||||
this.#collapseTimer = null;
|
||||
try {
|
||||
await this._setEncountered();
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] Failed to set firstBadgeEncounter flag:', err);
|
||||
}
|
||||
this._dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses the panel (via CSS class + 300ms timer matching CSS ease-out) and replaces it with a chip.
|
||||
*/
|
||||
_collapse() {
|
||||
// Clear any pending timer from _startTimer before creating new one
|
||||
clearTimeout(this.#collapseTimer);
|
||||
this.#collapseTimer = null;
|
||||
if (!this._panel) return;
|
||||
|
||||
const panel = this._panel;
|
||||
|
||||
// Apply collapse animation via CSS class
|
||||
panel.classList.add('sp-first-encounter-panel--collapsing');
|
||||
|
||||
const activeEl = document.activeElement;
|
||||
const wasInsidePanel = activeEl ? panel.contains(activeEl) : false;
|
||||
|
||||
// Replace after CSS transition duration (300ms ease-out per AC)
|
||||
this.#collapseTimer = setTimeout(() => {
|
||||
this.#collapseTimer = null;
|
||||
if (!this._panel) return;
|
||||
this._panel.remove();
|
||||
this._panel = null;
|
||||
|
||||
const chip = this._createChip();
|
||||
document.body.appendChild(chip);
|
||||
this._chip = chip;
|
||||
|
||||
if (wasInsidePanel) {
|
||||
chip.focus();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the collapsed chip element.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createChip() {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'sp-visibility-chip';
|
||||
chip.setAttribute('role', 'button');
|
||||
chip.setAttribute('tabindex', '0');
|
||||
chip.setAttribute('aria-label', 'Camera visibility — click for details');
|
||||
|
||||
chip.addEventListener('click', () => this._openDetails());
|
||||
chip.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this._openDetails();
|
||||
}
|
||||
});
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the panel from the DOM without triggering collapse animation.
|
||||
*/
|
||||
_dismiss() {
|
||||
if (this._panel) {
|
||||
this._panel.remove();
|
||||
this._panel = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called on teardown — clears timer to prevent ghost timers.
|
||||
*/
|
||||
_onClose() {
|
||||
clearTimeout(this.#collapseTimer); // ghost prevention
|
||||
this.#collapseTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes both panel and chip from DOM.
|
||||
*/
|
||||
cleanup() {
|
||||
this._onClose();
|
||||
if (this._panel) {
|
||||
this._panel.remove();
|
||||
this._panel = null;
|
||||
}
|
||||
if (this._chip) {
|
||||
this._chip.remove();
|
||||
this._chip = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VisibilityBadge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Player-facing camera visibility badge.
|
||||
* Mounted on the player's own AV tile via AVTileAdapter.
|
||||
* Shows the current visibility state and triggers first-encounter education UI.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
export class VisibilityBadge {
|
||||
/**
|
||||
* @param {object} stateStore - StateStore instance
|
||||
* @param {object|null} controller - ScryingPoolController (may be null)
|
||||
* @param {object} avTileAdapter - AVTileAdapter instance (shared with RoleRenderer)
|
||||
* @param {object} adapter - FoundryAdapter instance
|
||||
*/
|
||||
constructor(stateStore, controller, avTileAdapter, adapter) {
|
||||
this._stateStore = stateStore;
|
||||
this._controller = controller;
|
||||
this._avTileAdapter = avTileAdapter;
|
||||
this._adapter = adapter;
|
||||
/** @type {string|null} */
|
||||
this._currentUserId = null;
|
||||
/** @type {string} */
|
||||
this._currentState = 'active';
|
||||
/** @type {object|null} */
|
||||
this._currentStateActor = null;
|
||||
/** @type {string|null} */
|
||||
this._currentStateReason = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._badgeEl = null;
|
||||
/** @type {FirstEncounterPanel|null} */
|
||||
this._firstEncounterPanel = null;
|
||||
/** @type {Function|null} */
|
||||
this._stateChangedHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises the badge — resolves the current user, subscribes to state changes,
|
||||
* and mounts the initial badge element.
|
||||
* No-ops if no current user can be resolved.
|
||||
*/
|
||||
init() {
|
||||
const currentUser = this._adapter.users.current?.();
|
||||
if (!currentUser?.id) {
|
||||
this._currentUserId = null;
|
||||
return;
|
||||
}
|
||||
this._currentUserId = currentUser.id;
|
||||
|
||||
// Subscribe to state changes
|
||||
this._stateChangedHandler = data => this._onStateChanged(data);
|
||||
Hooks.on('scrying-pool:stateChanged', this._stateChangedHandler);
|
||||
|
||||
// Mount initial badge
|
||||
const initialState = this._stateStore.getState?.(this._currentUserId) ?? 'active';
|
||||
this._currentState = initialState;
|
||||
this._badgeEl = this._createBadgeElement(initialState);
|
||||
this._avTileAdapter.mount(this._currentUserId, this._badgeEl);
|
||||
|
||||
// Re-mount badge if Foundry re-renders the AV tile
|
||||
this._avTileAdapter.onTileRerender(this._currentUserId, () => {
|
||||
this._mountBadge(this._currentState);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the badge DOM element for the given state.
|
||||
* @param {string} state
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createBadgeElement(state) {
|
||||
const stateLabel = PLAYER_STATE_LABELS[state] ?? null;
|
||||
const ariaLabel = `Camera visibility: ${stateLabel ?? 'Active'}`;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sp-visibility-badge';
|
||||
el.dataset.spRole = 'visibility-badge';
|
||||
el.setAttribute('role', 'status');
|
||||
el.setAttribute('aria-live', 'polite');
|
||||
el.setAttribute('aria-label', ariaLabel);
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'sp-visibility-badge__label';
|
||||
labelSpan.textContent = stateLabel ?? '';
|
||||
el.appendChild(labelSpan);
|
||||
|
||||
el.addEventListener('click', () => this._openDetailsPanel(el));
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-mounts the badge at the given state (idempotent via AVTileAdapter).
|
||||
* @param {string} state
|
||||
*/
|
||||
_mountBadge(state) {
|
||||
if (!this._currentUserId) return;
|
||||
this._currentState = state;
|
||||
const stateLabel = PLAYER_STATE_LABELS[state] ?? null;
|
||||
const ariaLabel = `Camera visibility: ${stateLabel ?? 'Active'}`;
|
||||
|
||||
if (!this._badgeEl) {
|
||||
this._badgeEl = this._createBadgeElement(state);
|
||||
} else {
|
||||
this._badgeEl.setAttribute('aria-label', ariaLabel);
|
||||
const labelSpan = this._badgeEl.querySelector('.sp-visibility-badge__label');
|
||||
if (labelSpan) {
|
||||
labelSpan.textContent = stateLabel ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
this._avTileAdapter.mount(this._currentUserId, this._badgeEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a `scrying-pool:stateChanged` hook event.
|
||||
* Guards to only process events for the current user.
|
||||
* @param {{ userId: string, state: string, actor?: object, reason?: string }} data
|
||||
*/
|
||||
_onStateChanged(data) {
|
||||
// Validate data shape
|
||||
if (!data || typeof data !== 'object' || !data.userId || !data.state) {
|
||||
return;
|
||||
}
|
||||
if (data.userId !== this._currentUserId) return;
|
||||
|
||||
this._currentState = data.state;
|
||||
this._currentStateActor = data.actor;
|
||||
this._currentStateReason = data.reason;
|
||||
this._mountBadge(data.state);
|
||||
|
||||
// Trigger first-encounter panel if not yet shown
|
||||
if (!this._getFirstBadgeEncountered() && !this._firstEncounterPanel) {
|
||||
this._firstEncounterPanel = new FirstEncounterPanel(
|
||||
() => this._setFirstBadgeEncountered(),
|
||||
() => this._openDetailsPanel(this._badgeEl)
|
||||
);
|
||||
this._firstEncounterPanel.show(this._badgeEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the VisibilityDetailsPanel for the current state.
|
||||
* @param {HTMLElement|null} triggerEl - Element to return focus to on close
|
||||
*/
|
||||
_openDetailsPanel(triggerEl) {
|
||||
if (!triggerEl) return;
|
||||
// Use stored actor/reason from state change, or fall back to current user
|
||||
const actor = this._currentStateActor ?? this._adapter.users.current?.() ?? null;
|
||||
const panel = new VisibilityDetailsPanel(this._controller);
|
||||
panel.show(this._currentState, actor, this._currentStateReason, triggerEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the player has already seen the first-encounter panel.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_getFirstBadgeEncountered() {
|
||||
return this._adapter.users.current()?.getFlag('video-view-manager', 'firstBadgeEncounter') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the firstBadgeEncounter flag via Foundry user flags.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _setFirstBadgeEncountered() {
|
||||
await this._adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down badge subscriptions and releases AVTileAdapter observers.
|
||||
* Cleans up DOM elements and event listeners.
|
||||
*/
|
||||
teardown() {
|
||||
if (this._stateChangedHandler) {
|
||||
Hooks.off('scrying-pool:stateChanged', this._stateChangedHandler);
|
||||
this._stateChangedHandler = null;
|
||||
}
|
||||
if (this._firstEncounterPanel) {
|
||||
this._firstEncounterPanel.cleanup();
|
||||
this._firstEncounterPanel = null;
|
||||
}
|
||||
if (this._badgeEl) {
|
||||
this._badgeEl.remove();
|
||||
this._badgeEl = null;
|
||||
}
|
||||
this._avTileAdapter.disconnect();
|
||||
this._currentUserId = null;
|
||||
this._currentState = 'active';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user