Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76ce992505 |
+199
@@ -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()`
|
||||
@@ -28,6 +28,8 @@ export default [
|
||||
foundry: "readonly",
|
||||
CONFIG: "readonly",
|
||||
CONST: "readonly",
|
||||
Token: "readonly",
|
||||
PIXI: "readonly",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
|
||||
+17
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
// Delegate all button clicks via a single listener
|
||||
const handler = (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
switch (btn.dataset.action) {
|
||||
case 'close':
|
||||
this.close();
|
||||
break;
|
||||
case 'reopen-strip':
|
||||
this._roleRenderer?.openStrip();
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
// Add click handler for close strip button
|
||||
const closeBtn = this.element.querySelector('[data-action="close-strip"]');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user