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