Video over token, free-form video windows

This commit is contained in:
2026-06-07 22:18:08 +02:00
parent a9dbb9306a
commit a78f3faae2
22 changed files with 2649 additions and 36 deletions
@@ -0,0 +1,199 @@
# Story 5.3: Freeform Layout for Floating Camera Windows
**Status:** ready-for-dev
**Epic:** 5 - Full AV Replacement
**Story Key:** 5-3-freeform-layout-floating-windows
**Created:** 2026-06-07
**Last Updated:** 2026-06-07
**Target Version:** v0.2.0
---
## Story Header
| Field | Value |
|-------|-------|
| **Epic** | 5 - Full AV Replacement |
| **Story ID** | 5.3 |
| **Story Key** | 5-3-freeform-layout-floating-windows |
| **Title** | Freeform Layout for Floating Camera Windows |
| **Status** | ready-for-dev |
| **Priority** | Medium |
| **Assigned Agent** | DEV |
| **Created** | 2026-06-07 |
| **Last Updated** | 2026-06-07 |
---
## Story Requirements
### User Story
**As a** GM using Scrying Pool,
**I want to** select a "Windows" layout mode where each participant's camera feed appears in its own freely draggable and resizable window,
**So that** I can arrange camera feeds anywhere on my screen, resize them independently, and close/hide participants individually.
### Acceptance Criteria
#### AC-1: Layout Selector
**Given** the module is active and GM opens the Director's Board
**When** the GM looks at the layout selector
**Then** there is a 7th layout button labeled "Windows" (icon: `fa-window-restore`)
**And** clicking it switches to freeform mode
**And** the previously active layout (e.g. vertical/horizontal/mosaic) is replaced — freeform is mutually exclusive
#### AC-2: Per-Participant Floating Windows
**Given** the GM has selected the freeform layout
**Then** each visible participant (not hidden from table) gets their own `ApplicationV2` floating window
**And** the GM's own feed is included if `showGMSelfFeed` is enabled and GM has video active
**And** each window displays the participant's webcam feed as an `<video>` element with `object-fit: cover`
**And** each window has the participant's name in the title bar
#### AC-3: Drag and Resize
**Given** there is a freeform camera window
**When** the GM drags the window by its header
**Then** the window moves freely to any screen position
**And** when the GM resizes the window (via the resize handle in the bottom-right corner)
**Then** the new dimensions are applied
**And** both position and size are persisted globally (same positions on all scenes)
#### AC-4: Position Persistence
**Given** the GM has arranged freeform windows at certain positions and sizes
**When** the page is reloaded or the GM re-enters freeform mode
**Then** all windows restore to their last saved positions and sizes
#### AC-5: Cascade for New Participants
**Given** a new participant appears (first time or no saved position)
**Then** their window appears at the top-left of the screen with a cascading offset (50,50 + 30px each step)
**And** the cascade wraps around after ~300px offset so windows don't go off-screen
#### AC-6: Volume Control
**Given** a freeform camera window is open
**Then** there is a volume slider in the window footer (range input, 0 to 1, step 0.05)
**And** moving the slider changes the volume of that window's video element
**And** volume is not persisted (resets to 100% on reload)
#### AC-7: Window Controls
**Given** a freeform camera window is open
**Then** the window title bar has two control buttons: "Spotlight" (star icon) and "Hide" (eye-slash icon)
**And** clicking "Spotlight" toggles a visual glow effect (golden box-shadow/outline) on that window
**And** clicking "Hide" hides that participant from the table (same as hiding from the strip or Directors Board)
**And** clicking the window's close (X) button also hides that participant
#### AC-8: Visual Spotlight (Glow Effect)
**Given** a participant is spotlighted in freeform mode
**Then** their window shows a glowing golden border/outline
**And** all other windows remain unchanged (no resize, no hiding)
**And** clicking the spotlight button again removes the glow
**And** only one participant can be spotlighted at a time (switching moves the glow)
#### AC-9: Mode Switching
**Given** the GM is in freeform mode
**When** the GM selects another layout (vertical/horizontal/mosaic)
**Then** all freeform windows are closed
**And** the strip layout opens/re-renders normally
**Given** the GM is in strip mode (not freeform)
**When** the GM selects the freeform layout
**Then** the strip is closed (not rendered)
**And** freeform windows are created for all visible participants
**And** saved positions are restored
#### AC-10: Sync with Visibility Changes
**Given** a participant is hidden (via Directors Board or strip) while in freeform mode
**Then** their freeform window closes
**And** when they are shown again, their window re-opens at its last saved position
**Given** a participant connects or disconnects while in freeform mode
**Then** windows are created/destroyed accordingly
---
## Implementation Plan
### Files to Create
| File | Purpose |
|------|---------|
| `src/ui/gm/FreeformCameraWindow.js` | Individual ApplicationV2 window per participant |
| `src/ui/gm/FreeformLayoutManager.js` | Orchestrates window creation/destruction/sync |
| `templates/freeform-camera.hbs` | Template for each camera window |
| `styles/components/_freeform-camera.less` | Styles for floating camera windows |
### Files to Modify
| File | Change |
|------|--------|
| `module.js` | Import FreeformLayoutManager, construct in ready, add life cycle hooks |
| `src/ui/gm/DirectorsBoard.js` | Add freeform to `DOCK_LAYOUTS`, handle `set-dock-layout` |
| `templates/directors-board.hbs` | Add freeform layout button |
| `lang/en.json` | Add freeform layout i18n keys |
| `lang/fr.json` | Add freeform layout i18n keys |
| `styles/scrying-pool.less` | Import `_freeform-camera.less` |
### Data Contracts
#### World Setting: `freeformLayout`
```json
{
"windows": {
"userId1": { "left": 100, "top": 200, "width": 320, "height": 300 },
"userId2": { "left": 450, "top": 200, "width": 320, "height": 300 }
}
}
```
Not user-visible in config (`config: false`).
---
## Dev Notes
### Architecture
```
FreeformLayoutManager (adapter, controller, stateStore)
├── init() → registers hooks
├── sync() → reconciles visible users with open windows
├── setSpotlight(userId) → toggles visual glow
├── destroy() → closes all windows
└── Map<userId, FreeformCameraWindow>
└── FreeformCameraWindow ({userId, adapter, manager, position})
├── ApplicationV2 window with resizable: true
├── _onRender() → _attachVideo()
├── _onPosition() → manager._scheduleSave()
├── _onClickWindowControl() → spotlight/hide actions
├── _onClose() → _detachVideo(), hide participant
└── Volume slider → videoElement.volume (session only)
```
### Key Decisions (from user discussions)
| Decision | Choice |
|----------|--------|
| Volume persistence | Session only (default 1.0) |
| GM self-feed | Included if `showGMSelfFeed` + GM has video |
| Spotlight behavior | Visual glow only — no resize/hide of others |
| Default position | Top-left cascade: (50,50) + 30px per window, wrap at ~300px |
| Close button | Hides participant from table |
| Hide button | Hidden via controller.action |
### ApplicationV2 Patterns
- **Window controls** in `window.controls` array → override `_onClickWindowControl(event)`
- **Position tracking** → override `_onPosition(position)` → calls manager's save
- **Fallback base class** for test env → follow same pattern as ScryingPoolStrip
- **Constructor side-effect-free** → no render/init in constructor
- **Template** uses Handlebars `localize` helper for i18n
### Stream Access
- Get stream: `adapter.webrtc.getMediaStreamForUser(userId)`
- Create video: `document.createElement('video')``video.srcObject = stream`
- Autoplay + playsInline + mute (mute only for current user)
- Cleanup: `video.pause()`, `video.srcObject = null`, `video.remove()`
+2
View File
@@ -28,6 +28,8 @@ export default [
foundry: "readonly",
CONFIG: "readonly",
CONST: "readonly",
Token: "readonly",
PIXI: "readonly",
},
},
settings: {
+17 -1
View File
@@ -63,7 +63,8 @@
"horizontal-sm": "Horizontal Small",
"horizontal-md": "Horizontal Large",
"mosaic-sm": "Mosaic Small",
"mosaic-md": "Mosaic Large"
"mosaic-md": "Mosaic Large",
"freeform": "Windows"
},
"widgetWidth": {
"label": "Video Widget Widths",
@@ -207,6 +208,21 @@
"portraitErrorTooLarge": "Image is too large. Please use an image under 5MB.",
"portraitErrorInvalid": "Invalid image file. Please try another."
},
"ActorMapping": {
"title": "Actor-Webcam Mapping",
"label": "Actor-Webcam Mapping",
"hint": "Assign actors to player webcams for the token video overlay feature",
"description": "Assign a webcam feed to an actor's token. When \"Show Webcam Video on Tokens\" is enabled, the token of the selected actor will display the assigned user's webcam feed.",
"noUsers": "No users found.",
"gmBadge": "GM",
"noneOption": "— None —"
},
"Freeform": {
"spotlight": "Spotlight",
"hide": "Hide participant",
"volume": "Volume",
"toggleMic": "Toggle microphone"
},
"Settings": {
"PlayerPrivacyPanel": "Player Privacy Panel",
"PlayerPrivacyPanelLabel": "Control automation effects for your camera",
+17 -1
View File
@@ -63,7 +63,8 @@
"horizontal-sm": "Horizontale petite",
"horizontal-md": "Horizontale grande",
"mosaic-sm": "Mosaïque petite",
"mosaic-md": "Mosaïque grande"
"mosaic-md": "Mosaïque grande",
"freeform": "Fenêtres"
},
"widgetWidth": {
"label": "Largeurs des widgets vidéo",
@@ -207,6 +208,21 @@
"portraitErrorTooLarge": "L'image est trop volumineuse. Veuillez utiliser une image de moins de 5 Mo.",
"portraitErrorInvalid": "Fichier image invalide. Veuillez en essayer un autre."
},
"ActorMapping": {
"title": "Attribution acteur-webcam",
"label": "Attribution acteur-webcam",
"hint": "Attribuez des acteurs aux webcams des joueurs pour la superposition vidéo sur les tokens",
"description": "Associez un flux webcam au token d'un acteur. Quand \"Afficher la webcam sur les tokens\" est activé, le token de l'acteur sélectionné affichera la webcam de l'utilisateur assigné.",
"noUsers": "Aucun utilisateur trouvé.",
"gmBadge": "MJ",
"noneOption": "— Aucun —"
},
"Freeform": {
"spotlight": "Focus",
"hide": "Cacher le participant",
"volume": "Volume",
"toggleMic": "Activer/désactiver le micro"
},
"Settings": {
"PlayerPrivacyPanel": "Panneau de confidentialité du joueur",
"PlayerPrivacyPanelLabel": "Contrôlez les effets d'automatisation sur votre caméra",
+98 -6
View File
@@ -34,16 +34,19 @@ import { DirectorsBoard } from './src/ui/gm/DirectorsBoard.js';
import { ConfirmationBar } from './src/ui/gm/ConfirmationBar.js';
import { PlayerPrivacyPanelMenu, initPlayerPrivacyPanelMenu } from './src/ui/player/PlayerPrivacyPanelMenu.js';
import { initGMPlayerPrivacySelector } from './src/ui/gm/GMPlayerPrivacySelector.js';
import { GMActorMappingPanel, initGMActorMappingPanel } from './src/ui/gm/GMActorMappingPanel.js';
import { ScryingPoolCameraViews, initScryingPoolCameraViews } from './src/ui/shared/ScryingPoolCameraViews.js';
import { TokenVideoOverlay } from './src/ui/shared/TokenVideoOverlay.js';
import { FreeformLayoutManager } from './src/ui/gm/FreeformLayoutManager.js';
import { ScryingPoolSettings } from './src/ui/gm/ScryingPoolSettings.js';
import { SOCKET_EVENTS } from './src/contracts/socket-message.js';
// Factory function to create ScryingPoolSettings with roleRenderer dependency
// Factory function to create ScryingPoolSettings with roleRenderer and adapter dependencies
// Returns a class constructor (not a function) that Foundry can use for registerMenu
function initScryingPoolSettings(roleRendererRef) {
function initScryingPoolSettings(roleRendererRef, adapterRef) {
return class extends ScryingPoolSettings {
constructor(options = {}) {
super(roleRendererRef, options);
super(roleRendererRef, adapterRef, options);
}
};
}
@@ -65,6 +68,8 @@ let visibilityBadge;
let notificationBus;
let directorsBoard;
let confirmationBar;
let tokenVideoOverlay;
let freeformLayoutManager;
/** @type {boolean} Flag to prevent duplicate scene control button addition */
let directorsBoardButtonAdded = false;
@@ -119,6 +124,40 @@ Hooks.once("init", () => {
hint: "When enabled, the GM's own camera feed is shown in the Scrying Pool strip.",
});
// Story 5.X: Token Video Overlay — completely optional
adapter.settings.register("showVideoOnTokens", {
scope: "world",
config: true,
type: Boolean,
default: false,
name: "Show Webcam Video on Tokens",
hint: "When enabled, each player's webcam feed replaces their character token image on the active scene, masked to a circle. The video strip is automatically hidden to free screen space.",
onChange: (val) => {
tokenVideoOverlay?.[val ? 'enable' : 'disable']();
if (val) {
roleRenderer?.closeStrip();
} else {
roleRenderer?.openStrip();
}
},
});
// Story 5.X: GM actor-to-user mapping for token video overlay
adapter.settings.register("userActorMapping", {
scope: "world",
config: false,
type: Object,
default: {},
});
// Story 5.3: Freeform layout — floating camera window positions
adapter.settings.register("freeformLayout", {
scope: "world",
config: false,
type: Object,
default: { windows: {} },
});
// Story 2.1: per-user notification verbosity preference (client-scoped)
adapter.settings.register("notificationVerbosity", {
scope: "client",
@@ -150,7 +189,16 @@ Hooks.once("init", () => {
config: false,
type: String,
default: "vertical-sm",
onChange: () => roleRenderer?.rerenderStrip(),
onChange: (val) => {
if (val === 'freeform') {
roleRenderer?.closeStrip();
freeformLayoutManager?.init();
freeformLayoutManager?.sync();
} else {
freeformLayoutManager?.destroy();
roleRenderer?.rerenderStrip();
}
},
});
// Per-user size toggle — client-scoped so each user can expand/collapse independently.
@@ -376,6 +424,13 @@ Hooks.once("ready", () => {
// Story 2.1: NotificationBus — runs for all clients (GM and players)
notificationBus = new NotificationBus(adapter);
notificationBus.init();
// Story 5.X: Token Video Overlay — optional, all clients (GM sees all players)
if (adapter.webrtc) {
tokenVideoOverlay = new TokenVideoOverlay(adapter);
tokenVideoOverlay.init();
}
// Story 3.1: Register socket listener for preset apply echo (all clients receive)
// Note: In Foundry, socket messages are automatically broadcast to all clients.
// The GM emits PRESET_APPLIED, and all clients (including GM) receive it.
@@ -407,13 +462,37 @@ Hooks.once("ready", () => {
directorsBoard = new DirectorsBoard(stateStore, scryingPoolController, adapter, scenePresetManager, playerPrivacyManager);
directorsBoard.init();
window.directorsBoard = directorsBoard;
// Story 5.3: Freeform Layout Manager
const SettingsCls = initScryingPoolSettings(roleRenderer, adapter);
let settingsPanel = null;
freeformLayoutManager = new FreeformLayoutManager(adapter, scryingPoolController, stateStore, {
onOpenSettings: () => {
if (settingsPanel && settingsPanel.rendered) {
settingsPanel.close();
settingsPanel = null;
} else {
settingsPanel = new SettingsCls();
settingsPanel.render(true);
}
},
});
// If initial layout is freeform, switch immediately
const initialLayout = adapter.settings?.get?.('dockLayout');
if (initialLayout === 'freeform') {
roleRenderer.closeStrip();
freeformLayoutManager.init();
freeformLayoutManager.sync();
}
}
// Inject Scrying Pool deps into our camera views replacement (all clients)
// Directors Board reference is GM-only — players get null so _onConfigure is a no-op
initScryingPoolCameraViews(
adapter.users.isGM() ? directorsBoard : null,
stateStore
stateStore,
adapter
);
// Pre-load participant-card as a Handlebars partial for directors-board
@@ -428,6 +507,9 @@ Hooks.once("ready", () => {
}
})();
// Story 5.X: Initialize GMActorMappingPanel with DI dependencies
initGMActorMappingPanel(adapter);
// Story 4.1: Initialize PlayerPrivacyPanelMenu with DI dependencies
// Story 4.2: Pass portraitFallbackHandler for portrait selection
initPlayerPrivacyPanelMenu(adapter, playerPrivacyManager, portraitFallbackHandler);
@@ -448,6 +530,16 @@ Hooks.once("ready", () => {
restricted: false,
});
// Story 5.X: Register Actor-Webcam Mapping settings menu (GM only)
game.settings.registerMenu('scrying-pool', 'actorMapping', {
name: 'SCRYING_POOL.ActorMapping.title',
label: 'SCRYING_POOL.ActorMapping.label',
hint: 'SCRYING_POOL.ActorMapping.hint',
icon: 'fa-solid fa-address-card',
type: GMActorMappingPanel,
restricted: true,
});
// Register ScryingPoolSettings in module settings
// Provides button to reopen the strip when user closes it
game.settings.registerMenu('scrying-pool', 'stripSettings', {
@@ -455,7 +547,7 @@ Hooks.once("ready", () => {
label: 'SCRYING_POOL.Settings.Title',
hint: 'SCRYING_POOL.Settings.Hint',
icon: 'fa-solid fa-cog',
type: initScryingPoolSettings(roleRenderer),
type: initScryingPoolSettings(roleRenderer, adapter),
restricted: true, // GM only
});
+6
View File
@@ -209,6 +209,12 @@ export class FoundryAdapter {
},
};
/** Actors surface — wraps game.actors. */
this.actors = {
all: () => Array.from(g.actors ?? []),
get: (id) => g.actors?.get(id) ?? null,
};
/** Scenes surface — wraps game.scenes. */
this.scenes = {
/**
+1
View File
@@ -371,6 +371,7 @@ export class DirectorsBoard extends _AppBase {
{ key: 'horizontal-md', icon: 'fa-grip-horizontal', size: 'L', sepAfter: true },
{ key: 'mosaic-sm', icon: 'fa-border-all', size: 'S', sepAfter: false },
{ key: 'mosaic-md', icon: 'fa-border-all', size: 'L', sepAfter: false },
{ key: 'freeform', icon: 'fa-window-restore', size: 'W', sepAfter: false },
];
const dockLayouts = DOCK_LAYOUTS.map(l => ({
...l,
+250
View File
@@ -0,0 +1,250 @@
// @ts-nocheck
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 = {}; }
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() {}
_onPosition() {}
setPosition(p) { Object.assign(this.position, p); }
};
/**
* Individual floating camera window for the freeform layout.
* One instance per participant, fully resizable and draggable.
*/
export class FreeformCameraWindow extends _AppBase {
static DEFAULT_OPTIONS = {
classes: ['scrying-pool', 'freeform-camera'],
window: {
icon: 'fas fa-video',
resizable: true,
controls: [
{ action: 'spotlight', icon: 'fas fa-star', title: 'SCRYING_POOL.Freeform.spotlight' },
{ action: 'hide', icon: 'fas fa-eye-slash', title: 'SCRYING_POOL.Freeform.hide' },
],
},
position: { width: 320, height: 300 },
};
static PARTS = {
body: {
template: 'modules/scrying-pool/templates/freeform-camera.hbs',
},
};
/**
* @param {object} params
* @param {string} params.userId
* @param {object} params.adapter - FoundryAdapter instance
* @param {object} params.manager - FreeformLayoutManager instance
* @param {{left:number, top:number, width:number, height:number}} params.position
*/
constructor({ userId, adapter, manager, position }) {
const user = adapter.users.get(userId);
super({
position: { ...position },
window: { title: user?.name ?? userId },
});
this._userId = userId;
this._adapter = adapter;
this._manager = manager;
this._videoElement = null;
this._volume = 1.0;
this._isSpotlight = false;
}
/** @returns {string} */
get userId() { return this._userId; }
/** @param {boolean} val */
set spotlight(val) {
this._isSpotlight = val;
if (this.element) {
this.element.classList.toggle('is-spotlight', val);
}
}
/** @returns {boolean} */
get spotlight() { return this._isSpotlight; }
/** @inheritdoc */
async _prepareContext() {
const user = this._adapter.users.get(this._userId);
const stream = this._adapter.webrtc?.getMediaStreamForUser?.(this._userId);
const hasAudio = stream?.getAudioTracks()?.length > 0;
const audioMuted = hasAudio
? stream.getAudioTracks().every(t => !t.enabled)
: true;
return {
userId: this._userId,
userName: user?.name ?? this._userId,
hasAudio,
audioMuted,
volume: this._volume,
};
}
/** @inheritdoc */
_onRender(context, options) {
super._onRender?.(context, options);
this._attachVideo();
if (this._isSpotlight) {
this.element?.classList.add('is-spotlight');
}
const volumeSlider = this.element?.querySelector('.freeform-camera__volume');
if (volumeSlider) {
volumeSlider.value = String(this._volume);
volumeSlider.addEventListener('input', (e) => {
this._volume = parseFloat(e.target.value);
if (this._videoElement) {
this._videoElement.volume = this._volume;
}
});
}
const micBtn = this.element?.querySelector('[data-action="toggle-mic"]');
if (micBtn) {
micBtn.addEventListener('click', () => this._toggleMic());
}
const videoContainer = this.element?.querySelector('.freeform-camera__video-container');
if (videoContainer) {
videoContainer.addEventListener('dblclick', (e) => {
e.stopPropagation();
this._manager?.openSettings?.();
});
}
}
/**
* Attaches the user's webcam stream as a <video> element.
* Called from _onRender.
*/
_attachVideo() {
if (!this._adapter?.webrtc?.getMediaStreamForUser) return;
if (!this.element) return;
const container = this.element.querySelector('.freeform-camera__video-container');
if (!container) return;
const existing = container.querySelector('video');
if (existing) existing.remove();
const stream = this._adapter.webrtc.getMediaStreamForUser(this._userId);
if (!stream) return;
const video = document.createElement('video');
video.srcObject = stream;
video.autoplay = true;
video.playsInline = true;
video.muted = this._adapter.users.current?.()?.id === this._userId;
video.className = 'freeform-camera__video-element';
video.addEventListener('error', () => {
console.warn('[ScryingPool] Freeform video error for user:', this._userId);
});
container.appendChild(video);
this._videoElement = video;
video.volume = this._volume;
}
/**
* Detaches and cleans up the video element.
*/
_detachVideo() {
if (this._videoElement) {
this._videoElement.pause();
this._videoElement.srcObject = null;
this._videoElement.remove();
this._videoElement = null;
}
const container = this.element?.querySelector('.freeform-camera__video-container');
if (container) {
const els = container.querySelectorAll('video');
els.forEach(v => { v.pause(); v.srcObject = null; v.remove(); });
}
}
/**
* Toggles the local microphone mute state for this participant's stream.
*/
_toggleMic() {
const stream = this._adapter.webrtc?.getMediaStreamForUser?.(this._userId);
if (!stream) return;
const tracks = stream.getAudioTracks();
if (tracks.length === 0) return;
const allMuted = tracks.every(t => !t.enabled);
tracks.forEach(t => { t.enabled = allMuted; });
const btn = this.element?.querySelector('[data-action="toggle-mic"] i');
if (btn) {
btn.className = allMuted ? 'fas fa-microphone' : 'fas fa-microphone-slash';
}
}
/** @inheritdoc */
_onClickWindowControl(event) {
const btn = event.currentTarget;
const action = btn.dataset.action;
if (action === 'spotlight') {
this._manager.setSpotlight(this._userId);
} else if (action === 'hide') {
this._hideParticipant();
}
}
/**
* Hides this participant from the table via the manager.
* @returns {Promise<void>}
*/
async _hideParticipant() {
await this._manager?.hideParticipant(this._userId);
}
/** @inheritdoc */
_onPosition(position) {
super._onPosition?.(position);
this._manager?._scheduleSave();
}
/** @inheritdoc */
async _onClose(options) {
this._detachVideo();
if (!options?._fromManager) {
await this._hideParticipant();
}
await super._onClose?.(options);
}
/**
* Synchronously removes this window from the DOM.
* Bypasses the async ApplicationV2 close lifecycle for reliable cleanup.
* Does NOT hide the participant — used by FreeformLayoutManager on layout switch.
*/
removeDOM() {
this._detachVideo();
if (this.element) {
this.element.remove();
}
this._rendered = false;
}
}
+264
View File
@@ -0,0 +1,264 @@
// @ts-nocheck
import { generateOpId } from '../../utils/uuid.js';
import { FreeformCameraWindow } from './FreeformCameraWindow.js';
/**
* Orchestrates the freeform layout: creates/destroys FreeformCameraWindow instances
* based on participant visibility, saves/restores positions, and manages spotlight.
*/
export class FreeformLayoutManager {
/**
* @param {object} adapter - FoundryAdapter instance
* @param {object} controller - ScryingPoolController instance
* @param {object} stateStore - StateStore instance
* @param {object} [options] - Extra options
* @param {Function} [options.onOpenSettings] - Callback to open the settings panel
*/
constructor(adapter, controller, stateStore, options = {}) {
this._adapter = adapter;
this._controller = controller;
this._stateStore = stateStore;
this._onOpenSettings = options.onOpenSettings;
/** @type {Map<string, FreeformCameraWindow>} */
/** @type {Map<string, FreeformCameraWindow>} */
this._windows = new Map();
this._spotlightUserId = null;
this._cascadeIndex = 0;
this._saveTimer = null;
this._initialized = false;
/** @type {Array<{event: string, handler: Function}>} */
this._hookHandlers = [];
}
/**
* Registers hooks for sync on state/user changes.
* Safe to call multiple times — guards against double init.
*/
init() {
if (this._initialized) return;
this._initialized = true;
const add = (event, handler) => {
this._adapter.hooks.on(event, handler);
this._hookHandlers.push({ event, handler });
};
add('scrying-pool:stateChanged', () => this.sync());
add('updateUser', () => this.sync());
add('userConnected', () => {
setTimeout(() => this.sync(), 1500);
});
}
/**
* Reconciles visible participants with open windows.
* Creates windows for newly visible users, closes for hidden/disconnected.
*/
sync() {
if (!this._initialized) return;
if (!this._adapter.users.isGM()) return;
const users = this._adapter.users.all();
const showGMSelfFeed = this._adapter.settings?.get?.('showGMSelfFeed') ?? true;
const currentUserId = this._adapter.users.current?.()?.id;
const visibleUserIds = users
.filter(u => {
if (u.id === currentUserId && !showGMSelfFeed) return false;
const state = this._stateStore.getState(u.id);
return state !== 'hidden' && state !== 'ghost' && state !== 'offline';
})
.map(u => u.id);
const visibleSet = new Set(visibleUserIds);
const currentSet = new Set(this._windows.keys());
// Close windows for users no longer visible
for (const userId of currentSet) {
if (!visibleSet.has(userId)) {
this._destroyWindow(userId);
}
}
// Create windows for newly visible users
for (const userId of visibleUserIds) {
if (!currentSet.has(userId)) {
this._createWindow(userId);
}
}
// Re-apply spotlight if the spotlighted user is still visible
if (this._spotlightUserId && !visibleSet.has(this._spotlightUserId)) {
this._spotlightUserId = null;
}
}
/**
* Creates a FreeformCameraWindow for a user.
* @param {string} userId
*/
_createWindow(userId) {
const saved = this._loadSavedPosition(userId);
const position = saved ?? this._cascadePosition();
const win = new FreeformCameraWindow({
userId,
adapter: this._adapter,
manager: this,
position,
});
this._windows.set(userId, win);
if (this._spotlightUserId === userId) {
win.spotlight = true;
}
win.render(true);
this._cascadeIndex++;
}
/**
* Closes and removes a FreeformCameraWindow for a user.
* @param {string} userId
*/
_destroyWindow(userId) {
const win = this._windows.get(userId);
if (!win) return;
win.removeDOM();
this._windows.delete(userId);
}
/**
* Returns a cascading position for new windows without saved positions.
* @returns {{left: number, top: number, width: number, height: number}}
*/
_cascadePosition() {
const base = 50;
const step = 30;
const maxOffset = 300;
const offset = (this._cascadeIndex * step) % (maxOffset + step);
return {
left: base + offset,
top: base + offset,
width: 320,
height: 300,
};
}
/**
* Loads a saved window position for a user from the freeformLayout setting.
* @param {string} userId
* @returns {{left:number, top:number, width:number, height:number}|null}
*/
_loadSavedPosition(userId) {
try {
const data = this._adapter.settings?.get?.('freeformLayout');
if (data?.windows?.[userId]) {
const { left, top, width, height } = data.windows[userId];
if (left != null && top != null) {
return { left, top, width: width ?? 320, height: height ?? 300 };
}
}
} catch (_) {}
return null;
}
/**
* Toggles spotlight on a user.
* If already spotlighted, removes spotlight. Otherwise sets it.
* @param {string} userId
*/
setSpotlight(userId) {
if (this._spotlightUserId === userId) {
// Un-spotlight
const win = this._windows.get(userId);
if (win) win.spotlight = false;
this._spotlightUserId = null;
} else {
// Remove current spotlight
if (this._spotlightUserId) {
const old = this._windows.get(this._spotlightUserId);
if (old) old.spotlight = false;
}
// Set new spotlight
const win = this._windows.get(userId);
if (win) win.spotlight = true;
this._spotlightUserId = userId;
}
}
/**
* Hides a participant from the table via the controller.
* Called when a freeform window's hide button or close button is triggered.
* @param {string} userId
*/
hideParticipant(userId) {
const opId = generateOpId();
const baseRevision = this._controller?.getRevision?.(userId) ?? 0;
this._controller?.action('freeform', userId, 'hidden', opId, baseRevision);
}
/**
* Opens the settings/config panel.
* Called when the GM double-clicks a freeform camera window.
*/
openSettings() {
this._onOpenSettings?.();
}
/**
* Schedules a debounced save of all window positions.
*/
_scheduleSave() {
if (this._saveTimer) {
clearTimeout(this._saveTimer);
}
this._saveTimer = setTimeout(() => this._savePositions(), 500);
}
/**
* Persists all window positions to the freeformLayout world setting.
*/
_savePositions() {
const windows = {};
for (const [userId, win] of this._windows) {
const pos = win.position;
windows[userId] = {
left: pos.left,
top: pos.top,
width: pos.width,
height: pos.height,
};
}
this._adapter.settings?.set?.('freeformLayout', { windows }).catch(err => {
console.warn('[ScryingPool] Failed to save freeform layout positions:', err);
});
}
/**
* Closes all windows and cleans up hooks. Safe to call multiple times.
*/
destroy() {
for (const [userId] of this._windows) {
this._destroyWindow(userId);
}
this._windows.clear();
this._spotlightUserId = null;
this._cascadeIndex = 0;
for (const { event, handler } of this._hookHandlers) {
try { this._adapter.hooks.off(event, handler); } catch (_) {}
}
this._hookHandlers = [];
this._initialized = false;
if (this._saveTimer) {
clearTimeout(this._saveTimer);
this._saveTimer = null;
}
}
}
+124
View File
@@ -0,0 +1,124 @@
/**
* GMActorMappingPanel — GM settings submenu for assigning actors to users
* for the Token Video Overlay feature.
*
* The GM maps an actor to a user's webcam feed. When "Show Webcam Video on
* Tokens" is enabled, any token of the mapped actor will display that user's
* webcam instead of the token icon.
*
* Extends ApplicationV2 via HandlebarsApplicationMixin.
* Uses module-level _adapter for DI (same pattern as PlayerPrivacyPanelMenu).
*
* @module ui/gm/GMActorMappingPanel
*/
/** @type {import('../../foundry/FoundryAdapter.js').FoundryAdapter|null} */
let _adapter = null;
/**
* Initialize static adapter reference. Called once from module.js ready hook.
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
*/
export function initGMActorMappingPanel(adapter) {
_adapter = adapter;
}
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 = {};
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() {}
_onPosition() {}
};
/**
* GM Actor Mapping Panel — assign actors to user webcams for token overlay.
*/
export class GMActorMappingPanel extends _AppBase {
static DEFAULT_OPTIONS = {
id: 'scrying-pool-actor-mapping',
classes: ['scrying-pool', 'actor-mapping'],
window: {
title: 'SCRYING_POOL.ActorMapping.title',
resizable: false,
width: 450,
height: 'auto',
},
position: {},
};
static PARTS = {
form: {
template: 'modules/scrying-pool/templates/actor-mapping.hbs',
},
};
async _prepareContext() {
const mapping = _adapter?.settings?.get('userActorMapping') ?? {};
const selectedId = (uid) => mapping[uid] ?? '';
const sortedActors = _adapter.actors.all()
.map(a => ({ id: a.id, name: a.name }))
.sort((a, b) => a.name.localeCompare(b.name));
const users = _adapter.users.all()
.map(u => ({
id: u.id,
name: u.name,
isGM: u.isGM,
avatar: u.avatar ?? 'icons/svg/mystery-man.svg',
actors: sortedActors.map(a => ({
id: a.id,
name: a.name,
selected: a.id === selectedId(u.id),
})),
}))
.sort((a, b) => {
if (a.isGM && !b.isGM) return -1;
if (!a.isGM && b.isGM) return 1;
return a.name.localeCompare(b.name);
});
return {
hasNoUsers: users.length === 0,
users,
};
}
_onRender(context, options) {
if (this._formHandlerAttached) return;
this._formHandlerAttached = true;
const form = this.element?.querySelector('form');
if (!form) return;
form.addEventListener('change', (event) => {
const select = event.target;
if (select.tagName !== 'SELECT') return;
const mapping = { ...(_adapter?.settings?.get('userActorMapping') ?? {}) };
if (select.value) {
mapping[select.name] = select.value;
} else {
delete mapping[select.name];
}
_adapter?.settings?.set('userActorMapping', mapping).catch(err => {
console.error('[ScryingPool] Failed to save actor mapping:', err);
});
});
}
}
+65 -22
View File
@@ -43,18 +43,39 @@ export class ScryingPoolSettings extends _AppBase {
};
/**
* @param {object} options - Application options
* @param {object} roleRenderer - The role renderer instance to access openStrip/closeStrip
* @param {object} adapter - FoundryAdapter instance
* @param {object} options - Application options
*/
constructor(roleRenderer, options = {}) {
constructor(roleRenderer, adapter, options = {}) {
super(options);
this._roleRenderer = roleRenderer;
this._adapter = adapter;
this._changingLayout = false;
}
/** @inheritdoc */
async _prepareContext(options) {
const currentDockLayout = this._adapter?.settings?.get?.('dockLayout') ?? 'vertical-sm';
const DOCK_LAYOUTS = [
{ key: 'vertical-sm', icon: 'fa-grip-vertical', size: 'S', sepAfter: false },
{ key: 'vertical-md', icon: 'fa-grip-vertical', size: 'L', sepAfter: true },
{ key: 'horizontal-sm', icon: 'fa-grip-horizontal', size: 'S', sepAfter: false },
{ key: 'horizontal-md', icon: 'fa-grip-horizontal', size: 'L', sepAfter: true },
{ key: 'mosaic-sm', icon: 'fa-border-all', size: 'S', sepAfter: false },
{ key: 'mosaic-md', icon: 'fa-border-all', size: 'L', sepAfter: false },
{ key: 'freeform', icon: 'fa-window-restore', size: 'W', sepAfter: false },
];
const dockLayouts = DOCK_LAYOUTS.map(l => ({
...l,
isActive: l.key === currentDockLayout,
label: l.key,
}));
return {
hasStrip: this._roleRenderer?._strip?.rendered ?? false,
dockLayouts,
currentDockLayout,
};
}
@@ -62,28 +83,50 @@ export class ScryingPoolSettings extends _AppBase {
async _onRender(context, options) {
super._onRender(context, options);
// Add click handler for window close button
const windowCloseBtn = this.element.querySelector('[data-action="close"]');
if (windowCloseBtn) {
windowCloseBtn.addEventListener('click', () => this.close());
}
// Prevent re-entrant layout changes
if (this._changingLayout) return;
// Add click handler for reopen button
const reopenBtn = this.element.querySelector('[data-action="reopen-strip"]');
if (reopenBtn) {
reopenBtn.addEventListener('click', () => {
this._roleRenderer?.openStrip();
this.close();
});
}
// Delegate all button clicks via a single listener
const handler = (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
// Add click handler for close strip button
const closeBtn = this.element.querySelector('[data-action="close-strip"]');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
this._roleRenderer?.closeStrip();
this.close();
});
switch (btn.dataset.action) {
case 'close':
this.close();
break;
case 'reopen-strip':
this._roleRenderer?.openStrip();
this.close();
break;
case 'close-strip':
this._roleRenderer?.closeStrip();
this.close();
break;
case 'set-dock-layout':
this._onSetDockLayout(btn.dataset.layout);
break;
}
};
this.element.addEventListener('click', handler);
}
/**
* Changes the dock layout via the adapter setting.
* @param {string} layoutKey
*/
async _onSetDockLayout(layoutKey) {
if (this._changingLayout) return;
this._changingLayout = true;
try {
await this._adapter?.settings?.set?.('dockLayout', layoutKey);
await this._adapter?.settings?.set?.('dockLayoutExpanded', '');
this.close();
} catch (err) {
console.error('[ScryingPool] Failed to set dockLayout:', err);
} finally {
this._changingLayout = false;
}
}
+2 -2
View File
@@ -180,8 +180,8 @@ export class ScryingPoolStrip extends _AppBase {
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 });
if (typeof this.setPosition === 'function') {
this.setPosition({ left: saved.left, top: saved.top });
}
}
}
+133 -3
View File
@@ -1,12 +1,15 @@
/**
* ScryingPoolCameraViews — replaces Foundry's CameraViews as CONFIG.ui.webrtc.
*
* Two responsibilities:
* Three responsibilities:
* 1. Redirect the "configure" camera action to the Scrying Pool Directors Board
* instead of Foundry's native AVConfig dialog.
* 2. Inject the Scrying Pool visibility state (sp-cam-hidden) into each user's
* camera context so the dock reflects the same hidden/active state as the
* module's state machine.
* 3. Monitor local video streams and auto-recover from browser-level mute/ended
* events that cause gray/black video in the player's self-view while other
* peers remain unaffected. (Bug fix: stream health monitoring)
*
* Set as CONFIG.ui.webrtc in the 'init' hook (before Foundry instantiates ui.webrtc).
* Dependencies are injected after 'ready' via initScryingPoolCameraViews().
@@ -23,11 +26,16 @@ function _getCameraViewsBase() {
return class _FallbackCameraViews {
static DEFAULT_OPTIONS = {};
static PARTS = {};
constructor(options = {}) { this.options = options; }
constructor(options = {}) {
this.options = options;
this._healthCheckInterval = null;
}
async render() {}
async close() {}
_prepareUserContext(_id) { return {}; }
_onConfigure() {}
_onRender() {}
getUserVideoElement(_userId) { return null; }
};
}
@@ -37,17 +45,25 @@ let _directorsBoard = null;
/** @type {object|null} StateStore instance — set via initScryingPoolCameraViews */
let _stateStore = null;
/** @type {object|null} FoundryAdapter instance — set via initScryingPoolCameraViews */
let _adapter = null;
/**
* Inject module dependencies. Called from module.js after 'ready' resolves.
* @param {object|null} directorsBoard - The singleton DirectorsBoard (GM only, else null)
* @param {object} stateStore - The module StateStore
* @param {object|null} adapter - The FoundryAdapter instance (for webrtc surface)
*/
export function initScryingPoolCameraViews(directorsBoard, stateStore) {
export function initScryingPoolCameraViews(directorsBoard, stateStore, adapter) {
_directorsBoard = directorsBoard;
_stateStore = stateStore;
_adapter = adapter;
}
export class ScryingPoolCameraViews extends _getCameraViewsBase() {
/** @type {number|null} */
_healthCheckInterval = null;
/**
* Intercept the configure camera button.
* Opens the Scrying Pool Directors Board instead of Foundry's AVConfig dialog.
@@ -80,4 +96,118 @@ export class ScryingPoolCameraViews extends _getCameraViewsBase() {
if (ctx && spState === 'hidden') ctx.spHidden = true;
return ctx;
}
/**
* Start periodic stream health monitoring after the dock is first rendered.
* Only starts once — guarded by _healthCheckInterval.
* @override
*/
_onRender(context, options) {
super._onRender?.(context, options);
if (_adapter?.webrtc && this._healthCheckInterval === null) {
this._healthCheckInterval = setInterval(
() => this._checkVideoStreamHealth(),
30000
);
}
}
/**
* Clean up health check interval when the view is closed.
* @override
*/
_onClose() {
if (this._healthCheckInterval !== null) {
clearInterval(this._healthCheckInterval);
this._healthCheckInterval = null;
}
super._onClose?.();
}
/**
* Periodic health check on all video tiles in the camera dock.
* Detects muted/ended tracks and re-acquires the MediaStream to
* recover from gray/black video without requiring a page reload.
* Same pattern as ScryingPoolStrip._checkVideoStreamHealth().
*/
_checkVideoStreamHealth() {
if (!this.element) return;
try {
const userIds = _adapter?.webrtc?.getConnectedUsers?.() ?? [];
for (const userId of userIds) {
const videoEl = this.getUserVideoElement(userId);
if (!videoEl) continue;
const stream = videoEl.srcObject;
if (!(stream instanceof MediaStream)) continue;
const videoTracks = stream.getVideoTracks();
// Track permanently ended → re-acquire stream
if (videoTracks.some(t => t.readyState === 'ended')) {
this._refreshUserVideo(userId);
continue;
}
// Track muted at browser level (tab backgrounded, camera contention)
if (videoTracks.some(t => t.muted)) {
this._scheduleMuteCheck(userId, stream);
continue;
}
// Video element not producing frames despite having a stream
if (videoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
this._refreshUserVideo(userId);
}
}
} catch (err) {
console.error('[ScryingPool] CameraViews stream health check failed:', err);
}
}
/**
* Debounced mute check: waits 3s then refreshes if the track remains muted.
* Avoids unnecessary re-attach on transient browser-level mutes.
* @param {string} userId
* @param {MediaStream} stream - Original stream to detect replacement
*/
_scheduleMuteCheck(userId, stream) {
const key = `_muteTimer_${userId}`;
if (this[key] !== undefined) return;
this[key] = setTimeout(() => {
this[key] = undefined;
try {
const videoEl = this.getUserVideoElement(userId);
if (!videoEl || videoEl.srcObject !== stream) return;
if (stream.getVideoTracks().some(t => t.muted)) {
this._refreshUserVideo(userId);
}
} catch (err) {
console.error('[ScryingPool] CameraViews mute check failed:', err);
}
}, 3000);
}
/**
* Re-acquires and re-attaches a user's MediaStream on the video element.
* Resetting srcObject forces the browser to restart the media pipeline.
* @param {string} userId
*/
_refreshUserVideo(userId) {
try {
const stream = _adapter?.webrtc?.getMediaStreamForUser?.(userId);
if (!(stream instanceof MediaStream)) return;
const videoEl = this.getUserVideoElement(userId);
if (!videoEl) return;
videoEl.srcObject = null;
videoEl.srcObject = stream;
} catch (err) {
console.error('[ScryingPool] CameraViews refresh video failed:', err);
}
}
}
+282
View File
@@ -0,0 +1,282 @@
// @ts-nocheck
/**
* TokenVideoOverlay — Optionally overlays player webcam video on their character
* tokens on the active scene. 100% optional, toggled via world setting.
*
* Architecture:
* - Replaces token.mesh texture with a video texture from the user's webcam
* - Patches Token.prototype.refresh to re-apply after redraws
* - Subscribes to canvasReady, createToken, deleteToken, updateToken hooks
* - When disabled, restores original texture on all meshes
*
* @module ui/shared/TokenVideoOverlay
*/
export class TokenVideoOverlay {
/**
* @param {object} adapter - FoundryAdapter instance
*/
constructor(adapter) {
this._adapter = adapter;
/** @type {Map<string, { videoEl: HTMLVideoElement, origTexture: PIXI.Texture, userId: string, canvas?: HTMLCanvasElement, ctx?: CanvasRenderingContext2D, pixiTexture?: PIXI.Texture, rafId?: number }>} */
this._overlays = new Map();
/** @type {Set<string>} */
this._pending = new Set();
/** @type {Function|null} */
this._origRefresh = null;
/** @type {boolean} */
this._enabled = false;
}
init() {
this._enabled = this._adapter.settings.get('showVideoOnTokens') ?? false;
const TokenClass = foundry?.canvas?.placeables?.Token ?? globalThis.Token;
if (!TokenClass?.prototype?.refresh) {
console.warn('[ScryingPool] TokenVideoOverlay: Token class not found');
}
if (TokenClass?.prototype?.refresh) {
this._origRefresh = TokenClass.prototype.refresh;
const self = this;
TokenClass.prototype.refresh = function (...args) {
const result = self._origRefresh.apply(this, args);
if (self._enabled) self._onTokenRefreshed(this);
return result;
};
}
Hooks.on('canvasReady', () => { if (this._enabled) this.syncAll(); });
Hooks.on('createToken', (doc) => { if (this._enabled) this._onCreateToken(doc); });
Hooks.on('deleteToken', (doc) => { if (this._enabled) this._detach(doc.id); });
Hooks.on('updateToken', (doc) => { if (this._enabled) this._onUpdateToken(doc); });
if (this._enabled) this.syncAll();
}
enable() {
this._enabled = true;
this.syncAll();
}
disable() {
this._enabled = false;
this._cleanupAll();
}
syncAll() {
if (!canvas?.tokens?.placeables) {
return;
}
const processed = new Set();
for (const token of canvas.tokens.placeables) {
const key = this._tokenKey(token);
processed.add(key);
if (!this._overlays.has(key) && !this._pending.has(key)) this._attach(token);
}
for (const [key] of this._overlays) {
if (!processed.has(key)) this._detachByKey(key);
}
}
_getOwningUserId(token) {
const actor = token.document?.actor;
if (!actor) return null;
const mapping = this._adapter.settings.get('userActorMapping') ?? {};
for (const [userId, actorId] of Object.entries(mapping)) {
if (actorId === actor.id) {
const user = this._adapter.users.get(userId);
if (user && user.active) return userId;
}
}
for (const user of this._adapter.users.all()) {
if (user.isGM || !user.active) continue;
if (typeof actor.testUserPermission === 'function') {
if (actor.testUserPermission(user, CONST.DOCUMENT_PERMISSION_LEVELS.OWNER)) {
return user.id;
}
}
}
return null;
}
_tokenKey(token) {
return `${canvas.scene?.id ?? '?'}.${token.id}`;
}
_attach(token) {
if (!token?.mesh) return;
const key = this._tokenKey(token);
if (this._pending.has(key)) return;
this._pending.add(key);
const userId = this._getOwningUserId(token);
if (!userId) {
this._pending.delete(key);
return;
}
const stream = this._adapter.webrtc?.getMediaStreamForUser?.(userId);
if (!stream?.getVideoTracks().length) {
this._pending.delete(key);
return;
}
const videoEl = document.createElement('video');
videoEl.srcObject = stream;
videoEl.autoplay = true;
videoEl.muted = true;
videoEl.playsInline = true;
videoEl.setAttribute('aria-hidden', 'true');
videoEl.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;';
document.body.appendChild(videoEl);
videoEl.play().catch(() => {});
requestAnimationFrame(() => {
this._pending.delete(key);
if (!this._enabled || !token?.mesh) {
videoEl.remove();
return;
}
const origTexture = token.mesh.texture;
const w = Math.max(1, Math.round(token.mesh.width));
const h = Math.max(1, Math.round(token.mesh.height));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (ctx) {
const radius = Math.min(w, h) / 2;
const cx = w / 2;
const cy = h / 2;
const pixiTexture = PIXI.Texture.from(canvas);
token.mesh.texture = pixiTexture;
const renderFrame = () => {
const overlay = this._overlays.get(key);
if (!overlay || !this._enabled) return;
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(videoEl, 0, 0, w, h);
ctx.restore();
pixiTexture.baseTexture.update();
overlay.rafId = requestAnimationFrame(renderFrame);
};
const rafId = requestAnimationFrame(renderFrame);
this._overlays.set(key, { videoEl, origTexture, userId, canvas, ctx, pixiTexture, rafId });
} else {
// Fallback: canvas 2D unavailable (e.g. test env), use direct video texture
token.mesh.texture = PIXI.Texture.from(videoEl);
this._overlays.set(key, { videoEl, origTexture, userId });
}
});
}
_detach(tokenId) {
const sceneId = canvas.scene?.id ?? '?';
this._detachByKey(`${sceneId}.${tokenId}`);
}
_detachByKey(key) {
const overlay = this._overlays.get(key);
if (!overlay) return;
if (overlay.rafId) cancelAnimationFrame(overlay.rafId);
const tokenId = key.split('.').pop();
const token = canvas.tokens?.get(tokenId);
if (token?.mesh && overlay.origTexture) {
token.mesh.texture = overlay.origTexture;
}
overlay.videoEl?.pause();
overlay.videoEl?.remove();
this._overlays.delete(key);
}
_onTokenRefreshed(token) {
const key = this._tokenKey(token);
const overlay = this._overlays.get(key);
if (overlay && token?.mesh) {
if (overlay.canvas) {
const w = Math.max(1, Math.round(token.mesh.width));
const h = Math.max(1, Math.round(token.mesh.height));
if (overlay.canvas.width !== w || overlay.canvas.height !== h) {
this._detachByKey(key);
this._attach(token);
return;
}
if (token.mesh.texture !== overlay.pixiTexture) {
token.mesh.texture = overlay.pixiTexture;
}
} else {
const tex = token.mesh.texture;
const src = tex?.baseTexture?.resource?.source;
if (!src || src.tagName !== 'VIDEO' || src !== overlay.videoEl) {
token.mesh.texture = PIXI.Texture.from(overlay.videoEl);
}
}
} else if (!overlay && !this._pending.has(key)) {
this._attach(token);
}
}
_onCreateToken(doc) {
requestAnimationFrame(() => {
const token = canvas.tokens?.get(doc.id);
if (token) this._attach(token);
});
}
_onUpdateToken(doc) {
const token = canvas.tokens?.get(doc.id);
if (!token) return;
const key = this._tokenKey(token);
const existing = this._overlays.get(key);
const userId = this._getOwningUserId(token);
if (!userId) {
if (existing) this._detachByKey(key);
return;
}
if (existing && existing.userId === userId) {
if (token?.mesh) {
if (existing.pixiTexture) {
if (token.mesh.texture !== existing.pixiTexture) {
token.mesh.texture = existing.pixiTexture;
}
} else {
const tex = token.mesh.texture;
const src = tex?.baseTexture?.resource?.source;
if (!src || src.tagName !== 'VIDEO' || src !== existing.videoEl) {
token.mesh.texture = PIXI.Texture.from(existing.videoEl);
}
}
}
return;
}
if (existing) this._detachByKey(key);
this._attach(token);
}
_cleanupAll() {
for (const [key] of this._overlays) this._detachByKey(key);
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Actor-Mapping Panel styles.
*
* GM settings submenu to assign actors to user webcams for token overlay.
* Uses SP (Scrying Pool) semantic token system.
*/
@import "../tokens/_base.less";
.scrying-pool {
&.actor-mapping {
background: var(--sp-dialog-bg);
color: var(--sp-text-primary);
font-family: var(--font-primary, inherit);
border: var(--sp-dialog-border);
border-top: 2px solid var(--sp-accent);
border-radius: var(--sp-dialog-radius);
box-shadow: var(--sp-dialog-shadow);
}
.sp-actor-mapping {
display: flex;
flex-direction: column;
min-width: 320px;
max-width: 500px;
}
.sp-actor-mapping__hint {
margin: 0 0 12px 0;
font-size: 12px;
color: var(--sp-text-secondary);
line-height: 1.4;
padding: 12px 12px 0;
}
.sp-actor-mapping__empty {
text-align: center;
color: var(--sp-text-muted);
font-size: 13px;
padding: 24px 12px;
margin: 0;
}
.sp-actor-mapping__table {
display: flex;
flex-direction: column;
padding: 0 12px 12px;
gap: 6px;
}
.sp-actor-mapping__row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--sp-surface);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius-md);
}
.sp-actor-mapping__user {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.sp-actor-mapping__user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.sp-actor-mapping__user-name {
font-size: 12px;
font-weight: 600;
color: var(--sp-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sp-actor-mapping__badge {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--sp-accent);
border: 1px solid var(--sp-accent);
border-radius: 3px;
padding: 1px 4px;
flex-shrink: 0;
line-height: 1.2;
opacity: 0.85;
}
.sp-actor-mapping__select {
flex-shrink: 0;
width: 180px;
padding: 4px 6px;
font-size: 12px;
font-family: var(--font-primary, inherit);
background: var(--sp-control-bg);
color: var(--sp-text-primary);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius-sm);
cursor: pointer;
}
.sp-actor-mapping__select:focus-visible {
outline: none;
box-shadow: var(--sp-focus-ring);
}
}
+200
View File
@@ -0,0 +1,200 @@
//
// _freeform-camera.less — Floating camera window styles for freeform layout
//
//
// _freeform-camera.less — Floating camera window styles for freeform layout
//
// DOM structure (Foundry v14 ApplicationV2):
// section.app-v2.application.window-content.scrying-pool.freeform-camera
// header.window-header ← title bar
// section.window-content ← inner wrapper for PARTS
// section.freeform-camera__body ← our template root
// div.freeform-camera__video-container
// div.freeform-camera__footer
// footer.window-resizable-handle ← resize grip
//
// ── Outer app element ─────────────────────────────────────────────────────
// Override Foundry's defaults with higher specificity + !important.
.scrying-pool.freeform-camera {
border-radius: 4px !important;
overflow: hidden !important;
}
// ── Window header — ultra-compact ─────────────────────────────────────────
.scrying-pool.freeform-camera > header.window-header {
padding: 1px 6px !important;
min-height: 0 !important;
height: 22px !important;
line-height: 20px !important;
background: rgba(0, 0, 0, 0.75) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
flex-shrink: 0;
gap: 2px;
}
.scrying-pool.freeform-camera .window-title {
font-size: 10px !important;
line-height: 18px !important;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #dde2e8;
}
.scrying-pool.freeform-camera .window-controls {
gap: 1px;
}
.scrying-pool.freeform-camera .window-control {
width: 18px !important;
height: 18px !important;
line-height: 18px !important;
font-size: 10px !important;
padding: 0 !important;
margin: 0 !important;
border-radius: 2px !important;
display: flex !important;
align-items: center;
justify-content: center;
i {
font-size: 10px !important;
}
&:hover {
background: rgba(255, 255, 255, 0.15) !important;
}
}
.scrying-pool.freeform-camera .window-control.close {
font-size: 13px !important;
font-weight: 400;
}
// ── Inner content wrapper (Foundry's .window-content) ─────────────────────
.scrying-pool.freeform-camera > .window-content {
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-height: 0 !important;
overflow: hidden !important;
background: #000;
}
// ── Our body inside inner wrapper ─────────────────────────────────────────
.scrying-pool.freeform-camera .freeform-camera__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
color: #dde2e8;
}
.scrying-pool.freeform-camera .freeform-camera__video-container {
flex: 1;
position: relative;
min-height: 0;
overflow: hidden;
background: #000;
}
.scrying-pool.freeform-camera .freeform-camera__video-element {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
// ── Footer ────────────────────────────────────────────────────────────────
.scrying-pool.freeform-camera .freeform-camera__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1px 6px;
gap: 4px;
background: rgba(0, 0, 0, 0.55);
flex-shrink: 0;
height: 18px;
}
.scrying-pool.freeform-camera .freeform-camera__footer-left {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.scrying-pool.freeform-camera .freeform-camera__footer-right {
display: flex;
align-items: center;
flex-shrink: 0;
}
.scrying-pool.freeform-camera .freeform-camera__control-btn {
background: none;
border: none;
color: #dde2e8;
cursor: pointer;
padding: 1px 3px;
font-size: 9px;
line-height: 1;
border-radius: 2px;
transition: background 0.15s;
&:hover { background: rgba(255, 255, 255, 0.15); }
&:active { background: rgba(255, 255, 255, 0.25); }
i { font-size: 9px; }
}
.scrying-pool.freeform-camera .freeform-camera__name {
font-size: 9px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1;
}
// ── Volume slider ─────────────────────────────────────────────────────────
.scrying-pool.freeform-camera .freeform-camera__volume {
width: 44px;
height: 10px;
accent-color: #4a9eff;
cursor: pointer;
margin: 0;
&::-webkit-slider-thumb {
appearance: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4a9eff;
border: none;
}
&::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4a9eff;
border: none;
}
&::-webkit-slider-runnable-track { height: 2px; }
&::-moz-range-track { height: 2px; }
}
// ── Spotlight glow ────────────────────────────────────────────────────────
.scrying-pool.freeform-camera.is-spotlight {
box-shadow: 0 0 10px 2px #ffd700;
border-color: #ffd700;
}
.scrying-pool.freeform-camera.is-spotlight > header.window-header {
border-bottom-color: #ffd700 !important;
}
+272
View File
@@ -990,6 +990,16 @@
.sp-participant-avatar.sp-state-pending .sp-avatar__shell::after {
box-shadow: inset 0 0 0 2px var(--sp-state-color);
}
.sp-participant-avatar.sp-state-focused .sp-avatar__shell::after {
box-shadow: inset 0 0 0 2px var(--sp-urgency-director);
}
.sp-strip__participant-item.sp-dragging {
opacity: 0.3;
}
.sp-strip__participant-item.sp-drag-over {
box-shadow: inset 0 0 0 2px var(--sp-urgency-director);
border-radius: 4px;
}
@media (prefers-reduced-motion: no-preference) {
.sp-participant-avatar.sp-state-pending .sp-avatar__shell::after {
animation: sp-pulse 2s ease-in-out infinite;
@@ -2932,6 +2942,268 @@ dialog.sp-visibility-details-panel::backdrop {
text-transform: uppercase;
letter-spacing: 0.03em;
}
/**
* Actor-Mapping Panel styles.
*
* GM settings submenu to assign actors to user webcams for token overlay.
* Uses SP (Scrying Pool) semantic token system.
*/
.scrying-pool.actor-mapping {
background: var(--sp-dialog-bg);
color: var(--sp-text-primary);
font-family: var(--font-primary, inherit);
border: var(--sp-dialog-border);
border-top: 2px solid var(--sp-accent);
border-radius: var(--sp-dialog-radius);
box-shadow: var(--sp-dialog-shadow);
}
.scrying-pool .sp-actor-mapping {
display: flex;
flex-direction: column;
min-width: 320px;
max-width: 500px;
}
.scrying-pool .sp-actor-mapping__hint {
margin: 0 0 12px 0;
font-size: 12px;
color: var(--sp-text-secondary);
line-height: 1.4;
padding: 12px 12px 0;
}
.scrying-pool .sp-actor-mapping__empty {
text-align: center;
color: var(--sp-text-muted);
font-size: 13px;
padding: 24px 12px;
margin: 0;
}
.scrying-pool .sp-actor-mapping__table {
display: flex;
flex-direction: column;
padding: 0 12px 12px;
gap: 6px;
}
.scrying-pool .sp-actor-mapping__row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--sp-surface);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius-md);
}
.scrying-pool .sp-actor-mapping__user {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.scrying-pool .sp-actor-mapping__user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.scrying-pool .sp-actor-mapping__user-name {
font-size: 12px;
font-weight: 600;
color: var(--sp-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scrying-pool .sp-actor-mapping__badge {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--sp-accent);
border: 1px solid var(--sp-accent);
border-radius: 3px;
padding: 1px 4px;
flex-shrink: 0;
line-height: 1.2;
opacity: 0.85;
}
.scrying-pool .sp-actor-mapping__select {
flex-shrink: 0;
width: 180px;
padding: 4px 6px;
font-size: 12px;
font-family: var(--font-primary, inherit);
background: var(--sp-control-bg);
color: var(--sp-text-primary);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius-sm);
cursor: pointer;
}
.scrying-pool .sp-actor-mapping__select:focus-visible {
outline: none;
box-shadow: var(--sp-focus-ring);
}
.scrying-pool.freeform-camera {
border-radius: 4px !important;
overflow: hidden !important;
}
.scrying-pool.freeform-camera > header.window-header {
padding: 1px 6px !important;
min-height: 0 !important;
height: 22px !important;
line-height: 20px !important;
background: rgba(0, 0, 0, 0.75) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
flex-shrink: 0;
gap: 2px;
}
.scrying-pool.freeform-camera .window-title {
font-size: 10px !important;
line-height: 18px !important;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #dde2e8;
}
.scrying-pool.freeform-camera .window-controls {
gap: 1px;
}
.scrying-pool.freeform-camera .window-control {
width: 18px !important;
height: 18px !important;
line-height: 18px !important;
font-size: 10px !important;
padding: 0 !important;
margin: 0 !important;
border-radius: 2px !important;
display: flex !important;
align-items: center;
justify-content: center;
}
.scrying-pool.freeform-camera .window-control i {
font-size: 10px !important;
}
.scrying-pool.freeform-camera .window-control:hover {
background: rgba(255, 255, 255, 0.15) !important;
}
.scrying-pool.freeform-camera .window-control.close {
font-size: 13px !important;
font-weight: 400;
}
.scrying-pool.freeform-camera > .window-content {
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-height: 0 !important;
overflow: hidden !important;
background: #000;
}
.scrying-pool.freeform-camera .freeform-camera__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
color: #dde2e8;
}
.scrying-pool.freeform-camera .freeform-camera__video-container {
flex: 1;
position: relative;
min-height: 0;
overflow: hidden;
background: #000;
}
.scrying-pool.freeform-camera .freeform-camera__video-element {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.scrying-pool.freeform-camera .freeform-camera__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1px 6px;
gap: 4px;
background: rgba(0, 0, 0, 0.55);
flex-shrink: 0;
height: 18px;
}
.scrying-pool.freeform-camera .freeform-camera__footer-left {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.scrying-pool.freeform-camera .freeform-camera__footer-right {
display: flex;
align-items: center;
flex-shrink: 0;
}
.scrying-pool.freeform-camera .freeform-camera__control-btn {
background: none;
border: none;
color: #dde2e8;
cursor: pointer;
padding: 1px 3px;
font-size: 9px;
line-height: 1;
border-radius: 2px;
transition: background 0.15s;
}
.scrying-pool.freeform-camera .freeform-camera__control-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.scrying-pool.freeform-camera .freeform-camera__control-btn:active {
background: rgba(255, 255, 255, 0.25);
}
.scrying-pool.freeform-camera .freeform-camera__control-btn i {
font-size: 9px;
}
.scrying-pool.freeform-camera .freeform-camera__name {
font-size: 9px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1;
}
.scrying-pool.freeform-camera .freeform-camera__volume {
width: 44px;
height: 10px;
accent-color: #4a9eff;
cursor: pointer;
margin: 0;
}
.scrying-pool.freeform-camera .freeform-camera__volume::-webkit-slider-thumb {
appearance: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4a9eff;
border: none;
}
.scrying-pool.freeform-camera .freeform-camera__volume::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4a9eff;
border: none;
}
.scrying-pool.freeform-camera .freeform-camera__volume::-webkit-slider-runnable-track {
height: 2px;
}
.scrying-pool.freeform-camera .freeform-camera__volume::-moz-range-track {
height: 2px;
}
.scrying-pool.freeform-camera.is-spotlight {
box-shadow: 0 0 10px 2px #ffd700;
border-color: #ffd700;
}
.scrying-pool.freeform-camera.is-spotlight > header.window-header {
border-bottom-color: #ffd700 !important;
}
/*
* VisibilityBadge :root exception
* ─────────────────────────────────────────────────────────────────────────────
+4
View File
@@ -33,6 +33,10 @@
@import "components/_preset-import-export.less";
// Story 4.1: Player Privacy Panel
@import "components/_player-privacy-panel.less";
// Story 5.X: Actor-Webcam Mapping Panel
@import "components/_actor-mapping.less";
// Story 5.3: Freeform Layout — Floating Camera Windows
@import "components/_freeform-camera.less";
/*
* VisibilityBadge :root exception
+25
View File
@@ -0,0 +1,25 @@
{{!-- Actor-Webcam Mapping Panel --}}
<form class="sp-actor-mapping">
{{#if hasNoUsers}}
<p class="sp-actor-mapping__empty">{{localize "SCRYING_POOL.ActorMapping.noUsers"}}</p>
{{else}}
<p class="sp-actor-mapping__hint">{{localize "SCRYING_POOL.ActorMapping.description"}}</p>
<div class="sp-actor-mapping__table">
{{#each users}}
<div class="sp-actor-mapping__row">
<div class="sp-actor-mapping__user">
<img class="sp-actor-mapping__user-avatar" src="{{this.avatar}}" alt="">
<span class="sp-actor-mapping__user-name">{{this.name}}</span>
{{#if this.isGM}}<span class="sp-actor-mapping__badge">{{localize "SCRYING_POOL.ActorMapping.gmBadge"}}</span>{{/if}}
</div>
<select name="{{this.id}}" class="sp-actor-mapping__select">
<option value="">{{localize "SCRYING_POOL.ActorMapping.noneOption"}}</option>
{{#each this.actors}}
<option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>{{this.name}}</option>
{{/each}}
</select>
</div>
{{/each}}
</div>
{{/if}}
</form>
+23
View File
@@ -0,0 +1,23 @@
{{!-- Freeform camera window body --}}
<section class="freeform-camera__body" data-user-id="{{userId}}">
<div class="freeform-camera__video-container"></div>
<div class="freeform-camera__footer">
<div class="freeform-camera__footer-left">
<button type="button" class="freeform-camera__control-btn freeform-camera__mic-btn"
data-action="toggle-mic"
data-tooltip="{{localize 'SCRYING_POOL.Freeform.toggleMic'}}">
{{#if audioMuted}}
<i class="fas fa-microphone-slash"></i>
{{else}}
<i class="fas fa-microphone"></i>
{{/if}}
</button>
<span class="freeform-camera__name">{{userName}}</span>
</div>
<div class="freeform-camera__footer-right">
<input type="range" class="freeform-camera__volume" min="0" max="1" step="0.05" value="{{volume}}"
data-action="set-volume"
data-tooltip="{{localize 'SCRYING_POOL.Freeform.volume'}}">
</div>
</div>
</section>
+62
View File
@@ -31,6 +31,25 @@
{{/if}}
</div>
</div>
<!-- Layout selector -->
<div class="scrying-pool-settings__content">
<div class="scrying-pool-settings__status">
<label class="scrying-pool-settings__label">View Layout</label>
<div class="scrying-pool-settings__layout-group">
{{#each dockLayouts}}
<button type="button"
class="scrying-pool-settings__layout-btn{{#if isActive}} is-active{{/if}}"
data-action="set-dock-layout"
data-layout="{{key}}"
data-tooltip="{{label}}">
<i class="fas {{icon}}" aria-hidden="true"></i>
<span class="scrying-pool-settings__layout-size">{{size}}</span>
</button>
{{/each}}
</div>
</div>
</div>
</div>
<style>
@@ -206,5 +225,48 @@
background: linear-gradient(175deg, hsl(0, 60%, 35%) 0%, hsl(0, 60%, 30%) 100%);
}
}
/* Layout selector */
.scrying-pool-settings__layout-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
}
.scrying-pool-settings__layout-btn {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 4px 7px;
font-size: 11px;
font-weight: 500;
background: rgba(255, 255, 255, 0.06);
color: var(--sp-text-primary, #dde2e8);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
i {
font-size: 10px;
pointer-events: none;
}
&:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
&.is-active {
background: hsl(200, 55%, 35%);
border-color: hsl(200, 55%, 50%);
}
}
.scrying-pool-settings__layout-size {
font-size: 9px;
opacity: 0.6;
}
</style>
</form>
@@ -0,0 +1,485 @@
// @ts-nocheck
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TokenVideoOverlay } from '../../../../src/ui/shared/TokenVideoOverlay.js';
import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js';
describe('TokenVideoOverlay', () => {
let adapter;
let overlay;
let mockToken;
let rafCallbacks;
beforeEach(() => {
rafCallbacks = [];
let rafIdCounter = 0;
vi.stubGlobal('PIXI', {
Texture: {
from: vi.fn(() => {
const tex = { baseTexture: { update: vi.fn() } };
return tex;
}),
},
});
vi.stubGlobal('requestAnimationFrame', (cb) => {
rafCallbacks.push(cb);
return ++rafIdCounter;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
vi.stubGlobal('Hooks', { on: vi.fn(), once: vi.fn() });
globalThis.canvas = {
scene: { id: 'scene-1' },
tokens: { placeables: [], get: vi.fn() },
};
globalThis.CONST = { DOCUMENT_PERMISSION_LEVELS: { OWNER: 3 } };
globalThis.foundry = {
canvas: { placeables: { Token: class {} } },
};
HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve());
// happy-dom rejects non-MediaStream srcObject values
Object.defineProperty(HTMLVideoElement.prototype, 'srcObject', {
writable: true,
value: null,
});
const mockStream = {
getVideoTracks: vi.fn(() => [{ enabled: true }]),
};
adapter = createFoundryAdapterMock({
webrtc: {
getMediaStreamForUser: vi.fn(() => mockStream),
},
settings: {
get: vi.fn((key) => {
if (key === 'userActorMapping') return {};
if (key === 'showVideoOnTokens') return true;
return null;
}),
},
users: {
all: vi.fn(() => [{ id: 'user-1', isGM: false, active: true }]),
get: vi.fn(() => ({ id: 'user-1', isGM: false, active: true })),
},
});
mockToken = {
id: 'token-1',
document: {
actor: {
id: 'actor-1',
testUserPermission: vi.fn(() => true),
},
},
mesh: { width: 100, height: 100, texture: 'original-tex' },
};
overlay = new TokenVideoOverlay(adapter);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const tickRAF = () => {
const cb = rafCallbacks.shift();
if (cb) cb();
};
describe('constructor', () => {
it('stores adapter reference without side effects', () => {
expect(overlay._adapter).toBe(adapter);
expect(overlay._overlays).toBeInstanceOf(Map);
expect(overlay._overlays.size).toBe(0);
expect(overlay._pending).toBeInstanceOf(Set);
expect(overlay._pending.size).toBe(0);
expect(overlay._origRefresh).toBeNull();
expect(overlay._enabled).toBe(false);
});
});
describe('_attach() — canvas 2D fallback', () => {
// happy-dom getContext('2d') returns null — fallback path
beforeEach(() => {
overlay._enabled = true;
});
it('falls back to direct video texture when canvas 2D unavailable', () => {
overlay._attach(mockToken);
tickRAF();
expect(overlay._overlays.size).toBe(1);
const data = overlay._overlays.get('scene-1.token-1');
expect(data.canvas).toBeUndefined();
expect(data.ctx).toBeUndefined();
expect(data.pixiTexture).toBeUndefined();
expect(data.origTexture).toBe('original-tex');
expect(data.userId).toBe('user-1');
expect(data.videoEl).toBeInstanceOf(HTMLVideoElement);
expect(PIXI.Texture.from).toHaveBeenCalled();
});
it('does not create overlay when token has no mesh', () => {
const badToken = { id: 'bad', document: { actor: { id: 'a1' } } };
overlay._attach(badToken);
tickRAF();
expect(overlay._overlays.size).toBe(0);
});
it('clears pending when attach completes', () => {
overlay._attach(mockToken);
expect(overlay._pending.has('scene-1.token-1')).toBe(true);
tickRAF();
expect(overlay._pending.has('scene-1.token-1')).toBe(false);
});
});
describe('_attach() — circular canvas path', () => {
let mockCtx;
beforeEach(() => {
overlay._enabled = true;
mockCtx = {
clearRect: vi.fn(),
save: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
clip: vi.fn(),
drawImage: vi.fn(),
restore: vi.fn(),
};
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockCtx);
});
it('creates canvas sized to mesh dimensions', () => {
overlay._attach(mockToken);
tickRAF();
const data = overlay._overlays.get('scene-1.token-1');
expect(data.canvas).toBeInstanceOf(HTMLCanvasElement);
expect(data.canvas.width).toBe(100);
expect(data.canvas.height).toBe(100);
expect(data.ctx).toBe(mockCtx);
expect(data.rafId).toBeTypeOf('number');
expect(data.pixiTexture).toBeDefined();
});
it('sets mesh texture to canvas-based PIXI texture', () => {
overlay._attach(mockToken);
tickRAF();
const data = overlay._overlays.get('scene-1.token-1');
expect(mockToken.mesh.texture).toBe(data.pixiTexture);
expect(mockToken.mesh.texture).not.toBe('original-tex');
});
it('runs render loop drawing circular clip each frame', () => {
overlay._attach(mockToken);
tickRAF();
const data = overlay._overlays.get('scene-1.token-1');
const firstRafId = data.rafId;
tickRAF();
expect(mockCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100);
expect(mockCtx.save).toHaveBeenCalled();
expect(mockCtx.beginPath).toHaveBeenCalled();
expect(mockCtx.arc).toHaveBeenCalledWith(50, 50, 50, 0, Math.PI * 2);
expect(mockCtx.clip).toHaveBeenCalled();
expect(mockCtx.drawImage).toHaveBeenCalled();
expect(mockCtx.restore).toHaveBeenCalled();
expect(data.pixiTexture.baseTexture.update).toHaveBeenCalled();
expect(data.rafId).not.toBe(firstRafId);
});
it('uses min dimension radius for non-square tokens', () => {
mockToken.mesh.width = 200;
mockToken.mesh.height = 100;
overlay._attach(mockToken);
tickRAF();
tickRAF();
expect(mockCtx.arc).toHaveBeenCalledWith(100, 50, 50, 0, Math.PI * 2);
});
it('guards against zero-dimension mesh', () => {
mockToken.mesh.width = 0;
mockToken.mesh.height = 0;
overlay._attach(mockToken);
tickRAF();
const data = overlay._overlays.get('scene-1.token-1');
expect(data.canvas.width).toBe(1);
expect(data.canvas.height).toBe(1);
});
it('stops render loop when overlay is removed from map', () => {
overlay._attach(mockToken);
tickRAF();
overlay._overlays.delete('scene-1.token-1');
rafCallbacks.length = 0;
tickRAF();
expect(mockCtx.clearRect).not.toHaveBeenCalled();
});
it('stops render loop when disabled', () => {
overlay._attach(mockToken);
tickRAF();
overlay._enabled = false;
rafCallbacks.length = 0;
tickRAF();
expect(mockCtx.clearRect).not.toHaveBeenCalled();
});
});
describe('_detachByKey()', () => {
beforeEach(() => {
overlay._overlays.set('scene-1.token-1', {
videoEl: document.createElement('video'),
origTexture: 'original-tex',
userId: 'user-1',
canvas: document.createElement('canvas'),
pixiTexture: { baseTexture: { update: vi.fn() } },
rafId: 42,
});
globalThis.canvas.tokens.get = vi.fn(() => mockToken);
});
it('cancels rAF loop', () => {
overlay._detachByKey('scene-1.token-1');
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
});
it('restores original texture on mesh', () => {
mockToken.mesh.texture = 'overwritten-by-something';
overlay._detachByKey('scene-1.token-1');
expect(mockToken.mesh.texture).toBe('original-tex');
});
it('removes video element from DOM', () => {
const data = overlay._overlays.get('scene-1.token-1');
document.body.appendChild(data.videoEl);
overlay._detachByKey('scene-1.token-1');
expect(document.body.contains(data.videoEl)).toBe(false);
});
it('removes overlay from map', () => {
overlay._detachByKey('scene-1.token-1');
expect(overlay._overlays.has('scene-1.token-1')).toBe(false);
});
it('is no-op for unknown key', () => {
expect(() => overlay._detachByKey('unknown')).not.toThrow();
});
it('is idempotent', () => {
overlay._detachByKey('scene-1.token-1');
overlay._detachByKey('scene-1.token-1');
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1);
});
});
describe('_onTokenRefreshed()', () => {
let mockPixiTex;
beforeEach(() => {
overlay._enabled = true;
mockPixiTex = { baseTexture: { update: vi.fn() } };
const c = document.createElement('canvas');
c.width = 100;
c.height = 100;
overlay._overlays.set('scene-1.token-1', {
videoEl: document.createElement('video'),
origTexture: 'original-tex',
userId: 'user-1',
canvas: c,
ctx: { clearRect: vi.fn() },
pixiTexture: mockPixiTex,
rafId: 42,
});
globalThis.canvas.tokens.get = vi.fn(() => mockToken);
});
it('re-applies pixiTexture when mesh texture was lost', () => {
mockToken.mesh.texture = 'wrong-texture';
overlay._onTokenRefreshed(mockToken);
expect(mockToken.mesh.texture).toBe(mockPixiTex);
});
it('detects mesh resize and re-attaches', () => {
vi.spyOn(overlay, '_detachByKey');
vi.spyOn(overlay, '_attach');
mockToken.mesh.width = 200;
mockToken.mesh.height = 200;
overlay._onTokenRefreshed(mockToken);
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-1');
expect(overlay._attach).toHaveBeenCalledWith(mockToken);
});
it('does nothing when mesh size and texture match', () => {
vi.spyOn(overlay, '_detachByKey');
vi.spyOn(overlay, '_attach');
mockToken.mesh.texture = mockPixiTex;
overlay._onTokenRefreshed(mockToken);
expect(overlay._detachByKey).not.toHaveBeenCalled();
expect(overlay._attach).not.toHaveBeenCalled();
});
it('uses fallback video path for non-canvas overlays', () => {
overlay._overlays.set('scene-1.token-2', {
videoEl: document.createElement('video'),
origTexture: 'original-tex-2',
userId: 'user-2',
});
const token2 = {
id: 'token-2',
document: mockToken.document,
mesh: { width: 100, height: 100, texture: 'wrong' },
};
overlay._onTokenRefreshed(token2);
expect(PIXI.Texture.from).toHaveBeenCalled();
});
it('attaches when overlay missing and not pending', () => {
overlay._overlays.clear();
vi.spyOn(overlay, '_attach');
overlay._onTokenRefreshed(mockToken);
expect(overlay._attach).toHaveBeenCalledWith(mockToken);
});
it('skips attach when token is pending', () => {
overlay._overlays.clear();
overlay._pending.add('scene-1.token-1');
vi.spyOn(overlay, '_attach');
overlay._onTokenRefreshed(mockToken);
expect(overlay._attach).not.toHaveBeenCalled();
});
});
describe('_onUpdateToken()', () => {
let mockPixiTex;
beforeEach(() => {
overlay._enabled = true;
mockPixiTex = { baseTexture: { update: vi.fn() } };
overlay._overlays.set('scene-1.token-1', {
videoEl: document.createElement('video'),
origTexture: 'original-tex',
userId: 'user-1',
canvas: document.createElement('canvas'),
pixiTexture: mockPixiTex,
rafId: 42,
});
globalThis.canvas.tokens.get = vi.fn(() => mockToken);
});
it('re-applies pixiTexture when mesh lost it', () => {
mockToken.mesh.texture = 'wrong-texture';
overlay._onUpdateToken(mockToken.document);
expect(mockToken.mesh.texture).toBe(mockPixiTex);
});
it('detaches when token loses user', () => {
adapter.users.all = vi.fn(() => []);
overlay._onUpdateToken(mockToken.document);
expect(overlay._overlays.has('scene-1.token-1')).toBe(false);
});
it('detaches old and re-attaches when user changes', () => {
overlay._overlays.set('scene-1.token-1', {
videoEl: document.createElement('video'),
origTexture: 'original-tex',
userId: 'old-user',
});
vi.spyOn(overlay, '_detachByKey');
vi.spyOn(overlay, '_attach');
overlay._onUpdateToken(mockToken.document);
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-1');
expect(overlay._attach).toHaveBeenCalledWith(mockToken);
});
it('is no-op when token not on canvas', () => {
globalThis.canvas.tokens.get = vi.fn(() => null);
expect(() => overlay._onUpdateToken(mockToken.document)).not.toThrow();
});
});
describe('enable/disable', () => {
it('enable sets _enabled and calls syncAll', () => {
vi.spyOn(overlay, 'syncAll');
overlay.enable();
expect(overlay._enabled).toBe(true);
expect(overlay.syncAll).toHaveBeenCalled();
});
it('disable sets _enabled and calls _cleanupAll', () => {
vi.spyOn(overlay, '_cleanupAll');
overlay.enable();
overlay.disable();
expect(overlay._enabled).toBe(false);
expect(overlay._cleanupAll).toHaveBeenCalled();
});
});
describe('_cleanupAll()', () => {
it('detaches all overlays', () => {
vi.spyOn(overlay, '_detachByKey');
overlay._overlays.set('scene-1.token-1', { origTexture: 't1' });
overlay._overlays.set('scene-1.token-2', { origTexture: 't2' });
overlay._cleanupAll();
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-1');
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-2');
expect(overlay._detachByKey).toHaveBeenCalledTimes(2);
});
});
describe('syncAll()', () => {
beforeEach(() => {
overlay._enabled = true;
});
it('attaches to all canvas tokens', () => {
vi.spyOn(overlay, '_attach');
canvas.tokens.placeables = [mockToken];
overlay.syncAll();
expect(overlay._attach).toHaveBeenCalledWith(mockToken);
});
it('detaches overlays for tokens no longer on canvas', () => {
overlay._overlays.set('scene-1.token-1', {});
overlay._overlays.set('scene-1.token-2', {});
overlay._overlays.set('scene-2.token-3', {});
canvas.tokens.placeables = [mockToken];
vi.spyOn(overlay, '_detachByKey');
overlay.syncAll();
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-2');
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-2.token-3');
expect(overlay._detachByKey).not.toHaveBeenCalledWith('scene-1.token-1');
});
});
});