Compare commits
7 Commits
816b7951fb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 76ce992505 | |||
| a9dbb9306a | |||
| 602b1fc8e7 | |||
| daed472b46 | |||
| d9eda8c725 | |||
| 156f786448 | |||
| 9e80c2c028 |
@@ -0,0 +1,76 @@
|
|||||||
|
name: Release Creation
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "💡 The ${{ gitea.repository }} repository will be cloned to the runner."
|
||||||
|
|
||||||
|
- uses: RouxAntoine/checkout@v3.5.4
|
||||||
|
|
||||||
|
# get part of the tag after the `v`
|
||||||
|
- name: Extract tag version number
|
||||||
|
id: get_version
|
||||||
|
uses: battila7/get-version-action@v2
|
||||||
|
|
||||||
|
# Substitute the Manifest and Download URLs in the module.json
|
||||||
|
- name: Substitute Manifest and Download Links For Versioned Ones
|
||||||
|
id: sub_manifest_link_version
|
||||||
|
uses: microsoft/variable-substitution@v1
|
||||||
|
with:
|
||||||
|
files: 'module.json'
|
||||||
|
env:
|
||||||
|
version: ${{steps.get_version.outputs.version-without-v}}
|
||||||
|
url: https://www.uberwald.me/gitea/${{gitea.repository}}
|
||||||
|
manifest: https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json
|
||||||
|
download: https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip
|
||||||
|
|
||||||
|
# Set up Node.js
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Create a zip file with all files required by the module to add to the release
|
||||||
|
- run: |
|
||||||
|
apt update -y
|
||||||
|
apt install -y zip
|
||||||
|
|
||||||
|
- run: node scripts/package.mjs
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
run: |
|
||||||
|
# Upload module.zip
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
-H "Authorization: token ${{secrets.ALLOW_PUSH_RELEASE}}" \
|
||||||
|
--data-binary @./module.zip \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.zip"
|
||||||
|
|
||||||
|
# Upload module.json
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${{secrets.ALLOW_PUSH_RELEASE}}" \
|
||||||
|
--data-binary @./module.json \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.json"
|
||||||
|
|
||||||
|
- name: Publish to Foundry server
|
||||||
|
uses: https://github.com/djlechuck/foundryvtt-publish-package-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.FOUNDRYVTT_RELEASE_TOKEN }}
|
||||||
|
id: 'scrying-pool'
|
||||||
|
version: ${{gitea.event.release.tag_name}}
|
||||||
|
manifest: 'https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json'
|
||||||
|
notes: 'https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip'
|
||||||
|
compatibility-minimum: '14'
|
||||||
|
compatibility-verified: '14'
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: Release Creation
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "💡 The ${{ gitea.repository }} repository will be cloned to the runner."
|
||||||
|
|
||||||
|
- uses: RouxAntoine/checkout@v3.5.4
|
||||||
|
|
||||||
|
# get part of the tag after the `v`
|
||||||
|
- name: Extract tag version number
|
||||||
|
id: get_version
|
||||||
|
uses: battila7/get-version-action@v2
|
||||||
|
|
||||||
|
# Substitute the Manifest and Download URLs in the module.json
|
||||||
|
- name: Substitute Manifest and Download Links For Versioned Ones
|
||||||
|
id: sub_manifest_link_version
|
||||||
|
uses: microsoft/variable-substitution@v1
|
||||||
|
with:
|
||||||
|
files: 'module.json'
|
||||||
|
env:
|
||||||
|
version: ${{steps.get_version.outputs.version-without-v}}
|
||||||
|
url: https://www.uberwald.me/gitea/${{gitea.repository}}
|
||||||
|
manifest: https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json
|
||||||
|
download: https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip
|
||||||
|
|
||||||
|
# Set up Node.js
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Create a zip file with all files required by the module to add to the release
|
||||||
|
- run: |
|
||||||
|
apt update -y
|
||||||
|
apt install -y zip
|
||||||
|
|
||||||
|
- run: node scripts/package.mjs
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
run: |
|
||||||
|
# Upload module.zip
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
-H "Authorization: token ${{secrets.RELEASE_SCRYING_POOL}}" \
|
||||||
|
--data-binary @./module.zip \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.zip"
|
||||||
|
|
||||||
|
# Upload module.json
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${{secrets.ALLOW_PUSH_RELEASE}}" \
|
||||||
|
--data-binary @./module.json \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.json"
|
||||||
|
|
||||||
|
- name: Publish to Foundry server
|
||||||
|
uses: https://github.com/djlechuck/foundryvtt-publish-package-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.FOUNDRYVTT_RELEASE_TOKEN }}
|
||||||
|
id: 'scrying-pool'
|
||||||
|
version: ${{gitea.event.release.tag_name}}
|
||||||
|
manifest: 'https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json'
|
||||||
|
notes: 'https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip'
|
||||||
|
compatibility-minimum: '14'
|
||||||
|
compatibility-verified: '14'
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: Release Creation
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "💡 The ${{ gitea.repository }} repository will be cloned to the runner."
|
||||||
|
|
||||||
|
- uses: RouxAntoine/checkout@v3.5.4
|
||||||
|
|
||||||
|
# get part of the tag after the `v`
|
||||||
|
- name: Extract tag version number
|
||||||
|
id: get_version
|
||||||
|
uses: battila7/get-version-action@v2
|
||||||
|
|
||||||
|
# Substitute the Manifest and Download URLs in the module.json
|
||||||
|
- name: Substitute Manifest and Download Links For Versioned Ones
|
||||||
|
id: sub_manifest_link_version
|
||||||
|
uses: microsoft/variable-substitution@v1
|
||||||
|
with:
|
||||||
|
files: "module.json"
|
||||||
|
env:
|
||||||
|
version: ${{steps.get_version.outputs.version-without-v}}
|
||||||
|
url: https://www.uberwald.me/gitea/${{gitea.repository}}
|
||||||
|
manifest: https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json
|
||||||
|
download: https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip
|
||||||
|
|
||||||
|
# Set up Node.js
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Create a zip file with all files required by the module to add to the release
|
||||||
|
- run: |
|
||||||
|
apt update -y
|
||||||
|
apt install -y zip
|
||||||
|
|
||||||
|
- run: node scripts/package.mjs
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
run: |
|
||||||
|
# Upload module.zip
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
-H "Authorization: token ${{secrets.RELEASE_SCRYING_POOL}}" \
|
||||||
|
--data-binary @./module.zip \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.zip"
|
||||||
|
|
||||||
|
# Upload module.json
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${{secrets.RELEASE_SCRYING_POOL}}" \
|
||||||
|
--data-binary @./module.json \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.json"
|
||||||
|
|
||||||
|
- name: Publish to Foundry server
|
||||||
|
uses: https://github.com/djlechuck/foundryvtt-publish-package-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.FOUNDRYVTT_RELEASE_TOKEN }}
|
||||||
|
id: "scrying-pool"
|
||||||
|
version: ${{gitea.event.release.tag_name}}
|
||||||
|
manifest: "https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json"
|
||||||
|
notes: "https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip"
|
||||||
|
compatibility-minimum: "14"
|
||||||
|
compatibility-verified: "14"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: Release Creation
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "💡 The ${{ gitea.repository }} repository will be cloned to the runner."
|
||||||
|
|
||||||
|
- uses: RouxAntoine/checkout@v3.5.4
|
||||||
|
|
||||||
|
# get part of the tag after the `v`
|
||||||
|
- name: Extract tag version number
|
||||||
|
id: get_version
|
||||||
|
uses: battila7/get-version-action@v2
|
||||||
|
|
||||||
|
# Substitute the Manifest and Download URLs in the module.json
|
||||||
|
- name: Substitute Manifest and Download Links For Versioned Ones
|
||||||
|
id: sub_manifest_link_version
|
||||||
|
uses: microsoft/variable-substitution@v1
|
||||||
|
with:
|
||||||
|
files: "module.json"
|
||||||
|
env:
|
||||||
|
version: ${{steps.get_version.outputs.version-without-v}}
|
||||||
|
url: https://www.uberwald.me/gitea/${{gitea.repository}}
|
||||||
|
manifest: https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json
|
||||||
|
download: https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip
|
||||||
|
|
||||||
|
# Set up Node.js
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Create a zip file with all files required by the module to add to the release
|
||||||
|
- run: |
|
||||||
|
apt update -y
|
||||||
|
apt install -y zip
|
||||||
|
|
||||||
|
- run: node scripts/package.mjs
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
run: |
|
||||||
|
# Upload module.zip
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
-H "Authorization: token ${{secrets.RELEASE_SCRYING_POOL}}" \
|
||||||
|
--data-binary @./module.zip \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.zip"
|
||||||
|
|
||||||
|
# Upload module.json
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${{secrets.RELEASE_SCRYING_POOL}}" \
|
||||||
|
--data-binary @./module.json \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.json"
|
||||||
|
|
||||||
|
- name: Publish to Foundry server
|
||||||
|
uses: https://github.com/djlechuck/foundryvtt-publish-package-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.FOUNDRYVTT_RELEASE_TOKEN }}
|
||||||
|
id: "scrying-pool"
|
||||||
|
version: ${{gitea.event.release.tag_name}}
|
||||||
|
manifest: "https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json"
|
||||||
|
notes: "https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip"
|
||||||
|
compatibility-minimum: "14"
|
||||||
|
compatibility-verified: "14"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: Release Creation
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "💡 The ${{ gitea.repository }} repository will be cloned to the runner."
|
||||||
|
|
||||||
|
- uses: RouxAntoine/checkout@v3.5.4
|
||||||
|
|
||||||
|
# get part of the tag after the `v`
|
||||||
|
- name: Extract tag version number
|
||||||
|
id: get_version
|
||||||
|
uses: battila7/get-version-action@v2
|
||||||
|
|
||||||
|
# Substitute the Manifest and Download URLs in the module.json
|
||||||
|
- name: Substitute Manifest and Download Links For Versioned Ones
|
||||||
|
id: sub_manifest_link_version
|
||||||
|
uses: microsoft/variable-substitution@v1
|
||||||
|
with:
|
||||||
|
files: "module.json"
|
||||||
|
env:
|
||||||
|
version: ${{steps.get_version.outputs.version-without-v}}
|
||||||
|
url: https://www.uberwald.me/gitea/${{gitea.repository}}
|
||||||
|
manifest: https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json
|
||||||
|
download: https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip
|
||||||
|
|
||||||
|
# Set up Node.js
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Create a zip file with all files required by the module to add to the release
|
||||||
|
- run: |
|
||||||
|
apt update -y
|
||||||
|
apt install -y zip
|
||||||
|
|
||||||
|
- run: node scripts/package.mjs
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
run: |
|
||||||
|
# Upload module.zip
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
-H "Authorization: token ${{secrets.RELEASE_SCRYING_POOL}}" \
|
||||||
|
--data-binary @./module.zip \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.zip"
|
||||||
|
|
||||||
|
# Upload module.json
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${{secrets.RELEASE_SCRYING_POOL}}" \
|
||||||
|
--data-binary @./module.json \
|
||||||
|
"https://www.uberwald.me/api/v1/repos/${{gitea.repository}}/releases/${{gitea.event.release.id}}/assets?name=module.json"
|
||||||
|
|
||||||
|
# - name: Publish to Foundry server
|
||||||
|
# uses: https://github.com/djlechuck/foundryvtt-publish-package-action@v1
|
||||||
|
# with:
|
||||||
|
# token: ${{ secrets.FOUNDRYVTT_RELEASE_TOKEN }}
|
||||||
|
# id: "scrying-pool"
|
||||||
|
# version: ${{gitea.event.release.tag_name}}
|
||||||
|
# manifest: "https://www.uberwald.me/gitea/public/${{gitea.repository}}/releases/download/latest/module.json"
|
||||||
|
# notes: "https://www.uberwald.me/gitea/${{gitea.repository}}/releases/download/${{gitea.event.release.tag_name}}/module.zip"
|
||||||
|
# compatibility-minimum: "14"
|
||||||
|
# compatibility-verified: "14"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packs": [
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packs": [
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packs": [
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packs": [
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packs": [
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packs": [
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14+ — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packs": [
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14+ — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lang": "es",
|
||||||
|
"name": "Spanish",
|
||||||
|
"path": "lang/es.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14+ — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lang": "fr",
|
||||||
|
"name": "French",
|
||||||
|
"path": "lang/fr.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": "scrying-pool",
|
||||||
|
"title": "Scrying Pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GM camera visibility control for FoundryVTT v14+ — hide, show, and manage participant feeds in real time.",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Morr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "14",
|
||||||
|
"verified": "14"
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/scrying-pool.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lang": "fr",
|
||||||
|
"name": "French",
|
||||||
|
"path": "lang/fr.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": "${url}",
|
||||||
|
"manifest": "${manifest}",
|
||||||
|
"download": "${download}",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Scrying Pool Documentation Pack
|
||||||
|
|
||||||
|
✅ **Compendium is now built and ready!**
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `scrying-pool-docs.db` - **Pre-built SQLite compendium database** (20KB)
|
||||||
|
- `scrying-pool-guide.json` - Source JournalEntry content (English & French)
|
||||||
|
- `assets/` - Screenshot images (5 JPG files)
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
|
||||||
|
The `scrying-pool-docs.db` file contains a **JournalEntry** with:
|
||||||
|
- **2 pages**: English and French documentation
|
||||||
|
- **Professional styling** with CSS
|
||||||
|
- **5 screenshot references** (screenshot-main.jpg, screenshot-directors-board.jpg, etc.)
|
||||||
|
|
||||||
|
## Current Setup
|
||||||
|
|
||||||
|
The `module.json` defines the pack:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"packs": [
|
||||||
|
{
|
||||||
|
"name": "scrying-pool-documentation",
|
||||||
|
"label": "Scrying Pool Documentation",
|
||||||
|
"path": "packs/scrying-pool-docs.db",
|
||||||
|
"system": "",
|
||||||
|
"type": "JournalEntry"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When the module is installed in FoundryVTT, the **"Scrying Pool Documentation"** compendium will appear under the **Modules** tab with the JournalEntry already populated.
|
||||||
|
|
||||||
|
## Updating the Documentation
|
||||||
|
|
||||||
|
To update the documentation content:
|
||||||
|
|
||||||
|
### Option 1: Using the build script (Recommended)
|
||||||
|
```bash
|
||||||
|
# Edit the JSON source
|
||||||
|
nano packs/scrying-pool-guide.json
|
||||||
|
|
||||||
|
# Rebuild the database
|
||||||
|
node scripts/build-compendium.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Manual SQLite editing
|
||||||
|
```bash
|
||||||
|
sqlite3 packs/scrying-pool-docs.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Using fvtt-cli (Alternative)
|
||||||
|
```bash
|
||||||
|
npm install -g @foundryvtt/fvtt-cli
|
||||||
|
fvtt-cli pack create \
|
||||||
|
--input packs/scrying-pool-guide.json \
|
||||||
|
--output packs/scrying-pool-docs.db \
|
||||||
|
--type JournalEntry
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packs/
|
||||||
|
├── scrying-pool-docs.db # SQLite database (compendium)
|
||||||
|
├── scrying-pool-guide.json # Source JSON (for editing)
|
||||||
|
├── README.md # This file
|
||||||
|
└── assets/
|
||||||
|
├── README.md
|
||||||
|
├── screenshot-main.jpg
|
||||||
|
├── screenshot-directors-board.jpg
|
||||||
|
├── screenshot-player-view.jpg
|
||||||
|
├── screenshot-presets.jpg
|
||||||
|
└── screenshot-badge-states.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
- **Database**: SQLite3 with WAL mode enabled
|
||||||
|
- **Table**: `JournalEntry` with FoundryVTT v14 schema
|
||||||
|
- **JournalEntry ID**: `scrying-pool-guide`
|
||||||
|
- **Name**: "Scrying Pool - User Guide / Guide de l'utilisateur"
|
||||||
|
- **Pages**: 2 (English and French)
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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()`
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
title: 'Strip: Reorder, Spotlight & Auto-Snapshots'
|
||||||
|
type: 'feature'
|
||||||
|
created: '2026-05-27'
|
||||||
|
status: 'done'
|
||||||
|
baseline_commit: '816b7951fb88353b43c66e7b9f898701ea65ad2b'
|
||||||
|
context: []
|
||||||
|
---
|
||||||
|
|
||||||
|
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
**Problem:** Strip participants have a fixed order, no way to focus a single participant, and position is only saved on close.
|
||||||
|
|
||||||
|
**Approach:** Add HTML5 drag-and-drop reordering (per-GM flag), Shift+click spotlight (in-memory, one-tile focus mode), and periodic auto-save of strip position (debounced + 30s interval with viewport validation).
|
||||||
|
|
||||||
|
## Boundaries & Constraints
|
||||||
|
|
||||||
|
**Always:**
|
||||||
|
- Re-order: persists to `game.user.setFlag('scrying-pool', 'participantOrder', string[])`, per-GM only
|
||||||
|
- Spotlight: in-memory `_focusedUserId` only, no socket/persistence
|
||||||
|
- Auto-snapshots: extends existing `stripState` flag (`left, top, width, height, savedAt`), backward-compatible
|
||||||
|
- All three features are strip-only (not Director's Board)
|
||||||
|
- French locale strings in `scrying-pool.lang.fr.json`
|
||||||
|
|
||||||
|
**Ask First:** None
|
||||||
|
|
||||||
|
**Never:**
|
||||||
|
- No external drag-and-drop libraries
|
||||||
|
- No socket broadcasts for re-order or spotlight
|
||||||
|
- No CSS animation framework changes
|
||||||
|
|
||||||
|
## I/O & Edge-Case Matrix
|
||||||
|
|
||||||
|
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|
||||||
|
|----------|--------------|---------------------------|----------------|
|
||||||
|
| Re-order: drag participant | Drag start on tile | Tile shows `opacity: 0.3` during drag | N/A |
|
||||||
|
| Re-order: drop between tiles | Drop at new position | Participant order updates, flag saved, strip re-renders | Save failure silently caught |
|
||||||
|
| Re-order: double-click grip | Double-click grip area | Order resets to connection order | N/A |
|
||||||
|
| Spotlight: Shift+click | Shift+click participant | Only that participant visible, others `display:none` in DOM, strip resized | N/A |
|
||||||
|
| Spotlight: exit via Escape | Press Escape | Full list restored, `_focusedUserId` cleared | N/A |
|
||||||
|
| Spotlight: exit via button | Click exit-focus button (replaces DB icon) | Same as Escape | N/A |
|
||||||
|
| Auto-snapshot: drag ends | mouseup after drag | Debounced save to `stripState` flag | Save failure silently caught |
|
||||||
|
| Auto-snapshot: 30s timer | Interval fires | Save current position to `stripState` | Save failure silently caught |
|
||||||
|
| Auto-snapshot: position off-screen | Saved position outside viewport | Fall back to default position | Silent fallback, no error |
|
||||||
|
|
||||||
|
</frozen-after-approval>
|
||||||
|
|
||||||
|
## Code Map
|
||||||
|
|
||||||
|
- `src/ui/gm/ScryingPoolStrip.js` — All three features: drag handlers, spotlight state, auto-save timer
|
||||||
|
- `styles/components/_roster-strip.less` — Drag feedback (`opacity: 0.3`), spotlight visual state
|
||||||
|
- `templates/directors-board.hbs` — Reset strip position button
|
||||||
|
- `src/ui/gm/DirectorsBoard.js` — Reset position handler
|
||||||
|
- `module.js` — (no changes needed, instantiates the class)
|
||||||
|
- `scrying-pool.lang.fr.json` — French locale strings
|
||||||
|
|
||||||
|
## Tasks & Acceptance
|
||||||
|
|
||||||
|
**Execution:**
|
||||||
|
- [x] `src/ui/gm/ScryingPoolStrip.js` — Add `_focusedUserId`, drag handlers, _savePosition, re-order in _prepareContext
|
||||||
|
- [x] `styles/components/_roster-strip.less` — `.sp-state-focused` gold ring, drag ghost opacity
|
||||||
|
- [x] `templates/directors-board.hbs` — Reset strip position button
|
||||||
|
- [x] `src/ui/gm/DirectorsBoard.js` — `_onResetStripPosition()` handler
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Given a strip with 3+ participants, when GM drags a participant tile to a new position, then the participant order updates and persists across re-renders
|
||||||
|
- Given any layout, when GM Shift+clicks a participant, then other participants collapse and the focused tile shows gold state ring
|
||||||
|
- Given spotlight mode is active, when GM presses Escape, then all participants return to normal
|
||||||
|
- Given the strip is open, when GM drags the strip to a new position and releases, then the position is saved
|
||||||
|
- Given a saved off-screen position, when the strip re-renders, then it appears at default coordinates
|
||||||
|
|
||||||
|
## Suggested Review Order
|
||||||
|
|
||||||
|
**Re-order + Spotlight core logic**
|
||||||
|
|
||||||
|
- Entry point: participant filtering, DnD handlers, focus toggle, position save
|
||||||
|
[`ScryingPoolStrip.js:149`](../../src/ui/gm/ScryingPoolStrip.js#L149)
|
||||||
|
|
||||||
|
- Drag-drop splice with `fromIdx < toIdx` adjustment + null guard on element
|
||||||
|
[`ScryingPoolStrip.js:1013`](../../src/ui/gm/ScryingPoolStrip.js#L1013)
|
||||||
|
|
||||||
|
- Focus toggle toggles `_focusedUserId`, re-renders; Escape exits via document listener
|
||||||
|
[`ScryingPoolStrip.js:1076`](../../src/ui/gm/ScryingPoolStrip.js#L1076)
|
||||||
|
|
||||||
|
- Auto-save: called on grip mouseup + 30s interval; cleanup on teardown
|
||||||
|
[`ScryingPoolStrip.js:1094`](../../src/ui/gm/ScryingPoolStrip.js#L1094)
|
||||||
|
|
||||||
|
- Viewport-validated position restore with negative-value guard
|
||||||
|
[`ScryingPoolStrip.js:178`](../../src/ui/gm/ScryingPoolStrip.js#L178)
|
||||||
|
|
||||||
|
**Template changes**
|
||||||
|
|
||||||
|
- `isFocused` conditional class for gold ring on focused participant
|
||||||
|
[`roster-strip.hbs:54`](../../templates/roster-strip.hbs#L54)
|
||||||
|
|
||||||
|
- Reset strip position button in Director's Board footer
|
||||||
|
[`directors-board.hbs:140`](../../templates/directors-board.hbs#L140)
|
||||||
|
|
||||||
|
**CSS**
|
||||||
|
|
||||||
|
- Gold state ring for `.sp-state-focused`, drag ghost opacity, drop target indicator
|
||||||
|
[`_roster-strip.less:517`](../../styles/components/_roster-strip.less#L517)
|
||||||
|
|
||||||
|
**Director's Board**
|
||||||
|
|
||||||
|
- Reset position handler clears stripState flag
|
||||||
|
[`DirectorsBoard.js:861`](../../src/ui/gm/DirectorsBoard.js#L861)
|
||||||
|
|
||||||
|
**Locales**
|
||||||
|
|
||||||
|
- Reset strip button strings (EN + FR)
|
||||||
|
[`en.json:82`](../../lang/en.json#L82) · [`fr.json:82`](../../lang/fr.json#L82)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
- `npm run lint` -- expected: 0 errors
|
||||||
|
- `npm run typecheck` -- expected: 0 errors
|
||||||
|
- `npm run test` -- expected: all tests pass
|
||||||
@@ -12,7 +12,12 @@ inputDocuments:
|
|||||||
workflowType: 'architecture'
|
workflowType: 'architecture'
|
||||||
project_name: 'video-view-manager'
|
project_name: 'video-view-manager'
|
||||||
user_name: 'Morr'
|
user_name: 'Morr'
|
||||||
date: '2026-05-20'
|
date: '2026-05-27'
|
||||||
|
extendedAt: '2026-05-27'
|
||||||
|
extendedFeatures:
|
||||||
|
- reorder-participants
|
||||||
|
- spotlight-focus
|
||||||
|
- auto-position-snapshots
|
||||||
---
|
---
|
||||||
|
|
||||||
# Architecture Decision Document: Video View Manager (Scrying Pool)
|
# Architecture Decision Document: Video View Manager (Scrying Pool)
|
||||||
@@ -1091,3 +1096,82 @@ both critical gaps found during validation were resolved inline without reopenin
|
|||||||
Story 0 scaffold — `module.json` + `tsconfig.json` + `vitest.config.js` + `.eslintrc.js` +
|
Story 0 scaffold — `module.json` + `tsconfig.json` + `vitest.config.js` + `.eslintrc.js` +
|
||||||
`scripts/package.mjs` + `src/contracts/` (with validators + frozen fixtures) +
|
`scripts/package.mjs` + `src/contracts/` (with validators + frozen fixtures) +
|
||||||
`.gitea/workflows/ci.yml` — all Story 0 ACs green before any Story 1 code.
|
`.gitea/workflows/ci.yml` — all Story 0 ACs green before any Story 1 code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Addendum (2026-05-27): Re-order Participants
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Strip participant order is fixed to user connection order. GM cannot rearrange participants to match table seating, spotlight priority, or personal preference.
|
||||||
|
|
||||||
|
### Decision: Drag-and-drop via native HTML5 API
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Mechanism | HTML5 Drag & Drop (`draggable`, `dragstart`/`dragover`/`drop` events) | Zero dependencies; works in Foundry's embedded Chromium; no external lib needed |
|
||||||
|
| Persistence | User flag: `game.user.setFlag('scrying-pool', 'participantOrder', string[])` | Per-GM order; does not affect other GMs or players |
|
||||||
|
| Storage format | Ordered array of user IDs | Minimal, sortable, forward-compatible |
|
||||||
|
| Visual feedback | `opacity: 0.3` on dragged tile + `box-shadow` drop indicator on target gap | Lightweight; no layout shift |
|
||||||
|
| Reset | Double-click the grip area → reset to connection order | Escape hatch if order gets confusing |
|
||||||
|
| Scope | Strip only (not Director's Board) | Strip is the primary real-estate; DB reorder adds complexity with no clear need |
|
||||||
|
|
||||||
|
### Implementation Notes
|
||||||
|
- `ScryingPoolStrip` gets `_onDragStart`, `_onDragOver`, `_onDrop`, `_onDragEnd` handlers
|
||||||
|
- Attached in `_onRender` via `el.addEventListener`
|
||||||
|
- On drop: reorder participant list in `_prepareContext`, persist to user flag
|
||||||
|
- `_prepareContext` reads flag, applies order before filtering hidden participants
|
||||||
|
- No socket broadcast — order is per-GM/local-only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Addendum (2026-05-27): Spotlight / Focus
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
In all layouts, participants share equal visual weight. GM cannot temporarily focus on one participant's video feed (e.g., a player speaking, a dramatic reveal).
|
||||||
|
|
||||||
|
### Decision: One-tile-expand mode within the strip
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Activation | Click on participant avatar while holding `Shift` (or context menu → "Focus") | Intentional action; avoids accidental triggers |
|
||||||
|
| Visual | Selected tile expands to fill strip content area; other tiles collapse to `height: 0; overflow: hidden` (preserved in DOM for instant restore) | No layout reflow on restore; preserved DOM state |
|
||||||
|
| Restore | Click `Shift+click` again, or click an "Exit focus" button that replaces the toolbar, or press `Escape` | Multiple escape hatches |
|
||||||
|
| State | In-memory only: `_focusedUserId: string|null` on `ScryingPoolStrip` | Ephemeral — no persistence needed |
|
||||||
|
| Indicator | Focused tile gets `.sp-state-focused` class → gold state ring (`--sp-urgency-director`) | Visual consistency with existing state ring system |
|
||||||
|
| Strip sizing | `setPosition` recomputed with `1` participant during focus | Window snaps to single-tile dimensions |
|
||||||
|
| Layout compatibility | Works in all layouts (vertical, horizontal, mosaic) | Tile fills available space via same CSS that handles single-participant edge case |
|
||||||
|
| Director's Board | Unaffected — spotlight is strip-only | DB maintains overview while strip focuses |
|
||||||
|
|
||||||
|
### Implementation Notes
|
||||||
|
- `_focusedUserId` field on `ScryingPoolStrip`
|
||||||
|
- `_prepareContext` filters or transforms participant list: if `_focusedUserId` set, only that participant is visible; others have `hidden: true` (but preserved in DOM)
|
||||||
|
- Restore clears `_focusedUserId` → re-render → full list visible
|
||||||
|
- CSS: `.sp-participant-avatar.sp-state-focused` inherits existing state ring pattern (green → gold via `--sp-urgency-director`)
|
||||||
|
- No socket broadcast — purely local UI state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Addendum (2026-05-27): Auto Position Snapshots
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Strip position is saved only on close. If the browser window is resized, display changed, or the strip is accidentally dragged off-screen, there is no way to restore a known-good position without relaunching.
|
||||||
|
|
||||||
|
### Decision: Periodic auto-save + explicit save/restore
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Auto-save trigger | `debounced` save on `mouseup` after drag ends | Saves only when user finishes moving; no save storm during drag |
|
||||||
|
| Auto-save interval | Also every 30s via `setInterval` while strip is open | Safety net if drag event fails to fire |
|
||||||
|
| Storage | User flag: `game.user.setFlag('scrying-pool', 'stripState')` | Already partially used (saves on close); extend to include timestamp |
|
||||||
|
| Save payload | `{ left, top, width, height, savedAt }` | Position + dimensions + timestamp for diagnostics |
|
||||||
|
| Restore trigger | On `_onRender` — if saved position exists and strip has no explicit position yet | First render gets saved position |
|
||||||
|
| Reset | Director's Board button "Reset strip position" → clears flag + re-renders at default position | Manual escape hatch |
|
||||||
|
| Multi-monitor safety | Validate `saved.left` and `saved.top` are within available viewport (`window.screen.availWidth/Height`) before applying | Prevents strip from loading off-screen after monitor config change |
|
||||||
|
|
||||||
|
### Implementation Notes
|
||||||
|
- Extend existing `_loadPosition()` in `ScryingPoolStrip` — already reads `stripState` flag
|
||||||
|
- Add `_savePosition()` called on `mouseup` after drag + every 30s interval
|
||||||
|
- Viewport validation: `saved.left < screen.availWidth - 50 && saved.top < screen.availHeight - 50`
|
||||||
|
- On validation failure: silently fall back to default position (no error notification)
|
||||||
|
- Director's Board: add "Reset strip position" button (minor template change)
|
||||||
|
- Extends existing `stripState` flag — no new flags needed, backward-compatible with old saves
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export default [
|
|||||||
foundry: "readonly",
|
foundry: "readonly",
|
||||||
CONFIG: "readonly",
|
CONFIG: "readonly",
|
||||||
CONST: "readonly",
|
CONST: "readonly",
|
||||||
|
Token: "readonly",
|
||||||
|
PIXI: "readonly",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
+19
-2
@@ -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",
|
||||||
@@ -79,7 +80,8 @@
|
|||||||
"avModeEnable": "Enable A/V",
|
"avModeEnable": "Enable A/V",
|
||||||
"avModeDisable": "Disable A/V",
|
"avModeDisable": "Disable A/V",
|
||||||
"avConfig": "A/V Settings…",
|
"avConfig": "A/V Settings…",
|
||||||
"avConfigTitle": "Open Foundry A/V server settings (signaling server, LiveKit, etc.)"
|
"avConfigTitle": "Open Foundry A/V server settings (signaling server, LiveKit, etc.)",
|
||||||
|
"resetStrip": "Reset Strip Position"
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"showAll": "Show All",
|
"showAll": "Show All",
|
||||||
@@ -206,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",
|
||||||
|
|||||||
+19
-2
@@ -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",
|
||||||
@@ -79,7 +80,8 @@
|
|||||||
"avModeEnable": "Activer A/V",
|
"avModeEnable": "Activer A/V",
|
||||||
"avModeDisable": "Désactiver A/V",
|
"avModeDisable": "Désactiver A/V",
|
||||||
"avConfig": "Paramètres A/V...",
|
"avConfig": "Paramètres A/V...",
|
||||||
"avConfigTitle": "Ouvrir les paramètres du serveur A/V de Foundry (serveur de signalisation, LiveKit, etc.)"
|
"avConfigTitle": "Ouvrir les paramètres du serveur A/V de Foundry (serveur de signalisation, LiveKit, etc.)",
|
||||||
|
"resetStrip": "Réinitialiser la position de la barre"
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"showAll": "Tout afficher",
|
"showAll": "Tout afficher",
|
||||||
@@ -206,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,
|
||||||
@@ -382,13 +383,29 @@ export class DirectorsBoard extends _AppBase {
|
|||||||
// Defaults match the settings registration in module.js
|
// Defaults match the settings registration in module.js
|
||||||
const widgetWidthSm = this._adapter.settings?.get?.('widgetWidthSm') ?? '83';
|
const widgetWidthSm = this._adapter.settings?.get?.('widgetWidthSm') ?? '83';
|
||||||
const widgetWidthMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150';
|
const widgetWidthMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150';
|
||||||
const WIDTH_OPTIONS = [
|
const SM_WIDTH_OPTIONS = [
|
||||||
|
{ value: '60', label: '60px' },
|
||||||
|
{ value: '70', label: '70px' },
|
||||||
|
{ value: '80', label: '80px' },
|
||||||
|
{ value: '90', label: '90px' },
|
||||||
|
{ value: '100', label: '100px' },
|
||||||
|
{ value: '120', label: '120px' },
|
||||||
|
{ value: '140', label: '140px' },
|
||||||
|
{ value: '160', label: '160px' },
|
||||||
|
{ value: '180', label: '180px' },
|
||||||
|
{ value: '200', label: '200px' },
|
||||||
|
];
|
||||||
|
const MD_WIDTH_OPTIONS = [
|
||||||
{ value: '60', label: '60px' },
|
{ value: '60', label: '60px' },
|
||||||
{ value: '80', label: '80px' },
|
{ value: '80', label: '80px' },
|
||||||
{ value: '100', label: '100px' },
|
{ value: '100', label: '100px' },
|
||||||
{ value: '120', label: '120px' },
|
{ value: '120', label: '120px' },
|
||||||
{ value: '150', label: '150px' },
|
{ value: '150', label: '150px' },
|
||||||
{ value: '200', label: '200px' },
|
{ value: '200', label: '200px' },
|
||||||
|
{ value: '250', label: '250px' },
|
||||||
|
{ value: '300', label: '300px' },
|
||||||
|
{ value: '350', label: '350px' },
|
||||||
|
{ value: '400', label: '400px' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tile shape selector
|
// Tile shape selector
|
||||||
@@ -433,7 +450,8 @@ export class DirectorsBoard extends _AppBase {
|
|||||||
tileBorderColor: currentTileBorderColor,
|
tileBorderColor: currentTileBorderColor,
|
||||||
tileBorderWidths: TILE_BORDER_WIDTHS,
|
tileBorderWidths: TILE_BORDER_WIDTHS,
|
||||||
// Story 5.2: Video widget width customization
|
// Story 5.2: Video widget width customization
|
||||||
widthOptions: WIDTH_OPTIONS,
|
smWidthOptions: SM_WIDTH_OPTIONS,
|
||||||
|
mdWidthOptions: MD_WIDTH_OPTIONS,
|
||||||
widgetWidthSm,
|
widgetWidthSm,
|
||||||
widgetWidthMd,
|
widgetWidthMd,
|
||||||
};
|
};
|
||||||
@@ -496,6 +514,7 @@ export class DirectorsBoard extends _AppBase {
|
|||||||
case 'set-dock-layout': this._onSetDockLayout(btn.dataset.layout); break;
|
case 'set-dock-layout': this._onSetDockLayout(btn.dataset.layout); break;
|
||||||
case 'set-tile-shape': this._onSetTileShape(btn.dataset.shape); break;
|
case 'set-tile-shape': this._onSetTileShape(btn.dataset.shape); break;
|
||||||
case 'set-tile-border-width': this._onSetTileBorderWidth(parseInt(btn.dataset.width, 10)); break;
|
case 'set-tile-border-width': this._onSetTileBorderWidth(parseInt(btn.dataset.width, 10)); break;
|
||||||
|
case 'reset-strip-position': this._onResetStripPosition(); break;
|
||||||
case 'close': this.close(); break;
|
case 'close': this.close(); break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -840,6 +859,17 @@ export class DirectorsBoard extends _AppBase {
|
|||||||
if (this.rendered) this.render({ force: true });
|
if (this.rendered) this.render({ force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the saved strip position flag so the strip defaults to its original position.
|
||||||
|
*/
|
||||||
|
_onResetStripPosition() {
|
||||||
|
try {
|
||||||
|
game.user?.setFlag?.('scrying-pool', 'stripState', null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ScryingPool] Failed to reset strip position:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the PresetSaveDialog for saving the current visibility matrix as a preset.
|
* Opens the PresetSaveDialog for saving the current visibility matrix as a preset.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
this._roleRenderer?.openStrip();
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add click handler for close strip button
|
switch (btn.dataset.action) {
|
||||||
const closeBtn = this.element.querySelector('[data-action="close-strip"]');
|
case 'close':
|
||||||
if (closeBtn) {
|
this.close();
|
||||||
closeBtn.addEventListener('click', () => {
|
break;
|
||||||
this._roleRenderer?.closeStrip();
|
case 'reopen-strip':
|
||||||
this.close();
|
this._roleRenderer?.openStrip();
|
||||||
});
|
this.close();
|
||||||
|
break;
|
||||||
|
case 'close-strip':
|
||||||
|
this._roleRenderer?.closeStrip();
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
case 'set-dock-layout':
|
||||||
|
this._onSetDockLayout(btn.dataset.layout);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.element.addEventListener('click', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the dock layout via the adapter setting.
|
||||||
|
* @param {string} layoutKey
|
||||||
|
*/
|
||||||
|
async _onSetDockLayout(layoutKey) {
|
||||||
|
if (this._changingLayout) return;
|
||||||
|
this._changingLayout = true;
|
||||||
|
try {
|
||||||
|
await this._adapter?.settings?.set?.('dockLayout', layoutKey);
|
||||||
|
await this._adapter?.settings?.set?.('dockLayoutExpanded', '');
|
||||||
|
this.close();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ScryingPool] Failed to set dockLayout:', err);
|
||||||
|
} finally {
|
||||||
|
this._changingLayout = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,9 +149,26 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
this._healthCheckInterval = null;
|
this._healthCheckInterval = null;
|
||||||
/** @type {number|string|null} */
|
/** @type {number|string|null} */
|
||||||
this._userConnectedHookId = null;
|
this._userConnectedHookId = null;
|
||||||
|
/** @type {string|null} */
|
||||||
|
this._focusedUserId = null;
|
||||||
|
/** @type {number|null} */
|
||||||
|
this._positionSaveTimer = null;
|
||||||
|
|
||||||
// Load saved position from user flags
|
// Load saved position from user flags
|
||||||
this._loadPosition();
|
this._loadPosition();
|
||||||
|
|
||||||
|
/** Bound keydown handler for Escape-to-exit-focus */
|
||||||
|
this._onDocumentKeydown = (e) => {
|
||||||
|
if (e.key === 'Escape' && this._focusedUserId) {
|
||||||
|
this._focusedUserId = null;
|
||||||
|
if (typeof this.render === 'function') {
|
||||||
|
this.render({ force: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (typeof document?.addEventListener === 'function') {
|
||||||
|
document.addEventListener('keydown', this._onDocumentKeydown);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads saved window position from GM user flag. */
|
/** Loads saved window position from GM user flag. */
|
||||||
@@ -159,8 +176,13 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
try {
|
try {
|
||||||
const saved = game.user?.getFlag?.('scrying-pool', 'stripState');
|
const saved = game.user?.getFlag?.('scrying-pool', 'stripState');
|
||||||
if (saved?.left != null && saved?.top != null) {
|
if (saved?.left != null && saved?.top != null) {
|
||||||
if (this.options?.position) {
|
if (saved.left < 0 || saved.top < 0) return;
|
||||||
Object.assign(this.options.position, { left: saved.left, top: saved.top });
|
const screenW = typeof window !== 'undefined' ? (window.screen?.availWidth ?? Infinity) : Infinity;
|
||||||
|
const screenH = typeof window !== 'undefined' ? (window.screen?.availHeight ?? Infinity) : Infinity;
|
||||||
|
if (saved.left < screenW - 50 && saved.top < screenH - 50) {
|
||||||
|
if (typeof this.setPosition === 'function') {
|
||||||
|
this.setPosition({ left: saved.left, top: saved.top });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_e) { /* no-op in test environment */ }
|
} catch (_e) { /* no-op in test environment */ }
|
||||||
@@ -195,6 +217,22 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
? userIds
|
? userIds
|
||||||
: userIds.filter(id => id !== currentUserId);
|
: userIds.filter(id => id !== currentUserId);
|
||||||
|
|
||||||
|
// Re-order participants per GM's saved order (drag-and-drop)
|
||||||
|
try {
|
||||||
|
const savedOrder = game.user?.getFlag?.('scrying-pool', 'participantOrder');
|
||||||
|
if (Array.isArray(savedOrder) && savedOrder.length > 0) {
|
||||||
|
const orderMap = new Map(savedOrder.map((id, idx) => [id, idx]));
|
||||||
|
filteredUserIds.sort((a, b) => {
|
||||||
|
const ai = orderMap.get(a);
|
||||||
|
const bi = orderMap.get(b);
|
||||||
|
if (ai !== undefined && bi !== undefined) return ai - bi;
|
||||||
|
if (ai !== undefined) return -1;
|
||||||
|
if (bi !== undefined) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_e) { /* no-op in test environment */ }
|
||||||
|
|
||||||
// Check if we have stream access for video replacement (full AV replacement mode)
|
// Check if we have stream access for video replacement (full AV replacement mode)
|
||||||
const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined;
|
const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined;
|
||||||
|
|
||||||
@@ -207,8 +245,16 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
this._portraitFallbackHandler
|
this._portraitFallbackHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mark focused participant for gold ring visual
|
||||||
|
participants.forEach(p => { p.isFocused = this._focusedUserId === p.userId; });
|
||||||
|
|
||||||
// Remove hidden participants from the strip — they are managed via the Directors Board.
|
// Remove hidden participants from the strip — they are managed via the Directors Board.
|
||||||
const visibleParticipants = participants.filter(p => p.state !== 'hidden');
|
let visibleParticipants = participants.filter(p => p.state !== 'hidden');
|
||||||
|
|
||||||
|
// Spotlight: if a participant is focused, only show that one
|
||||||
|
if (this._focusedUserId) {
|
||||||
|
visibleParticipants = visibleParticipants.filter(p => p.userId === this._focusedUserId);
|
||||||
|
}
|
||||||
|
|
||||||
// Dock layout: world setting gives direction+canonical size; client override only applies if user toggled
|
// Dock layout: world setting gives direction+canonical size; client override only applies if user toggled
|
||||||
const rawLayout = this._adapter.settings?.get?.('dockLayout');
|
const rawLayout = this._adapter.settings?.get?.('dockLayout');
|
||||||
@@ -228,6 +274,8 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
const widgetWidthMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150';
|
const widgetWidthMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150';
|
||||||
const isLarge = effectiveSize === 'md';
|
const isLarge = effectiveSize === 'md';
|
||||||
const effectiveWidth = isLarge ? widgetWidthMd : widgetWidthSm;
|
const effectiveWidth = isLarge ? widgetWidthMd : widgetWidthSm;
|
||||||
|
const isSpotlightActive = !!this._focusedUserId;
|
||||||
|
const widgetWidth = isSpotlightActive ? String(parseInt(effectiveWidth, 10) * 2) : effectiveWidth;
|
||||||
|
|
||||||
const isGM = this._adapter.users.isGM?.() ?? false;
|
const isGM = this._adapter.users.isGM?.() ?? false;
|
||||||
|
|
||||||
@@ -248,7 +296,8 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
hasStreamAccess,
|
hasStreamAccess,
|
||||||
isGM,
|
isGM,
|
||||||
// Story 5.2: Video widget width customization
|
// Story 5.2: Video widget width customization
|
||||||
widgetWidth: effectiveWidth,
|
isSpotlightActive,
|
||||||
|
widgetWidth,
|
||||||
// Tile shape
|
// Tile shape
|
||||||
tileShape,
|
tileShape,
|
||||||
// Tile border
|
// Tile border
|
||||||
@@ -266,6 +315,11 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
el.querySelectorAll('[data-action="open-popover"]').forEach(btn => {
|
el.querySelectorAll('[data-action="open-popover"]').forEach(btn => {
|
||||||
const userId = btn.dataset.userId;
|
const userId = btn.dataset.userId;
|
||||||
btn.addEventListener('click', e => {
|
btn.addEventListener('click', e => {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.stopPropagation();
|
||||||
|
this._toggleFocus(userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this._openPopover(userId, btn);
|
this._openPopover(userId, btn);
|
||||||
});
|
});
|
||||||
@@ -276,6 +330,33 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drag-and-drop reordering of participants
|
||||||
|
// Remove old DnD listeners first to prevent duplicates on re-render
|
||||||
|
el.querySelectorAll('.sp-strip__participant-item').forEach(item => {
|
||||||
|
const existing = item._dndHandlers;
|
||||||
|
if (existing) {
|
||||||
|
item.removeEventListener('dragstart', existing.dragstart);
|
||||||
|
item.removeEventListener('dragover', existing.dragover);
|
||||||
|
item.removeEventListener('drop', existing.drop);
|
||||||
|
item.removeEventListener('dragend', existing.dragend);
|
||||||
|
item.removeEventListener('dragleave', existing.dragleave);
|
||||||
|
}
|
||||||
|
const handlers = {
|
||||||
|
dragstart: e => this._onDragStart(e),
|
||||||
|
dragover: e => this._onDragOver(e),
|
||||||
|
drop: e => this._onDrop(e),
|
||||||
|
dragend: e => this._onDragEnd(e),
|
||||||
|
dragleave: e => this._onDragLeave(e),
|
||||||
|
};
|
||||||
|
item._dndHandlers = handlers;
|
||||||
|
item.draggable = true;
|
||||||
|
item.addEventListener('dragstart', handlers.dragstart);
|
||||||
|
item.addEventListener('dragover', handlers.dragover);
|
||||||
|
item.addEventListener('drop', handlers.drop);
|
||||||
|
item.addEventListener('dragend', handlers.dragend);
|
||||||
|
item.addEventListener('dragleave', handlers.dragleave);
|
||||||
|
});
|
||||||
|
|
||||||
const toggle = el.querySelector('[data-action="toggle-expanded"]');
|
const toggle = el.querySelector('[data-action="toggle-expanded"]');
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
toggle.addEventListener('click', () => this._toggleExpanded());
|
toggle.addEventListener('click', () => this._toggleExpanded());
|
||||||
@@ -298,8 +379,13 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Drag grip — custom drag implementation (Foundry v14 ApplicationV1 does not expose its drag handler)
|
// Drag grip — custom drag implementation (Foundry v14 ApplicationV1 does not expose its drag handler)
|
||||||
|
// Double-click grip resets participant order to connection order
|
||||||
const grip = el.querySelector('[data-action="drag-grip"]');
|
const grip = el.querySelector('[data-action="drag-grip"]');
|
||||||
if (grip) {
|
if (grip) {
|
||||||
|
grip.addEventListener('dblclick', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this._resetParticipantOrder();
|
||||||
|
});
|
||||||
grip.addEventListener('mousedown', e => {
|
grip.addEventListener('mousedown', e => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -316,6 +402,7 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
this._savePosition();
|
||||||
};
|
};
|
||||||
document.addEventListener('mousemove', onMove);
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
@@ -352,12 +439,15 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
setVar('--sp-tile-border-active', bw > 0 ? '1' : '0');
|
setVar('--sp-tile-border-active', bw > 0 ? '1' : '0');
|
||||||
|
|
||||||
// Sync the outer Application window width with the selected dock layout.
|
// Sync the outer Application window width with the selected dock layout.
|
||||||
|
// Spotlight mode doubles the strip window to fit the enlarged tile.
|
||||||
if (typeof this.setPosition === 'function') {
|
if (typeof this.setPosition === 'function') {
|
||||||
const layout = context?.dockLayout ?? 'vertical-sm';
|
const layout = context?.dockLayout ?? 'vertical-sm';
|
||||||
const n = context?.participants?.length ?? 0;
|
const n = context?.participants?.length ?? 0;
|
||||||
const width = this._computeStripWidth(layout, n);
|
const spotlightMultiplier = context?.isSpotlightActive ? 2 : 1;
|
||||||
|
const width = this._computeStripWidth(layout, n) * spotlightMultiplier;
|
||||||
const height = this._computeStripHeight(layout, n);
|
const height = this._computeStripHeight(layout, n);
|
||||||
this.setPosition(height === 'auto' ? { width, height: 'auto' } : { width, height });
|
const adjustedHeight = height === 'auto' ? 'auto' : height * spotlightMultiplier;
|
||||||
|
this.setPosition(adjustedHeight === 'auto' ? { width, height: 'auto' } : { width, height: adjustedHeight });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +535,11 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
this._activePopover = null;
|
this._activePopover = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove document-level keydown listener
|
||||||
|
if (typeof document?.removeEventListener === 'function') {
|
||||||
|
document.removeEventListener('keydown', this._onDocumentKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
// Tear down stream monitoring
|
// Tear down stream monitoring
|
||||||
this._teardownStreamMonitoring();
|
this._teardownStreamMonitoring();
|
||||||
|
|
||||||
@@ -708,6 +803,9 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
// Periodic health check every 15 seconds
|
// Periodic health check every 15 seconds
|
||||||
this._healthCheckInterval = setInterval(() => this._checkVideoStreamHealth(), 15000);
|
this._healthCheckInterval = setInterval(() => this._checkVideoStreamHealth(), 15000);
|
||||||
|
|
||||||
|
// Auto-save strip position every 30 seconds
|
||||||
|
this._positionSaveTimer = setInterval(() => this._savePosition(), 30000);
|
||||||
|
|
||||||
// Watch for user connection changes to refresh streams
|
// Watch for user connection changes to refresh streams
|
||||||
if (typeof Hooks !== 'undefined') {
|
if (typeof Hooks !== 'undefined') {
|
||||||
this._userConnectedHookId = Hooks.on('userConnected', (userId, connected) => {
|
this._userConnectedHookId = Hooks.on('userConnected', (userId, connected) => {
|
||||||
@@ -728,6 +826,12 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
this._healthCheckInterval = null;
|
this._healthCheckInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear position save timer
|
||||||
|
if (this._positionSaveTimer !== null) {
|
||||||
|
clearInterval(this._positionSaveTimer);
|
||||||
|
this._positionSaveTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove userConnected hook
|
// Remove userConnected hook
|
||||||
if (this._userConnectedHookId !== null && typeof Hooks !== 'undefined') {
|
if (this._userConnectedHookId !== null && typeof Hooks !== 'undefined') {
|
||||||
Hooks.off('userConnected', this._userConnectedHookId);
|
Hooks.off('userConnected', this._userConnectedHookId);
|
||||||
@@ -905,6 +1009,143 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Re-order participants (Drag & Drop) ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles drag start on a participant item.
|
||||||
|
* @param {DragEvent} e
|
||||||
|
*/
|
||||||
|
_onDragStart(e) {
|
||||||
|
const item = e.target.closest('.sp-strip__participant-item');
|
||||||
|
if (!item) return;
|
||||||
|
const userId = item.querySelector('[data-user-id]')?.dataset?.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
e.dataTransfer.setData('text/plain', userId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
item.classList.add('sp-dragging');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles drag over on a participant item — shows drop indicator.
|
||||||
|
* @param {DragEvent} e
|
||||||
|
*/
|
||||||
|
_onDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const item = e.target.closest('.sp-strip__participant-item');
|
||||||
|
if (!item) return;
|
||||||
|
item.classList.add('sp-drag-over');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles drop — reorders participants and persists the order.
|
||||||
|
* @param {DragEvent} e
|
||||||
|
*/
|
||||||
|
_onDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fromUserId = e.dataTransfer.getData('text/plain');
|
||||||
|
const toItem = e.target.closest('.sp-strip__participant-item');
|
||||||
|
if (!toItem || !fromUserId) return;
|
||||||
|
const toUserId = toItem.querySelector('[data-user-id]')?.dataset?.userId;
|
||||||
|
if (!toUserId || fromUserId === toUserId) return;
|
||||||
|
|
||||||
|
const el = this.element;
|
||||||
|
if (!el) return;
|
||||||
|
const items = [...el.querySelectorAll('.sp-strip__participant-item')];
|
||||||
|
const userIds = items.map(item => item.querySelector('[data-user-id]')?.dataset?.userId).filter(Boolean);
|
||||||
|
const fromIdx = userIds.indexOf(fromUserId);
|
||||||
|
const toIdx = userIds.indexOf(toUserId);
|
||||||
|
if (fromIdx === -1 || toIdx === -1) return;
|
||||||
|
|
||||||
|
userIds.splice(fromIdx, 1);
|
||||||
|
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx;
|
||||||
|
userIds.splice(adjustedTo, 0, fromUserId);
|
||||||
|
|
||||||
|
this._saveParticipantOrder(userIds);
|
||||||
|
if (typeof this.render === 'function') {
|
||||||
|
this.render({ force: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles drag end — removes visual indicators.
|
||||||
|
*/
|
||||||
|
_onDragEnd() {
|
||||||
|
this.element?.querySelectorAll('.sp-strip__participant-item')?.forEach(item => {
|
||||||
|
item.classList.remove('sp-dragging', 'sp-drag-over');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles drag leave — removes drop indicator from the exited item.
|
||||||
|
* @param {DragEvent} e
|
||||||
|
*/
|
||||||
|
_onDragLeave(e) {
|
||||||
|
const item = e.target.closest('.sp-strip__participant-item');
|
||||||
|
if (item) item.classList.remove('sp-drag-over');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists participant order to GM user flag.
|
||||||
|
* @param {string[]} orderedUserIds
|
||||||
|
*/
|
||||||
|
_saveParticipantOrder(orderedUserIds) {
|
||||||
|
if (typeof game === 'undefined') return;
|
||||||
|
try {
|
||||||
|
game.user?.setFlag?.('scrying-pool', 'participantOrder', orderedUserIds);
|
||||||
|
} catch (_e) { /* no-op */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears saved order — next render uses connection order.
|
||||||
|
*/
|
||||||
|
_resetParticipantOrder() {
|
||||||
|
if (typeof game === 'undefined') return;
|
||||||
|
try {
|
||||||
|
game.user?.unsetFlag?.('scrying-pool', 'participantOrder');
|
||||||
|
} catch (_e) { /* no-op */ }
|
||||||
|
if (typeof this.render === 'function') {
|
||||||
|
this.render({ force: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spotlight / Focus ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles focus on a participant. If already focused, exits focus mode.
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
_toggleFocus(userId) {
|
||||||
|
if (this._focusedUserId === userId) {
|
||||||
|
this._focusedUserId = null;
|
||||||
|
} else {
|
||||||
|
this._focusedUserId = userId;
|
||||||
|
}
|
||||||
|
if (typeof this.render === 'function') {
|
||||||
|
this.render({ force: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-save position ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves current strip position to GM user flag.
|
||||||
|
*/
|
||||||
|
_savePosition() {
|
||||||
|
if (typeof game === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const pos = this.position;
|
||||||
|
if (pos?.left == null || pos?.top == null) return;
|
||||||
|
game.user?.setFlag?.('scrying-pool', 'stripState', {
|
||||||
|
left: pos.left,
|
||||||
|
top: pos.top,
|
||||||
|
width: pos.width ?? 240,
|
||||||
|
height: pos.height ?? 'auto',
|
||||||
|
savedAt: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (_e) { /* no-op */ }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a notification with i18n support and safe fallback for test environments.
|
* Shows a notification with i18n support and safe fallback for test environments.
|
||||||
* @param {'info'|'warn'|'error'} level
|
* @param {'info'|'warn'|'error'} level
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -136,6 +136,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Small tile shell sizing (all S layouts) ─────────────────────────────────
|
||||||
|
// Base .sp-avatar__shell is 60×60 fixed — override to follow --sp-widget-width
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-vertical-sm,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-sm,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-sm {
|
||||||
|
.sp-participant-avatar .sp-avatar__shell {
|
||||||
|
width: calc(var(--sp-widget-width) - 12px);
|
||||||
|
height: calc(var(--sp-widget-width) - 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Toolbar: grip + toggle + DB + close on one line ─────────────────────────
|
// ── Toolbar: grip + toggle + DB + close on one line ─────────────────────────
|
||||||
// All chrome lives here; hidden at rest, revealed on strip hover.
|
// All chrome lives here; hidden at rest, revealed on strip hover.
|
||||||
.sp-strip__toolbar {
|
.sp-strip__toolbar {
|
||||||
@@ -501,6 +512,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Spotlight focus state — gold ring (uses urgency-director token)
|
||||||
|
// ============================================================
|
||||||
|
.sp-participant-avatar.sp-state-focused {
|
||||||
|
.sp-avatar__shell::after {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--sp-urgency-director);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Drag-and-drop reorder feedback
|
||||||
|
// ============================================================
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// StateRing animations — gated under no-preference (AC-16)
|
// StateRing animations — gated under no-preference (AC-16)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -689,6 +689,12 @@
|
|||||||
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-participant-avatar::after {
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-participant-avatar::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-vertical-sm .sp-participant-avatar .sp-avatar__shell,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-sm .sp-participant-avatar .sp-avatar__shell,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-sm .sp-participant-avatar .sp-avatar__shell {
|
||||||
|
width: calc(var(--sp-widget-width) - 12px);
|
||||||
|
height: calc(var(--sp-widget-width) - 12px);
|
||||||
|
}
|
||||||
.sp-strip__toolbar {
|
.sp-strip__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -984,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;
|
||||||
@@ -2926,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>
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<div class="directors-board__widget-width-row">
|
<div class="directors-board__widget-width-row">
|
||||||
<span>{{localize "scrying-pool.directorsBoard.widgetWidth.small"}}</span>
|
<span>{{localize "scrying-pool.directorsBoard.widgetWidth.small"}}</span>
|
||||||
<select class="directors-board__widget-width-select" data-action="set-widget-width-sm" data-selected="{{widgetWidthSm}}">
|
<select class="directors-board__widget-width-select" data-action="set-widget-width-sm" data-selected="{{widgetWidthSm}}">
|
||||||
{{#each widthOptions}}
|
{{#each smWidthOptions}}
|
||||||
<option value="{{this.value}}">{{this.label}}</option>
|
<option value="{{this.value}}">{{this.label}}</option>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</select>
|
</select>
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
<div class="directors-board__widget-width-row">
|
<div class="directors-board__widget-width-row">
|
||||||
<span>{{localize "scrying-pool.directorsBoard.widgetWidth.large"}}</span>
|
<span>{{localize "scrying-pool.directorsBoard.widgetWidth.large"}}</span>
|
||||||
<select class="directors-board__widget-width-select" data-action="set-widget-width-md" data-selected="{{widgetWidthMd}}">
|
<select class="directors-board__widget-width-select" data-action="set-widget-width-md" data-selected="{{widgetWidthMd}}">
|
||||||
{{#each widthOptions}}
|
{{#each mdWidthOptions}}
|
||||||
<option value="{{this.value}}">{{this.label}}</option>
|
<option value="{{this.value}}">{{this.label}}</option>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</select>
|
</select>
|
||||||
@@ -137,6 +137,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="directors-board__footer">
|
<footer class="directors-board__footer">
|
||||||
|
<div class="directors-board__footer-group directors-board__footer-group--strip">
|
||||||
|
<button type="button" class="directors-board__footer-btn directors-board__footer-btn--secondary" data-action="reset-strip-position"
|
||||||
|
data-tooltip="{{localize "scrying-pool.directorsBoard.footer.resetStrip"}}">
|
||||||
|
<i class="fas fa-undo-alt" aria-hidden="true"></i>
|
||||||
|
{{localize "scrying-pool.directorsBoard.footer.resetStrip"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="directors-board__footer-group directors-board__footer-group--presets">
|
<div class="directors-board__footer-group directors-board__footer-group--presets">
|
||||||
<button type="button" class="directors-board__footer-btn" data-action="save-preset"
|
<button type="button" class="directors-board__footer-btn" data-action="save-preset"
|
||||||
data-tooltip="{{localize "scrying-pool.directorsBoard.footer.savePreset"}}">
|
data-tooltip="{{localize "scrying-pool.directorsBoard.footer.savePreset"}}">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
{{#each participants}}
|
{{#each participants}}
|
||||||
<li class="sp-strip__participant-item" role="listitem">
|
<li class="sp-strip__participant-item" role="listitem">
|
||||||
{{!-- ParticipantAvatar (44×44px container) --}}
|
{{!-- ParticipantAvatar (44×44px container) --}}
|
||||||
<button class="sp-participant-avatar sp-state-{{state}}{{#if hasPendingOp}} sp-state-pending{{/if}} sp-shape-{{../tileShape}}"
|
<button class="sp-participant-avatar sp-state-{{state}}{{#if hasPendingOp}} sp-state-pending{{/if}}{{#if isFocused}} sp-state-focused{{/if}} sp-shape-{{../tileShape}}"
|
||||||
data-user-id="{{userId}}"
|
data-user-id="{{userId}}"
|
||||||
data-action="open-popover"
|
data-action="open-popover"
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -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