5 Commits

Author SHA1 Message Date
uberwald 76ce992505 Video over token, free-form video windows
CI / ci (push) Successful in 40s
Release Creation / build (release) Successful in 46s
2026-06-07 22:18:08 +02:00
uberwald a9dbb9306a Various enhancements, restyling and new options
CI / ci (push) Successful in 44s
Release Creation / build (release) Successful in 44s
2026-05-27 12:10:44 +02:00
uberwald 602b1fc8e7 Various enhancements, restyling and new options
CI / ci (push) Successful in 49s
2026-05-27 12:10:19 +02:00
uberwald daed472b46 Add CC BY-NC-SA 4.0 license
CI / ci (push) Successful in 40s
2026-05-27 12:09:20 +02:00
uberwald d9eda8c725 Add comprehensive README in French and English
CI / ci (push) Successful in 37s
2026-05-27 12:05:06 +02:00
26 changed files with 3285 additions and 36 deletions
+439
View File
@@ -0,0 +1,439 @@
Attribution-NonCommercial-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
refer to those standards and best practices.
More considerations for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-ShareAlike 4.0 International Public License
("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-NC-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution, NonCommercial, and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
l. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
m. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
n. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
FULLY ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY
TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT FULLY ALLOWED IN
FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the "Licensor." The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material.
For the avoidance of doubt, this paragraph does not form part of the
public licenses.
=======================================================================
+195
View File
@@ -0,0 +1,195 @@
<p align="center">
<img src="https://raw.githubusercontent.com/morr/scrying-pool/main/.github/logo.png" alt="Scrying Pool" width="128" />
</p>
<h1 align="center">Scrying Pool</h1>
<p align="center">
<em>GM camera visibility control for FoundryVTT v14+</em>
<br />
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/"><img src="https://img.shields.io/badge/license-CC%20BY--NC--SA%204.0-lightgrey" alt="License: CC BY-NC-SA 4.0" /></a>
<br />
<sub>Français · <a href="#english">English</a></sub>
</p>
---
## Français
**Scrying Pool** est un module FoundryVTT qui donne au MJ un contrôle total sur la visibilité des caméras des participants. Fini les flux vidéo désordonnés — gérez qui voit quoi depuis une interface flottante et discrète.
### Fonctionnalités
| Fonction | Description |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| **Barre du MJ** | Palette flottante listant tous les participants connectés, avec leur état caméra en temps réel |
| **Director's Board** | Fenêtre de pilotage complète : grille de participants, actions groupées, mise en avant |
| **Masquer / Afficher** | Cache ou révèle un flux à la table — par clic droit ou popover |
| **Actions groupées** | Tout afficher, tout masquer, annuler la dernière action en un clic |
| **Mise en avant (Spotlight)** | `Shift+clic` sur un participant → sa vidéo s'agrandit (×2), les autres disparaissent. `Échap` pour quitter |
| **Réorganisation** | Faites glisser les participants dans la barre pour les réorganiser. Double-clic sur la poignée pour réinitialiser |
| **Mosaïque / Grille** | 6 dispositions : vertical/horizontal/mosaïque, chacune en taille S ou L |
| **Formes des tuiles** | Rond, arrondi, hexagone, octogone — appliqué aux avatars et aux vidéos |
| **Bordures** | Largeur et couleur paramétrables pour les tuiles vidéo |
| **Préréglages** | Sauvegardez et chargez des dispositions de visibilité, avec application automatique par scène |
| **Portrait personnalisé** | Les joueurs peuvent choisir une image de remplacement quand leur caméra est indisponible |
| **Confidentialité** | Panneau de consentement pour les automatismes (caméra de réaction, etc.) |
| **Notifications** | Notifications configurables (toutes, MJ seulement, silencieux) |
| **Rétablissement position** | La position de la barre est sauvegardée automatiquement toutes les 30s et au relâché du glisser |
### Installation
1. Dans FoundryVTT, allez dans la **Modules**
2. Recherchez **Scrying Pool**
3. Cliquez **Installer**
### Utilisation
#### Barre du MJ (strip)
La barre flottante apparaît automatiquement quand vous vous connectez en tant que MJ. Elle liste tous les participants avec leur état :
- **Clic** → popover pour masquer/afficher ce participant
- **Clic droit** → menu contextuel (masquer/afficher)
- **Shift+clic** → mode Spotlight (vidéo ×2)
- **Glisser** la poignée ⟞ pour déplacer la barre
- **Glisser** un participant pour le réorganiser
- **Double-clic** sur la poignée → réinitialiser l'ordre des participants
- **Bouton `[+]`** → basculer taille S/L
- **Bouton `[⊞]`** → ouvrir le Director's Board
La barre se fondu dans le décor — pas de chrome visible tant que vous ne la survolez pas.
#### Director's Board
Ouvrable depuis la barre (bouton `[⊞]`) ou via `Ctrl+Shift+V`.
- Grille de tous les participants avec statut et indicateur d'opération en attente
- Boutons **Tout afficher** / **Tout masquer**
- **Undo** (apparaît après une action groupée)
- **Rétablir** (après une mise en avant Spotlight)
- Sélecteur de **disposition** (vertical/horizontal/mosaïque, S/L)
- Sélecteur de **forme** des tuiles (cercle, arrondi, hexagone, octogone)
- Contrôles de **bordure** (largeur, couleur)
- Réglages de **taille des widgets** (petite 60200px, grande 60400px)
- Sauvegarde/chargement/export/import de **préréglages**
- **Application automatique** par scène
- Activation/désactivation A/V
- **Bouton Réinitialiser la barre** (rétablit la position par défaut)
#### Raccourcis clavier
| Raccourci | Action |
| -------------- | ------------------------------------------ |
| `Ctrl+Shift+V` | Ouvrir/Fermer le Director's Board |
| `Ctrl+Shift+S` | Tout afficher |
| `Ctrl+Shift+H` | Tout masquer |
| `Ctrl+Shift+P` | Mettre en avant le participant sélectionné |
### Configuration
Module accessible depuis **Paramètres → Gérer les modules → Scrying Pool**.
| Option | Description |
| --------------------------- | ---------------------------------------------------------------------- |
| Afficher le flux du MJ | Quand activé, la propre caméra du MJ est visible dans la barre |
| Verbosité des notifications | Toutes / MJ seulement / Silencieux |
| Application automatique | Activer/désactiver globalement l'application des préréglages par scène |
### Support
Pour signaler un bug ou proposer une amélioration : [ouvrir un ticket](https://github.com/morr/scrying-pool/issues).
---
## English
**Scrying Pool** is a FoundryVTT module that gives GMs full control over participant camera visibility. No more messy video feeds — manage who sees what from a discreet floating interface.
### Features
| Feature | Description |
| --------------------- | ---------------------------------------------------------------------------------------- |
| **GM Strip** | Floating palette listing all connected participants with real-time camera state |
| **Director's Board** | Full command window: participant grid, bulk actions, spotlight |
| **Hide / Show** | Hide or reveal a feed to the table — via right-click or popover |
| **Bulk actions** | Show all, hide all, undo last bulk action in one click |
| **Spotlight** | `Shift+click` a participant → their video enlarges (×2), others disappear. `Esc` to exit |
| **Re-order** | Drag participants in the strip to rearrange them. Double-click the grip to reset |
| **Mosaic / Grid** | 6 layouts: vertical/horizontal/mosaic, each in S or L size |
| **Tile shapes** | Circle, rounded, hexagon, octagon — applied to avatars and video feeds |
| **Borders** | Configurable width and color for video tiles |
| **Presets** | Save and load visibility layouts, with per-scene auto-apply |
| **Custom portrait** | Players can set a fallback image when their camera is unavailable |
| **Privacy** | Consent panel for automation features (reaction cam, etc.) |
| **Notifications** | Configurable notification verbosity (all, GM only, silent) |
| **Position recovery** | Strip position auto-saved every 30s and on drag release |
### Installation
1. In FoundryVTT, go to the **Modules**
2. Search for **Scrying Pool**
3. Click **Install**
### Usage
#### GM Strip
The floating strip appears automatically when you log in as GM. It lists all participants with their state:
- **Click** → popover to hide/show that participant
- **Right-click** → context menu (hide/show)
- **Shift+click** → Spotlight mode (×2 video)
- **Drag** the ⟞ handle to move the strip
- **Drag** a participant to reorder them
- **Double-click** the handle → reset participant order
- **`[+]` button** → toggle S/L size
- **`[⊞]` button** → open the Director's Board
The strip is stealthy — no chrome visible until hover.
#### Director's Board
Open from the strip (`[⊞]` button) or via `Ctrl+Shift+V`.
- Grid of all participants with status and pending-operation indicator
- **Show All** / **Hide All** buttons
- **Undo** (appears after a bulk action)
- **Restore** (after a Spotlight)
- **Layout** selector (vertical/horizontal/mosaic, S/L)
- **Shape** selector (circle, rounded, hexagon, octagon)
- **Border** controls (width, color)
- **Widget size** settings (small 60200px, large 60400px)
- **Preset** save/load/export/import
- Per-scene **auto-apply**
- A/V toggle
- **Reset Strip** button (restores default position)
#### Keyboard Shortcuts
| Shortcut | Action |
| -------------- | ----------------------------- |
| `Ctrl+Shift+V` | Open/Close Director's Board |
| `Ctrl+Shift+S` | Show All |
| `Ctrl+Shift+H` | Hide All |
| `Ctrl+Shift+P` | Spotlight focused participant |
### Settings
Access from **Settings → Manage Modules → Scrying Pool**.
| Option | Description |
| ---------------------- | ------------------------------------------------------------ |
| Show GM Self Feed | When enabled, the GM's own camera feed is shown in the strip |
| Notification Verbosity | All / GM Only / Silent |
| Auto-Apply | Globally enable/disable per-scene preset auto-apply |
### Support
Report bugs or request features: [open an issue](https://github.com/morr/scrying-pool/issues).
---
<p align="center">
<sub>Made for FoundryVTT v14+ · <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a></sub>
</p>
@@ -0,0 +1,199 @@
# Story 5.3: Freeform Layout for Floating Camera Windows
**Status:** ready-for-dev
**Epic:** 5 - Full AV Replacement
**Story Key:** 5-3-freeform-layout-floating-windows
**Created:** 2026-06-07
**Last Updated:** 2026-06-07
**Target Version:** v0.2.0
---
## Story Header
| Field | Value |
|-------|-------|
| **Epic** | 5 - Full AV Replacement |
| **Story ID** | 5.3 |
| **Story Key** | 5-3-freeform-layout-floating-windows |
| **Title** | Freeform Layout for Floating Camera Windows |
| **Status** | ready-for-dev |
| **Priority** | Medium |
| **Assigned Agent** | DEV |
| **Created** | 2026-06-07 |
| **Last Updated** | 2026-06-07 |
---
## Story Requirements
### User Story
**As a** GM using Scrying Pool,
**I want to** select a "Windows" layout mode where each participant's camera feed appears in its own freely draggable and resizable window,
**So that** I can arrange camera feeds anywhere on my screen, resize them independently, and close/hide participants individually.
### Acceptance Criteria
#### AC-1: Layout Selector
**Given** the module is active and GM opens the Director's Board
**When** the GM looks at the layout selector
**Then** there is a 7th layout button labeled "Windows" (icon: `fa-window-restore`)
**And** clicking it switches to freeform mode
**And** the previously active layout (e.g. vertical/horizontal/mosaic) is replaced — freeform is mutually exclusive
#### AC-2: Per-Participant Floating Windows
**Given** the GM has selected the freeform layout
**Then** each visible participant (not hidden from table) gets their own `ApplicationV2` floating window
**And** the GM's own feed is included if `showGMSelfFeed` is enabled and GM has video active
**And** each window displays the participant's webcam feed as an `<video>` element with `object-fit: cover`
**And** each window has the participant's name in the title bar
#### AC-3: Drag and Resize
**Given** there is a freeform camera window
**When** the GM drags the window by its header
**Then** the window moves freely to any screen position
**And** when the GM resizes the window (via the resize handle in the bottom-right corner)
**Then** the new dimensions are applied
**And** both position and size are persisted globally (same positions on all scenes)
#### AC-4: Position Persistence
**Given** the GM has arranged freeform windows at certain positions and sizes
**When** the page is reloaded or the GM re-enters freeform mode
**Then** all windows restore to their last saved positions and sizes
#### AC-5: Cascade for New Participants
**Given** a new participant appears (first time or no saved position)
**Then** their window appears at the top-left of the screen with a cascading offset (50,50 + 30px each step)
**And** the cascade wraps around after ~300px offset so windows don't go off-screen
#### AC-6: Volume Control
**Given** a freeform camera window is open
**Then** there is a volume slider in the window footer (range input, 0 to 1, step 0.05)
**And** moving the slider changes the volume of that window's video element
**And** volume is not persisted (resets to 100% on reload)
#### AC-7: Window Controls
**Given** a freeform camera window is open
**Then** the window title bar has two control buttons: "Spotlight" (star icon) and "Hide" (eye-slash icon)
**And** clicking "Spotlight" toggles a visual glow effect (golden box-shadow/outline) on that window
**And** clicking "Hide" hides that participant from the table (same as hiding from the strip or Directors Board)
**And** clicking the window's close (X) button also hides that participant
#### AC-8: Visual Spotlight (Glow Effect)
**Given** a participant is spotlighted in freeform mode
**Then** their window shows a glowing golden border/outline
**And** all other windows remain unchanged (no resize, no hiding)
**And** clicking the spotlight button again removes the glow
**And** only one participant can be spotlighted at a time (switching moves the glow)
#### AC-9: Mode Switching
**Given** the GM is in freeform mode
**When** the GM selects another layout (vertical/horizontal/mosaic)
**Then** all freeform windows are closed
**And** the strip layout opens/re-renders normally
**Given** the GM is in strip mode (not freeform)
**When** the GM selects the freeform layout
**Then** the strip is closed (not rendered)
**And** freeform windows are created for all visible participants
**And** saved positions are restored
#### AC-10: Sync with Visibility Changes
**Given** a participant is hidden (via Directors Board or strip) while in freeform mode
**Then** their freeform window closes
**And** when they are shown again, their window re-opens at its last saved position
**Given** a participant connects or disconnects while in freeform mode
**Then** windows are created/destroyed accordingly
---
## Implementation Plan
### Files to Create
| File | Purpose |
|------|---------|
| `src/ui/gm/FreeformCameraWindow.js` | Individual ApplicationV2 window per participant |
| `src/ui/gm/FreeformLayoutManager.js` | Orchestrates window creation/destruction/sync |
| `templates/freeform-camera.hbs` | Template for each camera window |
| `styles/components/_freeform-camera.less` | Styles for floating camera windows |
### Files to Modify
| File | Change |
|------|--------|
| `module.js` | Import FreeformLayoutManager, construct in ready, add life cycle hooks |
| `src/ui/gm/DirectorsBoard.js` | Add freeform to `DOCK_LAYOUTS`, handle `set-dock-layout` |
| `templates/directors-board.hbs` | Add freeform layout button |
| `lang/en.json` | Add freeform layout i18n keys |
| `lang/fr.json` | Add freeform layout i18n keys |
| `styles/scrying-pool.less` | Import `_freeform-camera.less` |
### Data Contracts
#### World Setting: `freeformLayout`
```json
{
"windows": {
"userId1": { "left": 100, "top": 200, "width": 320, "height": 300 },
"userId2": { "left": 450, "top": 200, "width": 320, "height": 300 }
}
}
```
Not user-visible in config (`config: false`).
---
## Dev Notes
### Architecture
```
FreeformLayoutManager (adapter, controller, stateStore)
├── init() → registers hooks
├── sync() → reconciles visible users with open windows
├── setSpotlight(userId) → toggles visual glow
├── destroy() → closes all windows
└── Map<userId, FreeformCameraWindow>
└── FreeformCameraWindow ({userId, adapter, manager, position})
├── ApplicationV2 window with resizable: true
├── _onRender() → _attachVideo()
├── _onPosition() → manager._scheduleSave()
├── _onClickWindowControl() → spotlight/hide actions
├── _onClose() → _detachVideo(), hide participant
└── Volume slider → videoElement.volume (session only)
```
### Key Decisions (from user discussions)
| Decision | Choice |
|----------|--------|
| Volume persistence | Session only (default 1.0) |
| GM self-feed | Included if `showGMSelfFeed` + GM has video |
| Spotlight behavior | Visual glow only — no resize/hide of others |
| Default position | Top-left cascade: (50,50) + 30px per window, wrap at ~300px |
| Close button | Hides participant from table |
| Hide button | Hidden via controller.action |
### ApplicationV2 Patterns
- **Window controls** in `window.controls` array → override `_onClickWindowControl(event)`
- **Position tracking** → override `_onPosition(position)` → calls manager's save
- **Fallback base class** for test env → follow same pattern as ScryingPoolStrip
- **Constructor side-effect-free** → no render/init in constructor
- **Template** uses Handlebars `localize` helper for i18n
### Stream Access
- Get stream: `adapter.webrtc.getMediaStreamForUser(userId)`
- Create video: `document.createElement('video')``video.srcObject = stream`
- Autoplay + playsInline + mute (mute only for current user)
- Cleanup: `video.pause()`, `video.srcObject = null`, `video.remove()`
+2
View File
@@ -28,6 +28,8 @@ export default [
foundry: "readonly",
CONFIG: "readonly",
CONST: "readonly",
Token: "readonly",
PIXI: "readonly",
},
},
settings: {
+17 -1
View File
@@ -63,7 +63,8 @@
"horizontal-sm": "Horizontal Small",
"horizontal-md": "Horizontal Large",
"mosaic-sm": "Mosaic Small",
"mosaic-md": "Mosaic Large"
"mosaic-md": "Mosaic Large",
"freeform": "Windows"
},
"widgetWidth": {
"label": "Video Widget Widths",
@@ -207,6 +208,21 @@
"portraitErrorTooLarge": "Image is too large. Please use an image under 5MB.",
"portraitErrorInvalid": "Invalid image file. Please try another."
},
"ActorMapping": {
"title": "Actor-Webcam Mapping",
"label": "Actor-Webcam Mapping",
"hint": "Assign actors to player webcams for the token video overlay feature",
"description": "Assign a webcam feed to an actor's token. When \"Show Webcam Video on Tokens\" is enabled, the token of the selected actor will display the assigned user's webcam feed.",
"noUsers": "No users found.",
"gmBadge": "GM",
"noneOption": "— None —"
},
"Freeform": {
"spotlight": "Spotlight",
"hide": "Hide participant",
"volume": "Volume",
"toggleMic": "Toggle microphone"
},
"Settings": {
"PlayerPrivacyPanel": "Player Privacy Panel",
"PlayerPrivacyPanelLabel": "Control automation effects for your camera",
+17 -1
View File
@@ -63,7 +63,8 @@
"horizontal-sm": "Horizontale petite",
"horizontal-md": "Horizontale grande",
"mosaic-sm": "Mosaïque petite",
"mosaic-md": "Mosaïque grande"
"mosaic-md": "Mosaïque grande",
"freeform": "Fenêtres"
},
"widgetWidth": {
"label": "Largeurs des widgets vidéo",
@@ -207,6 +208,21 @@
"portraitErrorTooLarge": "L'image est trop volumineuse. Veuillez utiliser une image de moins de 5 Mo.",
"portraitErrorInvalid": "Fichier image invalide. Veuillez en essayer un autre."
},
"ActorMapping": {
"title": "Attribution acteur-webcam",
"label": "Attribution acteur-webcam",
"hint": "Attribuez des acteurs aux webcams des joueurs pour la superposition vidéo sur les tokens",
"description": "Associez un flux webcam au token d'un acteur. Quand \"Afficher la webcam sur les tokens\" est activé, le token de l'acteur sélectionné affichera la webcam de l'utilisateur assigné.",
"noUsers": "Aucun utilisateur trouvé.",
"gmBadge": "MJ",
"noneOption": "— Aucun —"
},
"Freeform": {
"spotlight": "Focus",
"hide": "Cacher le participant",
"volume": "Volume",
"toggleMic": "Activer/désactiver le micro"
},
"Settings": {
"PlayerPrivacyPanel": "Panneau de confidentialité du joueur",
"PlayerPrivacyPanelLabel": "Contrôlez les effets d'automatisation sur votre caméra",
+98 -6
View File
@@ -34,16 +34,19 @@ import { DirectorsBoard } from './src/ui/gm/DirectorsBoard.js';
import { ConfirmationBar } from './src/ui/gm/ConfirmationBar.js';
import { PlayerPrivacyPanelMenu, initPlayerPrivacyPanelMenu } from './src/ui/player/PlayerPrivacyPanelMenu.js';
import { initGMPlayerPrivacySelector } from './src/ui/gm/GMPlayerPrivacySelector.js';
import { GMActorMappingPanel, initGMActorMappingPanel } from './src/ui/gm/GMActorMappingPanel.js';
import { ScryingPoolCameraViews, initScryingPoolCameraViews } from './src/ui/shared/ScryingPoolCameraViews.js';
import { TokenVideoOverlay } from './src/ui/shared/TokenVideoOverlay.js';
import { FreeformLayoutManager } from './src/ui/gm/FreeformLayoutManager.js';
import { ScryingPoolSettings } from './src/ui/gm/ScryingPoolSettings.js';
import { SOCKET_EVENTS } from './src/contracts/socket-message.js';
// Factory function to create ScryingPoolSettings with roleRenderer dependency
// Factory function to create ScryingPoolSettings with roleRenderer and adapter dependencies
// Returns a class constructor (not a function) that Foundry can use for registerMenu
function initScryingPoolSettings(roleRendererRef) {
function initScryingPoolSettings(roleRendererRef, adapterRef) {
return class extends ScryingPoolSettings {
constructor(options = {}) {
super(roleRendererRef, options);
super(roleRendererRef, adapterRef, options);
}
};
}
@@ -65,6 +68,8 @@ let visibilityBadge;
let notificationBus;
let directorsBoard;
let confirmationBar;
let tokenVideoOverlay;
let freeformLayoutManager;
/** @type {boolean} Flag to prevent duplicate scene control button addition */
let directorsBoardButtonAdded = false;
@@ -119,6 +124,40 @@ Hooks.once("init", () => {
hint: "When enabled, the GM's own camera feed is shown in the Scrying Pool strip.",
});
// Story 5.X: Token Video Overlay — completely optional
adapter.settings.register("showVideoOnTokens", {
scope: "world",
config: true,
type: Boolean,
default: false,
name: "Show Webcam Video on Tokens",
hint: "When enabled, each player's webcam feed replaces their character token image on the active scene, masked to a circle. The video strip is automatically hidden to free screen space.",
onChange: (val) => {
tokenVideoOverlay?.[val ? 'enable' : 'disable']();
if (val) {
roleRenderer?.closeStrip();
} else {
roleRenderer?.openStrip();
}
},
});
// Story 5.X: GM actor-to-user mapping for token video overlay
adapter.settings.register("userActorMapping", {
scope: "world",
config: false,
type: Object,
default: {},
});
// Story 5.3: Freeform layout — floating camera window positions
adapter.settings.register("freeformLayout", {
scope: "world",
config: false,
type: Object,
default: { windows: {} },
});
// Story 2.1: per-user notification verbosity preference (client-scoped)
adapter.settings.register("notificationVerbosity", {
scope: "client",
@@ -150,7 +189,16 @@ Hooks.once("init", () => {
config: false,
type: String,
default: "vertical-sm",
onChange: () => roleRenderer?.rerenderStrip(),
onChange: (val) => {
if (val === 'freeform') {
roleRenderer?.closeStrip();
freeformLayoutManager?.init();
freeformLayoutManager?.sync();
} else {
freeformLayoutManager?.destroy();
roleRenderer?.rerenderStrip();
}
},
});
// Per-user size toggle — client-scoped so each user can expand/collapse independently.
@@ -376,6 +424,13 @@ Hooks.once("ready", () => {
// Story 2.1: NotificationBus — runs for all clients (GM and players)
notificationBus = new NotificationBus(adapter);
notificationBus.init();
// Story 5.X: Token Video Overlay — optional, all clients (GM sees all players)
if (adapter.webrtc) {
tokenVideoOverlay = new TokenVideoOverlay(adapter);
tokenVideoOverlay.init();
}
// Story 3.1: Register socket listener for preset apply echo (all clients receive)
// Note: In Foundry, socket messages are automatically broadcast to all clients.
// The GM emits PRESET_APPLIED, and all clients (including GM) receive it.
@@ -407,13 +462,37 @@ Hooks.once("ready", () => {
directorsBoard = new DirectorsBoard(stateStore, scryingPoolController, adapter, scenePresetManager, playerPrivacyManager);
directorsBoard.init();
window.directorsBoard = directorsBoard;
// Story 5.3: Freeform Layout Manager
const SettingsCls = initScryingPoolSettings(roleRenderer, adapter);
let settingsPanel = null;
freeformLayoutManager = new FreeformLayoutManager(adapter, scryingPoolController, stateStore, {
onOpenSettings: () => {
if (settingsPanel && settingsPanel.rendered) {
settingsPanel.close();
settingsPanel = null;
} else {
settingsPanel = new SettingsCls();
settingsPanel.render(true);
}
},
});
// If initial layout is freeform, switch immediately
const initialLayout = adapter.settings?.get?.('dockLayout');
if (initialLayout === 'freeform') {
roleRenderer.closeStrip();
freeformLayoutManager.init();
freeformLayoutManager.sync();
}
}
// Inject Scrying Pool deps into our camera views replacement (all clients)
// Directors Board reference is GM-only — players get null so _onConfigure is a no-op
initScryingPoolCameraViews(
adapter.users.isGM() ? directorsBoard : null,
stateStore
stateStore,
adapter
);
// Pre-load participant-card as a Handlebars partial for directors-board
@@ -428,6 +507,9 @@ Hooks.once("ready", () => {
}
})();
// Story 5.X: Initialize GMActorMappingPanel with DI dependencies
initGMActorMappingPanel(adapter);
// Story 4.1: Initialize PlayerPrivacyPanelMenu with DI dependencies
// Story 4.2: Pass portraitFallbackHandler for portrait selection
initPlayerPrivacyPanelMenu(adapter, playerPrivacyManager, portraitFallbackHandler);
@@ -448,6 +530,16 @@ Hooks.once("ready", () => {
restricted: false,
});
// Story 5.X: Register Actor-Webcam Mapping settings menu (GM only)
game.settings.registerMenu('scrying-pool', 'actorMapping', {
name: 'SCRYING_POOL.ActorMapping.title',
label: 'SCRYING_POOL.ActorMapping.label',
hint: 'SCRYING_POOL.ActorMapping.hint',
icon: 'fa-solid fa-address-card',
type: GMActorMappingPanel,
restricted: true,
});
// Register ScryingPoolSettings in module settings
// Provides button to reopen the strip when user closes it
game.settings.registerMenu('scrying-pool', 'stripSettings', {
@@ -455,7 +547,7 @@ Hooks.once("ready", () => {
label: 'SCRYING_POOL.Settings.Title',
hint: 'SCRYING_POOL.Settings.Hint',
icon: 'fa-solid fa-cog',
type: initScryingPoolSettings(roleRenderer),
type: initScryingPoolSettings(roleRenderer, adapter),
restricted: true, // GM only
});
+1
View File
@@ -2,6 +2,7 @@
"id": "scrying-pool",
"title": "Scrying Pool",
"version": "0.1.0",
"license": "CC-BY-NC-SA-4.0",
"description": "GM camera visibility control for FoundryVTT v14+ — hide, show, and manage participant feeds in real time.",
"authors": [
{
+1
View File
@@ -1,6 +1,7 @@
{
"name": "scrying-pool",
"version": "0.1.0",
"license": "CC-BY-NC-SA-4.0",
"description": "FoundryVTT v14 module — Scrying Pool camera visibility control",
"type": "module",
"scripts": {
+6
View File
@@ -209,6 +209,12 @@ export class FoundryAdapter {
},
};
/** Actors surface — wraps game.actors. */
this.actors = {
all: () => Array.from(g.actors ?? []),
get: (id) => g.actors?.get(id) ?? null,
};
/** Scenes surface — wraps game.scenes. */
this.scenes = {
/**
+1
View File
@@ -371,6 +371,7 @@ export class DirectorsBoard extends _AppBase {
{ key: 'horizontal-md', icon: 'fa-grip-horizontal', size: 'L', sepAfter: true },
{ key: 'mosaic-sm', icon: 'fa-border-all', size: 'S', sepAfter: false },
{ key: 'mosaic-md', icon: 'fa-border-all', size: 'L', sepAfter: false },
{ key: 'freeform', icon: 'fa-window-restore', size: 'W', sepAfter: false },
];
const dockLayouts = DOCK_LAYOUTS.map(l => ({
...l,
+250
View File
@@ -0,0 +1,250 @@
// @ts-nocheck
const _AppBase =
typeof foundry !== 'undefined' &&
foundry.applications?.api?.HandlebarsApplicationMixin &&
foundry.applications?.api?.ApplicationV2
? foundry.applications.api.HandlebarsApplicationMixin(
foundry.applications.api.ApplicationV2
)
: class _FallbackApp {
static DEFAULT_OPTIONS = {};
static PARTS = {};
constructor(options = {}) { this.options = options; this.position = {}; }
get rendered() { return this._rendered ?? false; }
set rendered(v) { this._rendered = v; }
get element() { return this._element ?? null; }
set element(v) { this._element = v; }
async render() { this._rendered = true; }
async close() { this._rendered = false; }
async _prepareContext() { return {}; }
_onRender() {}
_onClose() {}
_onPosition() {}
setPosition(p) { Object.assign(this.position, p); }
};
/**
* Individual floating camera window for the freeform layout.
* One instance per participant, fully resizable and draggable.
*/
export class FreeformCameraWindow extends _AppBase {
static DEFAULT_OPTIONS = {
classes: ['scrying-pool', 'freeform-camera'],
window: {
icon: 'fas fa-video',
resizable: true,
controls: [
{ action: 'spotlight', icon: 'fas fa-star', title: 'SCRYING_POOL.Freeform.spotlight' },
{ action: 'hide', icon: 'fas fa-eye-slash', title: 'SCRYING_POOL.Freeform.hide' },
],
},
position: { width: 320, height: 300 },
};
static PARTS = {
body: {
template: 'modules/scrying-pool/templates/freeform-camera.hbs',
},
};
/**
* @param {object} params
* @param {string} params.userId
* @param {object} params.adapter - FoundryAdapter instance
* @param {object} params.manager - FreeformLayoutManager instance
* @param {{left:number, top:number, width:number, height:number}} params.position
*/
constructor({ userId, adapter, manager, position }) {
const user = adapter.users.get(userId);
super({
position: { ...position },
window: { title: user?.name ?? userId },
});
this._userId = userId;
this._adapter = adapter;
this._manager = manager;
this._videoElement = null;
this._volume = 1.0;
this._isSpotlight = false;
}
/** @returns {string} */
get userId() { return this._userId; }
/** @param {boolean} val */
set spotlight(val) {
this._isSpotlight = val;
if (this.element) {
this.element.classList.toggle('is-spotlight', val);
}
}
/** @returns {boolean} */
get spotlight() { return this._isSpotlight; }
/** @inheritdoc */
async _prepareContext() {
const user = this._adapter.users.get(this._userId);
const stream = this._adapter.webrtc?.getMediaStreamForUser?.(this._userId);
const hasAudio = stream?.getAudioTracks()?.length > 0;
const audioMuted = hasAudio
? stream.getAudioTracks().every(t => !t.enabled)
: true;
return {
userId: this._userId,
userName: user?.name ?? this._userId,
hasAudio,
audioMuted,
volume: this._volume,
};
}
/** @inheritdoc */
_onRender(context, options) {
super._onRender?.(context, options);
this._attachVideo();
if (this._isSpotlight) {
this.element?.classList.add('is-spotlight');
}
const volumeSlider = this.element?.querySelector('.freeform-camera__volume');
if (volumeSlider) {
volumeSlider.value = String(this._volume);
volumeSlider.addEventListener('input', (e) => {
this._volume = parseFloat(e.target.value);
if (this._videoElement) {
this._videoElement.volume = this._volume;
}
});
}
const micBtn = this.element?.querySelector('[data-action="toggle-mic"]');
if (micBtn) {
micBtn.addEventListener('click', () => this._toggleMic());
}
const videoContainer = this.element?.querySelector('.freeform-camera__video-container');
if (videoContainer) {
videoContainer.addEventListener('dblclick', (e) => {
e.stopPropagation();
this._manager?.openSettings?.();
});
}
}
/**
* Attaches the user's webcam stream as a <video> element.
* Called from _onRender.
*/
_attachVideo() {
if (!this._adapter?.webrtc?.getMediaStreamForUser) return;
if (!this.element) return;
const container = this.element.querySelector('.freeform-camera__video-container');
if (!container) return;
const existing = container.querySelector('video');
if (existing) existing.remove();
const stream = this._adapter.webrtc.getMediaStreamForUser(this._userId);
if (!stream) return;
const video = document.createElement('video');
video.srcObject = stream;
video.autoplay = true;
video.playsInline = true;
video.muted = this._adapter.users.current?.()?.id === this._userId;
video.className = 'freeform-camera__video-element';
video.addEventListener('error', () => {
console.warn('[ScryingPool] Freeform video error for user:', this._userId);
});
container.appendChild(video);
this._videoElement = video;
video.volume = this._volume;
}
/**
* Detaches and cleans up the video element.
*/
_detachVideo() {
if (this._videoElement) {
this._videoElement.pause();
this._videoElement.srcObject = null;
this._videoElement.remove();
this._videoElement = null;
}
const container = this.element?.querySelector('.freeform-camera__video-container');
if (container) {
const els = container.querySelectorAll('video');
els.forEach(v => { v.pause(); v.srcObject = null; v.remove(); });
}
}
/**
* Toggles the local microphone mute state for this participant's stream.
*/
_toggleMic() {
const stream = this._adapter.webrtc?.getMediaStreamForUser?.(this._userId);
if (!stream) return;
const tracks = stream.getAudioTracks();
if (tracks.length === 0) return;
const allMuted = tracks.every(t => !t.enabled);
tracks.forEach(t => { t.enabled = allMuted; });
const btn = this.element?.querySelector('[data-action="toggle-mic"] i');
if (btn) {
btn.className = allMuted ? 'fas fa-microphone' : 'fas fa-microphone-slash';
}
}
/** @inheritdoc */
_onClickWindowControl(event) {
const btn = event.currentTarget;
const action = btn.dataset.action;
if (action === 'spotlight') {
this._manager.setSpotlight(this._userId);
} else if (action === 'hide') {
this._hideParticipant();
}
}
/**
* Hides this participant from the table via the manager.
* @returns {Promise<void>}
*/
async _hideParticipant() {
await this._manager?.hideParticipant(this._userId);
}
/** @inheritdoc */
_onPosition(position) {
super._onPosition?.(position);
this._manager?._scheduleSave();
}
/** @inheritdoc */
async _onClose(options) {
this._detachVideo();
if (!options?._fromManager) {
await this._hideParticipant();
}
await super._onClose?.(options);
}
/**
* Synchronously removes this window from the DOM.
* Bypasses the async ApplicationV2 close lifecycle for reliable cleanup.
* Does NOT hide the participant — used by FreeformLayoutManager on layout switch.
*/
removeDOM() {
this._detachVideo();
if (this.element) {
this.element.remove();
}
this._rendered = false;
}
}
+264
View File
@@ -0,0 +1,264 @@
// @ts-nocheck
import { generateOpId } from '../../utils/uuid.js';
import { FreeformCameraWindow } from './FreeformCameraWindow.js';
/**
* Orchestrates the freeform layout: creates/destroys FreeformCameraWindow instances
* based on participant visibility, saves/restores positions, and manages spotlight.
*/
export class FreeformLayoutManager {
/**
* @param {object} adapter - FoundryAdapter instance
* @param {object} controller - ScryingPoolController instance
* @param {object} stateStore - StateStore instance
* @param {object} [options] - Extra options
* @param {Function} [options.onOpenSettings] - Callback to open the settings panel
*/
constructor(adapter, controller, stateStore, options = {}) {
this._adapter = adapter;
this._controller = controller;
this._stateStore = stateStore;
this._onOpenSettings = options.onOpenSettings;
/** @type {Map<string, FreeformCameraWindow>} */
/** @type {Map<string, FreeformCameraWindow>} */
this._windows = new Map();
this._spotlightUserId = null;
this._cascadeIndex = 0;
this._saveTimer = null;
this._initialized = false;
/** @type {Array<{event: string, handler: Function}>} */
this._hookHandlers = [];
}
/**
* Registers hooks for sync on state/user changes.
* Safe to call multiple times — guards against double init.
*/
init() {
if (this._initialized) return;
this._initialized = true;
const add = (event, handler) => {
this._adapter.hooks.on(event, handler);
this._hookHandlers.push({ event, handler });
};
add('scrying-pool:stateChanged', () => this.sync());
add('updateUser', () => this.sync());
add('userConnected', () => {
setTimeout(() => this.sync(), 1500);
});
}
/**
* Reconciles visible participants with open windows.
* Creates windows for newly visible users, closes for hidden/disconnected.
*/
sync() {
if (!this._initialized) return;
if (!this._adapter.users.isGM()) return;
const users = this._adapter.users.all();
const showGMSelfFeed = this._adapter.settings?.get?.('showGMSelfFeed') ?? true;
const currentUserId = this._adapter.users.current?.()?.id;
const visibleUserIds = users
.filter(u => {
if (u.id === currentUserId && !showGMSelfFeed) return false;
const state = this._stateStore.getState(u.id);
return state !== 'hidden' && state !== 'ghost' && state !== 'offline';
})
.map(u => u.id);
const visibleSet = new Set(visibleUserIds);
const currentSet = new Set(this._windows.keys());
// Close windows for users no longer visible
for (const userId of currentSet) {
if (!visibleSet.has(userId)) {
this._destroyWindow(userId);
}
}
// Create windows for newly visible users
for (const userId of visibleUserIds) {
if (!currentSet.has(userId)) {
this._createWindow(userId);
}
}
// Re-apply spotlight if the spotlighted user is still visible
if (this._spotlightUserId && !visibleSet.has(this._spotlightUserId)) {
this._spotlightUserId = null;
}
}
/**
* Creates a FreeformCameraWindow for a user.
* @param {string} userId
*/
_createWindow(userId) {
const saved = this._loadSavedPosition(userId);
const position = saved ?? this._cascadePosition();
const win = new FreeformCameraWindow({
userId,
adapter: this._adapter,
manager: this,
position,
});
this._windows.set(userId, win);
if (this._spotlightUserId === userId) {
win.spotlight = true;
}
win.render(true);
this._cascadeIndex++;
}
/**
* Closes and removes a FreeformCameraWindow for a user.
* @param {string} userId
*/
_destroyWindow(userId) {
const win = this._windows.get(userId);
if (!win) return;
win.removeDOM();
this._windows.delete(userId);
}
/**
* Returns a cascading position for new windows without saved positions.
* @returns {{left: number, top: number, width: number, height: number}}
*/
_cascadePosition() {
const base = 50;
const step = 30;
const maxOffset = 300;
const offset = (this._cascadeIndex * step) % (maxOffset + step);
return {
left: base + offset,
top: base + offset,
width: 320,
height: 300,
};
}
/**
* Loads a saved window position for a user from the freeformLayout setting.
* @param {string} userId
* @returns {{left:number, top:number, width:number, height:number}|null}
*/
_loadSavedPosition(userId) {
try {
const data = this._adapter.settings?.get?.('freeformLayout');
if (data?.windows?.[userId]) {
const { left, top, width, height } = data.windows[userId];
if (left != null && top != null) {
return { left, top, width: width ?? 320, height: height ?? 300 };
}
}
} catch (_) {}
return null;
}
/**
* Toggles spotlight on a user.
* If already spotlighted, removes spotlight. Otherwise sets it.
* @param {string} userId
*/
setSpotlight(userId) {
if (this._spotlightUserId === userId) {
// Un-spotlight
const win = this._windows.get(userId);
if (win) win.spotlight = false;
this._spotlightUserId = null;
} else {
// Remove current spotlight
if (this._spotlightUserId) {
const old = this._windows.get(this._spotlightUserId);
if (old) old.spotlight = false;
}
// Set new spotlight
const win = this._windows.get(userId);
if (win) win.spotlight = true;
this._spotlightUserId = userId;
}
}
/**
* Hides a participant from the table via the controller.
* Called when a freeform window's hide button or close button is triggered.
* @param {string} userId
*/
hideParticipant(userId) {
const opId = generateOpId();
const baseRevision = this._controller?.getRevision?.(userId) ?? 0;
this._controller?.action('freeform', userId, 'hidden', opId, baseRevision);
}
/**
* Opens the settings/config panel.
* Called when the GM double-clicks a freeform camera window.
*/
openSettings() {
this._onOpenSettings?.();
}
/**
* Schedules a debounced save of all window positions.
*/
_scheduleSave() {
if (this._saveTimer) {
clearTimeout(this._saveTimer);
}
this._saveTimer = setTimeout(() => this._savePositions(), 500);
}
/**
* Persists all window positions to the freeformLayout world setting.
*/
_savePositions() {
const windows = {};
for (const [userId, win] of this._windows) {
const pos = win.position;
windows[userId] = {
left: pos.left,
top: pos.top,
width: pos.width,
height: pos.height,
};
}
this._adapter.settings?.set?.('freeformLayout', { windows }).catch(err => {
console.warn('[ScryingPool] Failed to save freeform layout positions:', err);
});
}
/**
* Closes all windows and cleans up hooks. Safe to call multiple times.
*/
destroy() {
for (const [userId] of this._windows) {
this._destroyWindow(userId);
}
this._windows.clear();
this._spotlightUserId = null;
this._cascadeIndex = 0;
for (const { event, handler } of this._hookHandlers) {
try { this._adapter.hooks.off(event, handler); } catch (_) {}
}
this._hookHandlers = [];
this._initialized = false;
if (this._saveTimer) {
clearTimeout(this._saveTimer);
this._saveTimer = null;
}
}
}
+124
View File
@@ -0,0 +1,124 @@
/**
* GMActorMappingPanel — GM settings submenu for assigning actors to users
* for the Token Video Overlay feature.
*
* The GM maps an actor to a user's webcam feed. When "Show Webcam Video on
* Tokens" is enabled, any token of the mapped actor will display that user's
* webcam instead of the token icon.
*
* Extends ApplicationV2 via HandlebarsApplicationMixin.
* Uses module-level _adapter for DI (same pattern as PlayerPrivacyPanelMenu).
*
* @module ui/gm/GMActorMappingPanel
*/
/** @type {import('../../foundry/FoundryAdapter.js').FoundryAdapter|null} */
let _adapter = null;
/**
* Initialize static adapter reference. Called once from module.js ready hook.
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
*/
export function initGMActorMappingPanel(adapter) {
_adapter = adapter;
}
const _AppBase =
typeof foundry !== 'undefined' &&
foundry.applications?.api?.HandlebarsApplicationMixin &&
foundry.applications?.api?.ApplicationV2
? foundry.applications.api.HandlebarsApplicationMixin(
foundry.applications.api.ApplicationV2
)
: class _FallbackApp {
static DEFAULT_OPTIONS = {};
static PARTS = {};
get rendered() { return this._rendered ?? false; }
set rendered(v) { this._rendered = v; }
get element() { return this._element ?? null; }
set element(v) { this._element = v; }
async render() { this._rendered = true; }
async close() { this._rendered = false; }
async _prepareContext() { return {}; }
_onRender() {}
_onClose() {}
_onPosition() {}
};
/**
* GM Actor Mapping Panel — assign actors to user webcams for token overlay.
*/
export class GMActorMappingPanel extends _AppBase {
static DEFAULT_OPTIONS = {
id: 'scrying-pool-actor-mapping',
classes: ['scrying-pool', 'actor-mapping'],
window: {
title: 'SCRYING_POOL.ActorMapping.title',
resizable: false,
width: 450,
height: 'auto',
},
position: {},
};
static PARTS = {
form: {
template: 'modules/scrying-pool/templates/actor-mapping.hbs',
},
};
async _prepareContext() {
const mapping = _adapter?.settings?.get('userActorMapping') ?? {};
const selectedId = (uid) => mapping[uid] ?? '';
const sortedActors = _adapter.actors.all()
.map(a => ({ id: a.id, name: a.name }))
.sort((a, b) => a.name.localeCompare(b.name));
const users = _adapter.users.all()
.map(u => ({
id: u.id,
name: u.name,
isGM: u.isGM,
avatar: u.avatar ?? 'icons/svg/mystery-man.svg',
actors: sortedActors.map(a => ({
id: a.id,
name: a.name,
selected: a.id === selectedId(u.id),
})),
}))
.sort((a, b) => {
if (a.isGM && !b.isGM) return -1;
if (!a.isGM && b.isGM) return 1;
return a.name.localeCompare(b.name);
});
return {
hasNoUsers: users.length === 0,
users,
};
}
_onRender(context, options) {
if (this._formHandlerAttached) return;
this._formHandlerAttached = true;
const form = this.element?.querySelector('form');
if (!form) return;
form.addEventListener('change', (event) => {
const select = event.target;
if (select.tagName !== 'SELECT') return;
const mapping = { ...(_adapter?.settings?.get('userActorMapping') ?? {}) };
if (select.value) {
mapping[select.name] = select.value;
} else {
delete mapping[select.name];
}
_adapter?.settings?.set('userActorMapping', mapping).catch(err => {
console.error('[ScryingPool] Failed to save actor mapping:', err);
});
});
}
}
+62 -19
View File
@@ -43,18 +43,39 @@ export class ScryingPoolSettings extends _AppBase {
};
/**
* @param {object} options - Application options
* @param {object} roleRenderer - The role renderer instance to access openStrip/closeStrip
* @param {object} adapter - FoundryAdapter instance
* @param {object} options - Application options
*/
constructor(roleRenderer, options = {}) {
constructor(roleRenderer, adapter, options = {}) {
super(options);
this._roleRenderer = roleRenderer;
this._adapter = adapter;
this._changingLayout = false;
}
/** @inheritdoc */
async _prepareContext(options) {
const currentDockLayout = this._adapter?.settings?.get?.('dockLayout') ?? 'vertical-sm';
const DOCK_LAYOUTS = [
{ key: 'vertical-sm', icon: 'fa-grip-vertical', size: 'S', sepAfter: false },
{ key: 'vertical-md', icon: 'fa-grip-vertical', size: 'L', sepAfter: true },
{ key: 'horizontal-sm', icon: 'fa-grip-horizontal', size: 'S', sepAfter: false },
{ key: 'horizontal-md', icon: 'fa-grip-horizontal', size: 'L', sepAfter: true },
{ key: 'mosaic-sm', icon: 'fa-border-all', size: 'S', sepAfter: false },
{ key: 'mosaic-md', icon: 'fa-border-all', size: 'L', sepAfter: false },
{ key: 'freeform', icon: 'fa-window-restore', size: 'W', sepAfter: false },
];
const dockLayouts = DOCK_LAYOUTS.map(l => ({
...l,
isActive: l.key === currentDockLayout,
label: l.key,
}));
return {
hasStrip: this._roleRenderer?._strip?.rendered ?? false,
dockLayouts,
currentDockLayout,
};
}
@@ -62,28 +83,50 @@ export class ScryingPoolSettings extends _AppBase {
async _onRender(context, options) {
super._onRender(context, options);
// Add click handler for window close button
const windowCloseBtn = this.element.querySelector('[data-action="close"]');
if (windowCloseBtn) {
windowCloseBtn.addEventListener('click', () => this.close());
}
// Prevent re-entrant layout changes
if (this._changingLayout) return;
// Add click handler for reopen button
const reopenBtn = this.element.querySelector('[data-action="reopen-strip"]');
if (reopenBtn) {
reopenBtn.addEventListener('click', () => {
// 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;
}
}
+2 -2
View File
@@ -180,8 +180,8 @@ export class ScryingPoolStrip extends _AppBase {
const screenW = typeof window !== 'undefined' ? (window.screen?.availWidth ?? Infinity) : Infinity;
const screenH = typeof window !== 'undefined' ? (window.screen?.availHeight ?? Infinity) : Infinity;
if (saved.left < screenW - 50 && saved.top < screenH - 50) {
if (this.options?.position) {
Object.assign(this.options.position, { left: saved.left, top: saved.top });
if (typeof this.setPosition === 'function') {
this.setPosition({ left: saved.left, top: saved.top });
}
}
}
+133 -3
View File
@@ -1,12 +1,15 @@
/**
* ScryingPoolCameraViews — replaces Foundry's CameraViews as CONFIG.ui.webrtc.
*
* Two responsibilities:
* Three responsibilities:
* 1. Redirect the "configure" camera action to the Scrying Pool Directors Board
* instead of Foundry's native AVConfig dialog.
* 2. Inject the Scrying Pool visibility state (sp-cam-hidden) into each user's
* camera context so the dock reflects the same hidden/active state as the
* module's state machine.
* 3. Monitor local video streams and auto-recover from browser-level mute/ended
* events that cause gray/black video in the player's self-view while other
* peers remain unaffected. (Bug fix: stream health monitoring)
*
* Set as CONFIG.ui.webrtc in the 'init' hook (before Foundry instantiates ui.webrtc).
* Dependencies are injected after 'ready' via initScryingPoolCameraViews().
@@ -23,11 +26,16 @@ function _getCameraViewsBase() {
return class _FallbackCameraViews {
static DEFAULT_OPTIONS = {};
static PARTS = {};
constructor(options = {}) { this.options = options; }
constructor(options = {}) {
this.options = options;
this._healthCheckInterval = null;
}
async render() {}
async close() {}
_prepareUserContext(_id) { return {}; }
_onConfigure() {}
_onRender() {}
getUserVideoElement(_userId) { return null; }
};
}
@@ -37,17 +45,25 @@ let _directorsBoard = null;
/** @type {object|null} StateStore instance — set via initScryingPoolCameraViews */
let _stateStore = null;
/** @type {object|null} FoundryAdapter instance — set via initScryingPoolCameraViews */
let _adapter = null;
/**
* Inject module dependencies. Called from module.js after 'ready' resolves.
* @param {object|null} directorsBoard - The singleton DirectorsBoard (GM only, else null)
* @param {object} stateStore - The module StateStore
* @param {object|null} adapter - The FoundryAdapter instance (for webrtc surface)
*/
export function initScryingPoolCameraViews(directorsBoard, stateStore) {
export function initScryingPoolCameraViews(directorsBoard, stateStore, adapter) {
_directorsBoard = directorsBoard;
_stateStore = stateStore;
_adapter = adapter;
}
export class ScryingPoolCameraViews extends _getCameraViewsBase() {
/** @type {number|null} */
_healthCheckInterval = null;
/**
* Intercept the configure camera button.
* Opens the Scrying Pool Directors Board instead of Foundry's AVConfig dialog.
@@ -80,4 +96,118 @@ export class ScryingPoolCameraViews extends _getCameraViewsBase() {
if (ctx && spState === 'hidden') ctx.spHidden = true;
return ctx;
}
/**
* Start periodic stream health monitoring after the dock is first rendered.
* Only starts once — guarded by _healthCheckInterval.
* @override
*/
_onRender(context, options) {
super._onRender?.(context, options);
if (_adapter?.webrtc && this._healthCheckInterval === null) {
this._healthCheckInterval = setInterval(
() => this._checkVideoStreamHealth(),
30000
);
}
}
/**
* Clean up health check interval when the view is closed.
* @override
*/
_onClose() {
if (this._healthCheckInterval !== null) {
clearInterval(this._healthCheckInterval);
this._healthCheckInterval = null;
}
super._onClose?.();
}
/**
* Periodic health check on all video tiles in the camera dock.
* Detects muted/ended tracks and re-acquires the MediaStream to
* recover from gray/black video without requiring a page reload.
* Same pattern as ScryingPoolStrip._checkVideoStreamHealth().
*/
_checkVideoStreamHealth() {
if (!this.element) return;
try {
const userIds = _adapter?.webrtc?.getConnectedUsers?.() ?? [];
for (const userId of userIds) {
const videoEl = this.getUserVideoElement(userId);
if (!videoEl) continue;
const stream = videoEl.srcObject;
if (!(stream instanceof MediaStream)) continue;
const videoTracks = stream.getVideoTracks();
// Track permanently ended → re-acquire stream
if (videoTracks.some(t => t.readyState === 'ended')) {
this._refreshUserVideo(userId);
continue;
}
// Track muted at browser level (tab backgrounded, camera contention)
if (videoTracks.some(t => t.muted)) {
this._scheduleMuteCheck(userId, stream);
continue;
}
// Video element not producing frames despite having a stream
if (videoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
this._refreshUserVideo(userId);
}
}
} catch (err) {
console.error('[ScryingPool] CameraViews stream health check failed:', err);
}
}
/**
* Debounced mute check: waits 3s then refreshes if the track remains muted.
* Avoids unnecessary re-attach on transient browser-level mutes.
* @param {string} userId
* @param {MediaStream} stream - Original stream to detect replacement
*/
_scheduleMuteCheck(userId, stream) {
const key = `_muteTimer_${userId}`;
if (this[key] !== undefined) return;
this[key] = setTimeout(() => {
this[key] = undefined;
try {
const videoEl = this.getUserVideoElement(userId);
if (!videoEl || videoEl.srcObject !== stream) return;
if (stream.getVideoTracks().some(t => t.muted)) {
this._refreshUserVideo(userId);
}
} catch (err) {
console.error('[ScryingPool] CameraViews mute check failed:', err);
}
}, 3000);
}
/**
* Re-acquires and re-attaches a user's MediaStream on the video element.
* Resetting srcObject forces the browser to restart the media pipeline.
* @param {string} userId
*/
_refreshUserVideo(userId) {
try {
const stream = _adapter?.webrtc?.getMediaStreamForUser?.(userId);
if (!(stream instanceof MediaStream)) return;
const videoEl = this.getUserVideoElement(userId);
if (!videoEl) return;
videoEl.srcObject = null;
videoEl.srcObject = stream;
} catch (err) {
console.error('[ScryingPool] CameraViews refresh video failed:', err);
}
}
}
+282
View File
@@ -0,0 +1,282 @@
// @ts-nocheck
/**
* TokenVideoOverlay — Optionally overlays player webcam video on their character
* tokens on the active scene. 100% optional, toggled via world setting.
*
* Architecture:
* - Replaces token.mesh texture with a video texture from the user's webcam
* - Patches Token.prototype.refresh to re-apply after redraws
* - Subscribes to canvasReady, createToken, deleteToken, updateToken hooks
* - When disabled, restores original texture on all meshes
*
* @module ui/shared/TokenVideoOverlay
*/
export class TokenVideoOverlay {
/**
* @param {object} adapter - FoundryAdapter instance
*/
constructor(adapter) {
this._adapter = adapter;
/** @type {Map<string, { videoEl: HTMLVideoElement, origTexture: PIXI.Texture, userId: string, canvas?: HTMLCanvasElement, ctx?: CanvasRenderingContext2D, pixiTexture?: PIXI.Texture, rafId?: number }>} */
this._overlays = new Map();
/** @type {Set<string>} */
this._pending = new Set();
/** @type {Function|null} */
this._origRefresh = null;
/** @type {boolean} */
this._enabled = false;
}
init() {
this._enabled = this._adapter.settings.get('showVideoOnTokens') ?? false;
const TokenClass = foundry?.canvas?.placeables?.Token ?? globalThis.Token;
if (!TokenClass?.prototype?.refresh) {
console.warn('[ScryingPool] TokenVideoOverlay: Token class not found');
}
if (TokenClass?.prototype?.refresh) {
this._origRefresh = TokenClass.prototype.refresh;
const self = this;
TokenClass.prototype.refresh = function (...args) {
const result = self._origRefresh.apply(this, args);
if (self._enabled) self._onTokenRefreshed(this);
return result;
};
}
Hooks.on('canvasReady', () => { if (this._enabled) this.syncAll(); });
Hooks.on('createToken', (doc) => { if (this._enabled) this._onCreateToken(doc); });
Hooks.on('deleteToken', (doc) => { if (this._enabled) this._detach(doc.id); });
Hooks.on('updateToken', (doc) => { if (this._enabled) this._onUpdateToken(doc); });
if (this._enabled) this.syncAll();
}
enable() {
this._enabled = true;
this.syncAll();
}
disable() {
this._enabled = false;
this._cleanupAll();
}
syncAll() {
if (!canvas?.tokens?.placeables) {
return;
}
const processed = new Set();
for (const token of canvas.tokens.placeables) {
const key = this._tokenKey(token);
processed.add(key);
if (!this._overlays.has(key) && !this._pending.has(key)) this._attach(token);
}
for (const [key] of this._overlays) {
if (!processed.has(key)) this._detachByKey(key);
}
}
_getOwningUserId(token) {
const actor = token.document?.actor;
if (!actor) return null;
const mapping = this._adapter.settings.get('userActorMapping') ?? {};
for (const [userId, actorId] of Object.entries(mapping)) {
if (actorId === actor.id) {
const user = this._adapter.users.get(userId);
if (user && user.active) return userId;
}
}
for (const user of this._adapter.users.all()) {
if (user.isGM || !user.active) continue;
if (typeof actor.testUserPermission === 'function') {
if (actor.testUserPermission(user, CONST.DOCUMENT_PERMISSION_LEVELS.OWNER)) {
return user.id;
}
}
}
return null;
}
_tokenKey(token) {
return `${canvas.scene?.id ?? '?'}.${token.id}`;
}
_attach(token) {
if (!token?.mesh) return;
const key = this._tokenKey(token);
if (this._pending.has(key)) return;
this._pending.add(key);
const userId = this._getOwningUserId(token);
if (!userId) {
this._pending.delete(key);
return;
}
const stream = this._adapter.webrtc?.getMediaStreamForUser?.(userId);
if (!stream?.getVideoTracks().length) {
this._pending.delete(key);
return;
}
const videoEl = document.createElement('video');
videoEl.srcObject = stream;
videoEl.autoplay = true;
videoEl.muted = true;
videoEl.playsInline = true;
videoEl.setAttribute('aria-hidden', 'true');
videoEl.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;';
document.body.appendChild(videoEl);
videoEl.play().catch(() => {});
requestAnimationFrame(() => {
this._pending.delete(key);
if (!this._enabled || !token?.mesh) {
videoEl.remove();
return;
}
const origTexture = token.mesh.texture;
const w = Math.max(1, Math.round(token.mesh.width));
const h = Math.max(1, Math.round(token.mesh.height));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (ctx) {
const radius = Math.min(w, h) / 2;
const cx = w / 2;
const cy = h / 2;
const pixiTexture = PIXI.Texture.from(canvas);
token.mesh.texture = pixiTexture;
const renderFrame = () => {
const overlay = this._overlays.get(key);
if (!overlay || !this._enabled) return;
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(videoEl, 0, 0, w, h);
ctx.restore();
pixiTexture.baseTexture.update();
overlay.rafId = requestAnimationFrame(renderFrame);
};
const rafId = requestAnimationFrame(renderFrame);
this._overlays.set(key, { videoEl, origTexture, userId, canvas, ctx, pixiTexture, rafId });
} else {
// Fallback: canvas 2D unavailable (e.g. test env), use direct video texture
token.mesh.texture = PIXI.Texture.from(videoEl);
this._overlays.set(key, { videoEl, origTexture, userId });
}
});
}
_detach(tokenId) {
const sceneId = canvas.scene?.id ?? '?';
this._detachByKey(`${sceneId}.${tokenId}`);
}
_detachByKey(key) {
const overlay = this._overlays.get(key);
if (!overlay) return;
if (overlay.rafId) cancelAnimationFrame(overlay.rafId);
const tokenId = key.split('.').pop();
const token = canvas.tokens?.get(tokenId);
if (token?.mesh && overlay.origTexture) {
token.mesh.texture = overlay.origTexture;
}
overlay.videoEl?.pause();
overlay.videoEl?.remove();
this._overlays.delete(key);
}
_onTokenRefreshed(token) {
const key = this._tokenKey(token);
const overlay = this._overlays.get(key);
if (overlay && token?.mesh) {
if (overlay.canvas) {
const w = Math.max(1, Math.round(token.mesh.width));
const h = Math.max(1, Math.round(token.mesh.height));
if (overlay.canvas.width !== w || overlay.canvas.height !== h) {
this._detachByKey(key);
this._attach(token);
return;
}
if (token.mesh.texture !== overlay.pixiTexture) {
token.mesh.texture = overlay.pixiTexture;
}
} else {
const tex = token.mesh.texture;
const src = tex?.baseTexture?.resource?.source;
if (!src || src.tagName !== 'VIDEO' || src !== overlay.videoEl) {
token.mesh.texture = PIXI.Texture.from(overlay.videoEl);
}
}
} else if (!overlay && !this._pending.has(key)) {
this._attach(token);
}
}
_onCreateToken(doc) {
requestAnimationFrame(() => {
const token = canvas.tokens?.get(doc.id);
if (token) this._attach(token);
});
}
_onUpdateToken(doc) {
const token = canvas.tokens?.get(doc.id);
if (!token) return;
const key = this._tokenKey(token);
const existing = this._overlays.get(key);
const userId = this._getOwningUserId(token);
if (!userId) {
if (existing) this._detachByKey(key);
return;
}
if (existing && existing.userId === userId) {
if (token?.mesh) {
if (existing.pixiTexture) {
if (token.mesh.texture !== existing.pixiTexture) {
token.mesh.texture = existing.pixiTexture;
}
} else {
const tex = token.mesh.texture;
const src = tex?.baseTexture?.resource?.source;
if (!src || src.tagName !== 'VIDEO' || src !== existing.videoEl) {
token.mesh.texture = PIXI.Texture.from(existing.videoEl);
}
}
}
return;
}
if (existing) this._detachByKey(key);
this._attach(token);
}
_cleanupAll() {
for (const [key] of this._overlays) this._detachByKey(key);
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Actor-Mapping Panel styles.
*
* GM settings submenu to assign actors to user webcams for token overlay.
* Uses SP (Scrying Pool) semantic token system.
*/
@import "../tokens/_base.less";
.scrying-pool {
&.actor-mapping {
background: var(--sp-dialog-bg);
color: var(--sp-text-primary);
font-family: var(--font-primary, inherit);
border: var(--sp-dialog-border);
border-top: 2px solid var(--sp-accent);
border-radius: var(--sp-dialog-radius);
box-shadow: var(--sp-dialog-shadow);
}
.sp-actor-mapping {
display: flex;
flex-direction: column;
min-width: 320px;
max-width: 500px;
}
.sp-actor-mapping__hint {
margin: 0 0 12px 0;
font-size: 12px;
color: var(--sp-text-secondary);
line-height: 1.4;
padding: 12px 12px 0;
}
.sp-actor-mapping__empty {
text-align: center;
color: var(--sp-text-muted);
font-size: 13px;
padding: 24px 12px;
margin: 0;
}
.sp-actor-mapping__table {
display: flex;
flex-direction: column;
padding: 0 12px 12px;
gap: 6px;
}
.sp-actor-mapping__row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--sp-surface);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius-md);
}
.sp-actor-mapping__user {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.sp-actor-mapping__user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.sp-actor-mapping__user-name {
font-size: 12px;
font-weight: 600;
color: var(--sp-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sp-actor-mapping__badge {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--sp-accent);
border: 1px solid var(--sp-accent);
border-radius: 3px;
padding: 1px 4px;
flex-shrink: 0;
line-height: 1.2;
opacity: 0.85;
}
.sp-actor-mapping__select {
flex-shrink: 0;
width: 180px;
padding: 4px 6px;
font-size: 12px;
font-family: var(--font-primary, inherit);
background: var(--sp-control-bg);
color: var(--sp-text-primary);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius-sm);
cursor: pointer;
}
.sp-actor-mapping__select:focus-visible {
outline: none;
box-shadow: var(--sp-focus-ring);
}
}
+200
View File
@@ -0,0 +1,200 @@
//
// _freeform-camera.less — Floating camera window styles for freeform layout
//
//
// _freeform-camera.less — Floating camera window styles for freeform layout
//
// DOM structure (Foundry v14 ApplicationV2):
// section.app-v2.application.window-content.scrying-pool.freeform-camera
// header.window-header ← title bar
// section.window-content ← inner wrapper for PARTS
// section.freeform-camera__body ← our template root
// div.freeform-camera__video-container
// div.freeform-camera__footer
// footer.window-resizable-handle ← resize grip
//
// ── Outer app element ─────────────────────────────────────────────────────
// Override Foundry's defaults with higher specificity + !important.
.scrying-pool.freeform-camera {
border-radius: 4px !important;
overflow: hidden !important;
}
// ── Window header — ultra-compact ─────────────────────────────────────────
.scrying-pool.freeform-camera > header.window-header {
padding: 1px 6px !important;
min-height: 0 !important;
height: 22px !important;
line-height: 20px !important;
background: rgba(0, 0, 0, 0.75) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
flex-shrink: 0;
gap: 2px;
}
.scrying-pool.freeform-camera .window-title {
font-size: 10px !important;
line-height: 18px !important;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #dde2e8;
}
.scrying-pool.freeform-camera .window-controls {
gap: 1px;
}
.scrying-pool.freeform-camera .window-control {
width: 18px !important;
height: 18px !important;
line-height: 18px !important;
font-size: 10px !important;
padding: 0 !important;
margin: 0 !important;
border-radius: 2px !important;
display: flex !important;
align-items: center;
justify-content: center;
i {
font-size: 10px !important;
}
&:hover {
background: rgba(255, 255, 255, 0.15) !important;
}
}
.scrying-pool.freeform-camera .window-control.close {
font-size: 13px !important;
font-weight: 400;
}
// ── Inner content wrapper (Foundry's .window-content) ─────────────────────
.scrying-pool.freeform-camera > .window-content {
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-height: 0 !important;
overflow: hidden !important;
background: #000;
}
// ── Our body inside inner wrapper ─────────────────────────────────────────
.scrying-pool.freeform-camera .freeform-camera__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
color: #dde2e8;
}
.scrying-pool.freeform-camera .freeform-camera__video-container {
flex: 1;
position: relative;
min-height: 0;
overflow: hidden;
background: #000;
}
.scrying-pool.freeform-camera .freeform-camera__video-element {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
// ── Footer ────────────────────────────────────────────────────────────────
.scrying-pool.freeform-camera .freeform-camera__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1px 6px;
gap: 4px;
background: rgba(0, 0, 0, 0.55);
flex-shrink: 0;
height: 18px;
}
.scrying-pool.freeform-camera .freeform-camera__footer-left {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.scrying-pool.freeform-camera .freeform-camera__footer-right {
display: flex;
align-items: center;
flex-shrink: 0;
}
.scrying-pool.freeform-camera .freeform-camera__control-btn {
background: none;
border: none;
color: #dde2e8;
cursor: pointer;
padding: 1px 3px;
font-size: 9px;
line-height: 1;
border-radius: 2px;
transition: background 0.15s;
&:hover { background: rgba(255, 255, 255, 0.15); }
&:active { background: rgba(255, 255, 255, 0.25); }
i { font-size: 9px; }
}
.scrying-pool.freeform-camera .freeform-camera__name {
font-size: 9px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1;
}
// ── Volume slider ─────────────────────────────────────────────────────────
.scrying-pool.freeform-camera .freeform-camera__volume {
width: 44px;
height: 10px;
accent-color: #4a9eff;
cursor: pointer;
margin: 0;
&::-webkit-slider-thumb {
appearance: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4a9eff;
border: none;
}
&::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4a9eff;
border: none;
}
&::-webkit-slider-runnable-track { height: 2px; }
&::-moz-range-track { height: 2px; }
}
// ── Spotlight glow ────────────────────────────────────────────────────────
.scrying-pool.freeform-camera.is-spotlight {
box-shadow: 0 0 10px 2px #ffd700;
border-color: #ffd700;
}
.scrying-pool.freeform-camera.is-spotlight > header.window-header {
border-bottom-color: #ffd700 !important;
}
+272
View File
@@ -990,6 +990,16 @@
.sp-participant-avatar.sp-state-pending .sp-avatar__shell::after {
box-shadow: inset 0 0 0 2px var(--sp-state-color);
}
.sp-participant-avatar.sp-state-focused .sp-avatar__shell::after {
box-shadow: inset 0 0 0 2px var(--sp-urgency-director);
}
.sp-strip__participant-item.sp-dragging {
opacity: 0.3;
}
.sp-strip__participant-item.sp-drag-over {
box-shadow: inset 0 0 0 2px var(--sp-urgency-director);
border-radius: 4px;
}
@media (prefers-reduced-motion: no-preference) {
.sp-participant-avatar.sp-state-pending .sp-avatar__shell::after {
animation: sp-pulse 2s ease-in-out infinite;
@@ -2932,6 +2942,268 @@ dialog.sp-visibility-details-panel::backdrop {
text-transform: uppercase;
letter-spacing: 0.03em;
}
/**
* Actor-Mapping Panel styles.
*
* GM settings submenu to assign actors to user webcams for token overlay.
* Uses SP (Scrying Pool) semantic token system.
*/
.scrying-pool.actor-mapping {
background: var(--sp-dialog-bg);
color: var(--sp-text-primary);
font-family: var(--font-primary, inherit);
border: var(--sp-dialog-border);
border-top: 2px solid var(--sp-accent);
border-radius: var(--sp-dialog-radius);
box-shadow: var(--sp-dialog-shadow);
}
.scrying-pool .sp-actor-mapping {
display: flex;
flex-direction: column;
min-width: 320px;
max-width: 500px;
}
.scrying-pool .sp-actor-mapping__hint {
margin: 0 0 12px 0;
font-size: 12px;
color: var(--sp-text-secondary);
line-height: 1.4;
padding: 12px 12px 0;
}
.scrying-pool .sp-actor-mapping__empty {
text-align: center;
color: var(--sp-text-muted);
font-size: 13px;
padding: 24px 12px;
margin: 0;
}
.scrying-pool .sp-actor-mapping__table {
display: flex;
flex-direction: column;
padding: 0 12px 12px;
gap: 6px;
}
.scrying-pool .sp-actor-mapping__row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--sp-surface);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius-md);
}
.scrying-pool .sp-actor-mapping__user {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.scrying-pool .sp-actor-mapping__user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.scrying-pool .sp-actor-mapping__user-name {
font-size: 12px;
font-weight: 600;
color: var(--sp-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scrying-pool .sp-actor-mapping__badge {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--sp-accent);
border: 1px solid var(--sp-accent);
border-radius: 3px;
padding: 1px 4px;
flex-shrink: 0;
line-height: 1.2;
opacity: 0.85;
}
.scrying-pool .sp-actor-mapping__select {
flex-shrink: 0;
width: 180px;
padding: 4px 6px;
font-size: 12px;
font-family: var(--font-primary, inherit);
background: var(--sp-control-bg);
color: var(--sp-text-primary);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius-sm);
cursor: pointer;
}
.scrying-pool .sp-actor-mapping__select:focus-visible {
outline: none;
box-shadow: var(--sp-focus-ring);
}
.scrying-pool.freeform-camera {
border-radius: 4px !important;
overflow: hidden !important;
}
.scrying-pool.freeform-camera > header.window-header {
padding: 1px 6px !important;
min-height: 0 !important;
height: 22px !important;
line-height: 20px !important;
background: rgba(0, 0, 0, 0.75) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
flex-shrink: 0;
gap: 2px;
}
.scrying-pool.freeform-camera .window-title {
font-size: 10px !important;
line-height: 18px !important;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #dde2e8;
}
.scrying-pool.freeform-camera .window-controls {
gap: 1px;
}
.scrying-pool.freeform-camera .window-control {
width: 18px !important;
height: 18px !important;
line-height: 18px !important;
font-size: 10px !important;
padding: 0 !important;
margin: 0 !important;
border-radius: 2px !important;
display: flex !important;
align-items: center;
justify-content: center;
}
.scrying-pool.freeform-camera .window-control i {
font-size: 10px !important;
}
.scrying-pool.freeform-camera .window-control:hover {
background: rgba(255, 255, 255, 0.15) !important;
}
.scrying-pool.freeform-camera .window-control.close {
font-size: 13px !important;
font-weight: 400;
}
.scrying-pool.freeform-camera > .window-content {
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-height: 0 !important;
overflow: hidden !important;
background: #000;
}
.scrying-pool.freeform-camera .freeform-camera__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
color: #dde2e8;
}
.scrying-pool.freeform-camera .freeform-camera__video-container {
flex: 1;
position: relative;
min-height: 0;
overflow: hidden;
background: #000;
}
.scrying-pool.freeform-camera .freeform-camera__video-element {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.scrying-pool.freeform-camera .freeform-camera__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1px 6px;
gap: 4px;
background: rgba(0, 0, 0, 0.55);
flex-shrink: 0;
height: 18px;
}
.scrying-pool.freeform-camera .freeform-camera__footer-left {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.scrying-pool.freeform-camera .freeform-camera__footer-right {
display: flex;
align-items: center;
flex-shrink: 0;
}
.scrying-pool.freeform-camera .freeform-camera__control-btn {
background: none;
border: none;
color: #dde2e8;
cursor: pointer;
padding: 1px 3px;
font-size: 9px;
line-height: 1;
border-radius: 2px;
transition: background 0.15s;
}
.scrying-pool.freeform-camera .freeform-camera__control-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.scrying-pool.freeform-camera .freeform-camera__control-btn:active {
background: rgba(255, 255, 255, 0.25);
}
.scrying-pool.freeform-camera .freeform-camera__control-btn i {
font-size: 9px;
}
.scrying-pool.freeform-camera .freeform-camera__name {
font-size: 9px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1;
}
.scrying-pool.freeform-camera .freeform-camera__volume {
width: 44px;
height: 10px;
accent-color: #4a9eff;
cursor: pointer;
margin: 0;
}
.scrying-pool.freeform-camera .freeform-camera__volume::-webkit-slider-thumb {
appearance: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4a9eff;
border: none;
}
.scrying-pool.freeform-camera .freeform-camera__volume::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4a9eff;
border: none;
}
.scrying-pool.freeform-camera .freeform-camera__volume::-webkit-slider-runnable-track {
height: 2px;
}
.scrying-pool.freeform-camera .freeform-camera__volume::-moz-range-track {
height: 2px;
}
.scrying-pool.freeform-camera.is-spotlight {
box-shadow: 0 0 10px 2px #ffd700;
border-color: #ffd700;
}
.scrying-pool.freeform-camera.is-spotlight > header.window-header {
border-bottom-color: #ffd700 !important;
}
/*
* VisibilityBadge :root exception
* ─────────────────────────────────────────────────────────────────────────────
+4
View File
@@ -33,6 +33,10 @@
@import "components/_preset-import-export.less";
// Story 4.1: Player Privacy Panel
@import "components/_player-privacy-panel.less";
// Story 5.X: Actor-Webcam Mapping Panel
@import "components/_actor-mapping.less";
// Story 5.3: Freeform Layout — Floating Camera Windows
@import "components/_freeform-camera.less";
/*
* VisibilityBadge :root exception
+25
View File
@@ -0,0 +1,25 @@
{{!-- Actor-Webcam Mapping Panel --}}
<form class="sp-actor-mapping">
{{#if hasNoUsers}}
<p class="sp-actor-mapping__empty">{{localize "SCRYING_POOL.ActorMapping.noUsers"}}</p>
{{else}}
<p class="sp-actor-mapping__hint">{{localize "SCRYING_POOL.ActorMapping.description"}}</p>
<div class="sp-actor-mapping__table">
{{#each users}}
<div class="sp-actor-mapping__row">
<div class="sp-actor-mapping__user">
<img class="sp-actor-mapping__user-avatar" src="{{this.avatar}}" alt="">
<span class="sp-actor-mapping__user-name">{{this.name}}</span>
{{#if this.isGM}}<span class="sp-actor-mapping__badge">{{localize "SCRYING_POOL.ActorMapping.gmBadge"}}</span>{{/if}}
</div>
<select name="{{this.id}}" class="sp-actor-mapping__select">
<option value="">{{localize "SCRYING_POOL.ActorMapping.noneOption"}}</option>
{{#each this.actors}}
<option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>{{this.name}}</option>
{{/each}}
</select>
</div>
{{/each}}
</div>
{{/if}}
</form>
+23
View File
@@ -0,0 +1,23 @@
{{!-- Freeform camera window body --}}
<section class="freeform-camera__body" data-user-id="{{userId}}">
<div class="freeform-camera__video-container"></div>
<div class="freeform-camera__footer">
<div class="freeform-camera__footer-left">
<button type="button" class="freeform-camera__control-btn freeform-camera__mic-btn"
data-action="toggle-mic"
data-tooltip="{{localize 'SCRYING_POOL.Freeform.toggleMic'}}">
{{#if audioMuted}}
<i class="fas fa-microphone-slash"></i>
{{else}}
<i class="fas fa-microphone"></i>
{{/if}}
</button>
<span class="freeform-camera__name">{{userName}}</span>
</div>
<div class="freeform-camera__footer-right">
<input type="range" class="freeform-camera__volume" min="0" max="1" step="0.05" value="{{volume}}"
data-action="set-volume"
data-tooltip="{{localize 'SCRYING_POOL.Freeform.volume'}}">
</div>
</div>
</section>
+62
View File
@@ -31,6 +31,25 @@
{{/if}}
</div>
</div>
<!-- Layout selector -->
<div class="scrying-pool-settings__content">
<div class="scrying-pool-settings__status">
<label class="scrying-pool-settings__label">View Layout</label>
<div class="scrying-pool-settings__layout-group">
{{#each dockLayouts}}
<button type="button"
class="scrying-pool-settings__layout-btn{{#if isActive}} is-active{{/if}}"
data-action="set-dock-layout"
data-layout="{{key}}"
data-tooltip="{{label}}">
<i class="fas {{icon}}" aria-hidden="true"></i>
<span class="scrying-pool-settings__layout-size">{{size}}</span>
</button>
{{/each}}
</div>
</div>
</div>
</div>
<style>
@@ -206,5 +225,48 @@
background: linear-gradient(175deg, hsl(0, 60%, 35%) 0%, hsl(0, 60%, 30%) 100%);
}
}
/* Layout selector */
.scrying-pool-settings__layout-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
}
.scrying-pool-settings__layout-btn {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 4px 7px;
font-size: 11px;
font-weight: 500;
background: rgba(255, 255, 255, 0.06);
color: var(--sp-text-primary, #dde2e8);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
i {
font-size: 10px;
pointer-events: none;
}
&:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
&.is-active {
background: hsl(200, 55%, 35%);
border-color: hsl(200, 55%, 50%);
}
}
.scrying-pool-settings__layout-size {
font-size: 9px;
opacity: 0.6;
}
</style>
</form>
@@ -0,0 +1,485 @@
// @ts-nocheck
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TokenVideoOverlay } from '../../../../src/ui/shared/TokenVideoOverlay.js';
import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js';
describe('TokenVideoOverlay', () => {
let adapter;
let overlay;
let mockToken;
let rafCallbacks;
beforeEach(() => {
rafCallbacks = [];
let rafIdCounter = 0;
vi.stubGlobal('PIXI', {
Texture: {
from: vi.fn(() => {
const tex = { baseTexture: { update: vi.fn() } };
return tex;
}),
},
});
vi.stubGlobal('requestAnimationFrame', (cb) => {
rafCallbacks.push(cb);
return ++rafIdCounter;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
vi.stubGlobal('Hooks', { on: vi.fn(), once: vi.fn() });
globalThis.canvas = {
scene: { id: 'scene-1' },
tokens: { placeables: [], get: vi.fn() },
};
globalThis.CONST = { DOCUMENT_PERMISSION_LEVELS: { OWNER: 3 } };
globalThis.foundry = {
canvas: { placeables: { Token: class {} } },
};
HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve());
// happy-dom rejects non-MediaStream srcObject values
Object.defineProperty(HTMLVideoElement.prototype, 'srcObject', {
writable: true,
value: null,
});
const mockStream = {
getVideoTracks: vi.fn(() => [{ enabled: true }]),
};
adapter = createFoundryAdapterMock({
webrtc: {
getMediaStreamForUser: vi.fn(() => mockStream),
},
settings: {
get: vi.fn((key) => {
if (key === 'userActorMapping') return {};
if (key === 'showVideoOnTokens') return true;
return null;
}),
},
users: {
all: vi.fn(() => [{ id: 'user-1', isGM: false, active: true }]),
get: vi.fn(() => ({ id: 'user-1', isGM: false, active: true })),
},
});
mockToken = {
id: 'token-1',
document: {
actor: {
id: 'actor-1',
testUserPermission: vi.fn(() => true),
},
},
mesh: { width: 100, height: 100, texture: 'original-tex' },
};
overlay = new TokenVideoOverlay(adapter);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const tickRAF = () => {
const cb = rafCallbacks.shift();
if (cb) cb();
};
describe('constructor', () => {
it('stores adapter reference without side effects', () => {
expect(overlay._adapter).toBe(adapter);
expect(overlay._overlays).toBeInstanceOf(Map);
expect(overlay._overlays.size).toBe(0);
expect(overlay._pending).toBeInstanceOf(Set);
expect(overlay._pending.size).toBe(0);
expect(overlay._origRefresh).toBeNull();
expect(overlay._enabled).toBe(false);
});
});
describe('_attach() — canvas 2D fallback', () => {
// happy-dom getContext('2d') returns null — fallback path
beforeEach(() => {
overlay._enabled = true;
});
it('falls back to direct video texture when canvas 2D unavailable', () => {
overlay._attach(mockToken);
tickRAF();
expect(overlay._overlays.size).toBe(1);
const data = overlay._overlays.get('scene-1.token-1');
expect(data.canvas).toBeUndefined();
expect(data.ctx).toBeUndefined();
expect(data.pixiTexture).toBeUndefined();
expect(data.origTexture).toBe('original-tex');
expect(data.userId).toBe('user-1');
expect(data.videoEl).toBeInstanceOf(HTMLVideoElement);
expect(PIXI.Texture.from).toHaveBeenCalled();
});
it('does not create overlay when token has no mesh', () => {
const badToken = { id: 'bad', document: { actor: { id: 'a1' } } };
overlay._attach(badToken);
tickRAF();
expect(overlay._overlays.size).toBe(0);
});
it('clears pending when attach completes', () => {
overlay._attach(mockToken);
expect(overlay._pending.has('scene-1.token-1')).toBe(true);
tickRAF();
expect(overlay._pending.has('scene-1.token-1')).toBe(false);
});
});
describe('_attach() — circular canvas path', () => {
let mockCtx;
beforeEach(() => {
overlay._enabled = true;
mockCtx = {
clearRect: vi.fn(),
save: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
clip: vi.fn(),
drawImage: vi.fn(),
restore: vi.fn(),
};
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockCtx);
});
it('creates canvas sized to mesh dimensions', () => {
overlay._attach(mockToken);
tickRAF();
const data = overlay._overlays.get('scene-1.token-1');
expect(data.canvas).toBeInstanceOf(HTMLCanvasElement);
expect(data.canvas.width).toBe(100);
expect(data.canvas.height).toBe(100);
expect(data.ctx).toBe(mockCtx);
expect(data.rafId).toBeTypeOf('number');
expect(data.pixiTexture).toBeDefined();
});
it('sets mesh texture to canvas-based PIXI texture', () => {
overlay._attach(mockToken);
tickRAF();
const data = overlay._overlays.get('scene-1.token-1');
expect(mockToken.mesh.texture).toBe(data.pixiTexture);
expect(mockToken.mesh.texture).not.toBe('original-tex');
});
it('runs render loop drawing circular clip each frame', () => {
overlay._attach(mockToken);
tickRAF();
const data = overlay._overlays.get('scene-1.token-1');
const firstRafId = data.rafId;
tickRAF();
expect(mockCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100);
expect(mockCtx.save).toHaveBeenCalled();
expect(mockCtx.beginPath).toHaveBeenCalled();
expect(mockCtx.arc).toHaveBeenCalledWith(50, 50, 50, 0, Math.PI * 2);
expect(mockCtx.clip).toHaveBeenCalled();
expect(mockCtx.drawImage).toHaveBeenCalled();
expect(mockCtx.restore).toHaveBeenCalled();
expect(data.pixiTexture.baseTexture.update).toHaveBeenCalled();
expect(data.rafId).not.toBe(firstRafId);
});
it('uses min dimension radius for non-square tokens', () => {
mockToken.mesh.width = 200;
mockToken.mesh.height = 100;
overlay._attach(mockToken);
tickRAF();
tickRAF();
expect(mockCtx.arc).toHaveBeenCalledWith(100, 50, 50, 0, Math.PI * 2);
});
it('guards against zero-dimension mesh', () => {
mockToken.mesh.width = 0;
mockToken.mesh.height = 0;
overlay._attach(mockToken);
tickRAF();
const data = overlay._overlays.get('scene-1.token-1');
expect(data.canvas.width).toBe(1);
expect(data.canvas.height).toBe(1);
});
it('stops render loop when overlay is removed from map', () => {
overlay._attach(mockToken);
tickRAF();
overlay._overlays.delete('scene-1.token-1');
rafCallbacks.length = 0;
tickRAF();
expect(mockCtx.clearRect).not.toHaveBeenCalled();
});
it('stops render loop when disabled', () => {
overlay._attach(mockToken);
tickRAF();
overlay._enabled = false;
rafCallbacks.length = 0;
tickRAF();
expect(mockCtx.clearRect).not.toHaveBeenCalled();
});
});
describe('_detachByKey()', () => {
beforeEach(() => {
overlay._overlays.set('scene-1.token-1', {
videoEl: document.createElement('video'),
origTexture: 'original-tex',
userId: 'user-1',
canvas: document.createElement('canvas'),
pixiTexture: { baseTexture: { update: vi.fn() } },
rafId: 42,
});
globalThis.canvas.tokens.get = vi.fn(() => mockToken);
});
it('cancels rAF loop', () => {
overlay._detachByKey('scene-1.token-1');
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
});
it('restores original texture on mesh', () => {
mockToken.mesh.texture = 'overwritten-by-something';
overlay._detachByKey('scene-1.token-1');
expect(mockToken.mesh.texture).toBe('original-tex');
});
it('removes video element from DOM', () => {
const data = overlay._overlays.get('scene-1.token-1');
document.body.appendChild(data.videoEl);
overlay._detachByKey('scene-1.token-1');
expect(document.body.contains(data.videoEl)).toBe(false);
});
it('removes overlay from map', () => {
overlay._detachByKey('scene-1.token-1');
expect(overlay._overlays.has('scene-1.token-1')).toBe(false);
});
it('is no-op for unknown key', () => {
expect(() => overlay._detachByKey('unknown')).not.toThrow();
});
it('is idempotent', () => {
overlay._detachByKey('scene-1.token-1');
overlay._detachByKey('scene-1.token-1');
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1);
});
});
describe('_onTokenRefreshed()', () => {
let mockPixiTex;
beforeEach(() => {
overlay._enabled = true;
mockPixiTex = { baseTexture: { update: vi.fn() } };
const c = document.createElement('canvas');
c.width = 100;
c.height = 100;
overlay._overlays.set('scene-1.token-1', {
videoEl: document.createElement('video'),
origTexture: 'original-tex',
userId: 'user-1',
canvas: c,
ctx: { clearRect: vi.fn() },
pixiTexture: mockPixiTex,
rafId: 42,
});
globalThis.canvas.tokens.get = vi.fn(() => mockToken);
});
it('re-applies pixiTexture when mesh texture was lost', () => {
mockToken.mesh.texture = 'wrong-texture';
overlay._onTokenRefreshed(mockToken);
expect(mockToken.mesh.texture).toBe(mockPixiTex);
});
it('detects mesh resize and re-attaches', () => {
vi.spyOn(overlay, '_detachByKey');
vi.spyOn(overlay, '_attach');
mockToken.mesh.width = 200;
mockToken.mesh.height = 200;
overlay._onTokenRefreshed(mockToken);
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-1');
expect(overlay._attach).toHaveBeenCalledWith(mockToken);
});
it('does nothing when mesh size and texture match', () => {
vi.spyOn(overlay, '_detachByKey');
vi.spyOn(overlay, '_attach');
mockToken.mesh.texture = mockPixiTex;
overlay._onTokenRefreshed(mockToken);
expect(overlay._detachByKey).not.toHaveBeenCalled();
expect(overlay._attach).not.toHaveBeenCalled();
});
it('uses fallback video path for non-canvas overlays', () => {
overlay._overlays.set('scene-1.token-2', {
videoEl: document.createElement('video'),
origTexture: 'original-tex-2',
userId: 'user-2',
});
const token2 = {
id: 'token-2',
document: mockToken.document,
mesh: { width: 100, height: 100, texture: 'wrong' },
};
overlay._onTokenRefreshed(token2);
expect(PIXI.Texture.from).toHaveBeenCalled();
});
it('attaches when overlay missing and not pending', () => {
overlay._overlays.clear();
vi.spyOn(overlay, '_attach');
overlay._onTokenRefreshed(mockToken);
expect(overlay._attach).toHaveBeenCalledWith(mockToken);
});
it('skips attach when token is pending', () => {
overlay._overlays.clear();
overlay._pending.add('scene-1.token-1');
vi.spyOn(overlay, '_attach');
overlay._onTokenRefreshed(mockToken);
expect(overlay._attach).not.toHaveBeenCalled();
});
});
describe('_onUpdateToken()', () => {
let mockPixiTex;
beforeEach(() => {
overlay._enabled = true;
mockPixiTex = { baseTexture: { update: vi.fn() } };
overlay._overlays.set('scene-1.token-1', {
videoEl: document.createElement('video'),
origTexture: 'original-tex',
userId: 'user-1',
canvas: document.createElement('canvas'),
pixiTexture: mockPixiTex,
rafId: 42,
});
globalThis.canvas.tokens.get = vi.fn(() => mockToken);
});
it('re-applies pixiTexture when mesh lost it', () => {
mockToken.mesh.texture = 'wrong-texture';
overlay._onUpdateToken(mockToken.document);
expect(mockToken.mesh.texture).toBe(mockPixiTex);
});
it('detaches when token loses user', () => {
adapter.users.all = vi.fn(() => []);
overlay._onUpdateToken(mockToken.document);
expect(overlay._overlays.has('scene-1.token-1')).toBe(false);
});
it('detaches old and re-attaches when user changes', () => {
overlay._overlays.set('scene-1.token-1', {
videoEl: document.createElement('video'),
origTexture: 'original-tex',
userId: 'old-user',
});
vi.spyOn(overlay, '_detachByKey');
vi.spyOn(overlay, '_attach');
overlay._onUpdateToken(mockToken.document);
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-1');
expect(overlay._attach).toHaveBeenCalledWith(mockToken);
});
it('is no-op when token not on canvas', () => {
globalThis.canvas.tokens.get = vi.fn(() => null);
expect(() => overlay._onUpdateToken(mockToken.document)).not.toThrow();
});
});
describe('enable/disable', () => {
it('enable sets _enabled and calls syncAll', () => {
vi.spyOn(overlay, 'syncAll');
overlay.enable();
expect(overlay._enabled).toBe(true);
expect(overlay.syncAll).toHaveBeenCalled();
});
it('disable sets _enabled and calls _cleanupAll', () => {
vi.spyOn(overlay, '_cleanupAll');
overlay.enable();
overlay.disable();
expect(overlay._enabled).toBe(false);
expect(overlay._cleanupAll).toHaveBeenCalled();
});
});
describe('_cleanupAll()', () => {
it('detaches all overlays', () => {
vi.spyOn(overlay, '_detachByKey');
overlay._overlays.set('scene-1.token-1', { origTexture: 't1' });
overlay._overlays.set('scene-1.token-2', { origTexture: 't2' });
overlay._cleanupAll();
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-1');
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-2');
expect(overlay._detachByKey).toHaveBeenCalledTimes(2);
});
});
describe('syncAll()', () => {
beforeEach(() => {
overlay._enabled = true;
});
it('attaches to all canvas tokens', () => {
vi.spyOn(overlay, '_attach');
canvas.tokens.placeables = [mockToken];
overlay.syncAll();
expect(overlay._attach).toHaveBeenCalledWith(mockToken);
});
it('detaches overlays for tokens no longer on canvas', () => {
overlay._overlays.set('scene-1.token-1', {});
overlay._overlays.set('scene-1.token-2', {});
overlay._overlays.set('scene-2.token-3', {});
canvas.tokens.placeables = [mockToken];
vi.spyOn(overlay, '_detachByKey');
overlay.syncAll();
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-2');
expect(overlay._detachByKey).toHaveBeenCalledWith('scene-2.token-3');
expect(overlay._detachByKey).not.toHaveBeenCalledWith('scene-1.token-1');
});
});
});