Video over token, free-form video windows
CI / ci (push) Successful in 40s
Release Creation / build (release) Successful in 46s

This commit is contained in:
2026-06-07 22:18:08 +02:00
parent a9dbb9306a
commit 76ce992505
22 changed files with 2649 additions and 36 deletions
+124
View File
@@ -0,0 +1,124 @@
/**
* GMActorMappingPanel — GM settings submenu for assigning actors to users
* for the Token Video Overlay feature.
*
* The GM maps an actor to a user's webcam feed. When "Show Webcam Video on
* Tokens" is enabled, any token of the mapped actor will display that user's
* webcam instead of the token icon.
*
* Extends ApplicationV2 via HandlebarsApplicationMixin.
* Uses module-level _adapter for DI (same pattern as PlayerPrivacyPanelMenu).
*
* @module ui/gm/GMActorMappingPanel
*/
/** @type {import('../../foundry/FoundryAdapter.js').FoundryAdapter|null} */
let _adapter = null;
/**
* Initialize static adapter reference. Called once from module.js ready hook.
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
*/
export function initGMActorMappingPanel(adapter) {
_adapter = adapter;
}
const _AppBase =
typeof foundry !== 'undefined' &&
foundry.applications?.api?.HandlebarsApplicationMixin &&
foundry.applications?.api?.ApplicationV2
? foundry.applications.api.HandlebarsApplicationMixin(
foundry.applications.api.ApplicationV2
)
: class _FallbackApp {
static DEFAULT_OPTIONS = {};
static PARTS = {};
get rendered() { return this._rendered ?? false; }
set rendered(v) { this._rendered = v; }
get element() { return this._element ?? null; }
set element(v) { this._element = v; }
async render() { this._rendered = true; }
async close() { this._rendered = false; }
async _prepareContext() { return {}; }
_onRender() {}
_onClose() {}
_onPosition() {}
};
/**
* GM Actor Mapping Panel — assign actors to user webcams for token overlay.
*/
export class GMActorMappingPanel extends _AppBase {
static DEFAULT_OPTIONS = {
id: 'scrying-pool-actor-mapping',
classes: ['scrying-pool', 'actor-mapping'],
window: {
title: 'SCRYING_POOL.ActorMapping.title',
resizable: false,
width: 450,
height: 'auto',
},
position: {},
};
static PARTS = {
form: {
template: 'modules/scrying-pool/templates/actor-mapping.hbs',
},
};
async _prepareContext() {
const mapping = _adapter?.settings?.get('userActorMapping') ?? {};
const selectedId = (uid) => mapping[uid] ?? '';
const sortedActors = _adapter.actors.all()
.map(a => ({ id: a.id, name: a.name }))
.sort((a, b) => a.name.localeCompare(b.name));
const users = _adapter.users.all()
.map(u => ({
id: u.id,
name: u.name,
isGM: u.isGM,
avatar: u.avatar ?? 'icons/svg/mystery-man.svg',
actors: sortedActors.map(a => ({
id: a.id,
name: a.name,
selected: a.id === selectedId(u.id),
})),
}))
.sort((a, b) => {
if (a.isGM && !b.isGM) return -1;
if (!a.isGM && b.isGM) return 1;
return a.name.localeCompare(b.name);
});
return {
hasNoUsers: users.length === 0,
users,
};
}
_onRender(context, options) {
if (this._formHandlerAttached) return;
this._formHandlerAttached = true;
const form = this.element?.querySelector('form');
if (!form) return;
form.addEventListener('change', (event) => {
const select = event.target;
if (select.tagName !== 'SELECT') return;
const mapping = { ...(_adapter?.settings?.get('userActorMapping') ?? {}) };
if (select.value) {
mapping[select.name] = select.value;
} else {
delete mapping[select.name];
}
_adapter?.settings?.set('userActorMapping', mapping).catch(err => {
console.error('[ScryingPool] Failed to save actor mapping:', err);
});
});
}
}