538 lines
20 KiB
JavaScript
538 lines
20 KiB
JavaScript
// OQ-1 Spike Result: css-fallback — FoundryVTT v14 — 2026-05-21
|
|
//
|
|
// Investigation findings (Story 1.2 spike):
|
|
// - game.webrtc is foundry.av.AVMaster — no getConnection(userId) method exists.
|
|
// - Remote stream access: game.webrtc.client.getMediaStreamForUser(userId)
|
|
// via the abstract AVClient public interface.
|
|
// - track.enabled = false on remote inbound tracks does NOT stop WebRTC bandwidth.
|
|
// RTP packets continue arriving from the remote peer regardless.
|
|
// - True bandwidth elimination requires SDP renegotiation (not in AVMaster public API).
|
|
// - LiveKit backend (CONFIG.WebRTC.clientClass override) removes peers/remoteStreams;
|
|
// only getMediaStreamForUser() remains safe across backends.
|
|
//
|
|
// Conclusion: this.webrtc = null. scrying-pool.webrtcMode = 'css-fallback' (world default).
|
|
// CSS/DOM cosmetic hiding is the only honest implementation path for Story 1.3+.
|
|
|
|
/**
|
|
* Sole gateway to game.* APIs for the Scrying Pool module.
|
|
*
|
|
* Exposes typed surfaces for: settings, socket, users, scenes, notifications, hooks, webrtc.
|
|
* All surfaces are built in the constructor — construction is side-effect-free.
|
|
*
|
|
* The `webrtc` property starts as null and must be set externally at
|
|
* Hooks.once('ready') via {@link FoundryAdapter.probeCapability} and
|
|
* {@link FoundryAdapter.buildWebRTCSurface}.
|
|
*/
|
|
export class FoundryAdapter {
|
|
/** Settings namespace for all scrying-pool world settings. */
|
|
static SETTINGS_NS = 'scrying-pool';
|
|
|
|
/**
|
|
* World setting key for the resolved WebRTC capability mode.
|
|
* Full identifier: `scrying-pool.webrtcMode`.
|
|
* Value is one of: `'track-disable'` | `'css-fallback'` | `'unsupported'`
|
|
*/
|
|
static SETTING_WEBRTC_MODE = 'webrtcMode';
|
|
|
|
/** Flag scope/namespace for module-specific user flags. */
|
|
static FLAG_SCOPE = 'video-view-manager';
|
|
|
|
/**
|
|
* Creates a FoundryAdapter. Side-effect-free — no hooks or listeners registered.
|
|
* @param {object} [game] - The FoundryVTT `game` global. Optional for legacy/test
|
|
* compatibility; surfaces will be no-ops if omitted.
|
|
*/
|
|
constructor(game) {
|
|
this._game = game ?? {};
|
|
|
|
/**
|
|
* WebRTC track-disabling surface, or null on the css-fallback/unsupported path.
|
|
* Set externally at ready time via probeCapability + buildWebRTCSurface.
|
|
* @type {{ disableTrack(userId: string): void, enableTrack(userId: string): void } | null}
|
|
*/
|
|
this.webrtc = null;
|
|
|
|
const g = /** @type {any} */ (this._game);
|
|
const ns = FoundryAdapter.SETTINGS_NS;
|
|
|
|
/**
|
|
* Settings surface — wraps game.settings, pre-namespaced to 'scrying-pool'.
|
|
* Keys passed to these methods are the SHORT key (e.g. 'visibilityMatrix'),
|
|
* NOT the fully-qualified key ('scrying-pool.visibilityMatrix').
|
|
*/
|
|
this.settings = {
|
|
/**
|
|
* Register a setting.
|
|
* @param {string} key - The setting key.
|
|
* @param {object} config - The setting configuration.
|
|
* @returns {void|undefined}
|
|
*/
|
|
register: (key, config) => g.settings?.register(ns, key, config),
|
|
/**
|
|
* Get a setting value.
|
|
* @param {string} key - The setting key.
|
|
* @returns {unknown}
|
|
*/
|
|
get: (key) => g.settings?.get(ns, key) ?? null,
|
|
/**
|
|
* Set a setting value.
|
|
* @param {string} key - The setting key.
|
|
* @param {unknown} value - The value to set.
|
|
* @returns {Promise<unknown>}
|
|
*/
|
|
set: (key, value) => /** @type {Promise<unknown>} */ (g.settings?.set(ns, key, value) ?? Promise.resolve()),
|
|
/**
|
|
* Register a settings menu.
|
|
* @param {string} namespace - The namespace.
|
|
* @param {string} menuKey - The menu key.
|
|
* @param {object} config - The menu configuration.
|
|
* @returns {void|undefined}
|
|
*/
|
|
registerMenu: (namespace, menuKey, config) => g.settings?.registerMenu(namespace, menuKey, config),
|
|
};
|
|
|
|
/** Socket surface — wraps game.socket. */
|
|
this.socket = {
|
|
/**
|
|
* Emit a socket event.
|
|
* @param {string} event - The event name.
|
|
* @param {object} payload - The event payload.
|
|
* @returns {void|undefined}
|
|
*/
|
|
emit: (event, payload) => {
|
|
if (g.socket) return g.socket.emit(event, payload);
|
|
},
|
|
/**
|
|
* Subscribe to a socket event.
|
|
* @param {string} event - The event name.
|
|
* @param {Function} handler - The event handler.
|
|
* @returns {void|undefined}
|
|
*/
|
|
on: (event, handler) => {
|
|
if (g.socket) return g.socket.on(event, handler);
|
|
},
|
|
/**
|
|
* Unsubscribe from a socket event.
|
|
* @param {string} event - The event name.
|
|
* @param {Function} handler - The event handler.
|
|
* @returns {void|undefined}
|
|
*/
|
|
off: (event, handler) => {
|
|
if (g.socket) return g.socket.off(event, handler);
|
|
},
|
|
};
|
|
|
|
/** Users surface — wraps game.users / game.user. */
|
|
this.users = {
|
|
/**
|
|
* Get a user by ID.
|
|
* @param {string} userId - The user ID.
|
|
* @returns {object|null} The user object or null.
|
|
*/
|
|
get: (userId) => g.users?.get(userId) ?? null,
|
|
/**
|
|
* Get all users.
|
|
* @returns {object[]} Array of all users.
|
|
*/
|
|
all: () => Array.from(g.users ?? []),
|
|
/**
|
|
* @param {string} [userId] - If omitted or null/undefined, checks current user.
|
|
* @returns {boolean}
|
|
*/
|
|
isGM: (userId) => {
|
|
if (userId == null) {
|
|
// null or undefined: check current user
|
|
return (g.user?.isGM ?? false);
|
|
}
|
|
// Explicit userId (including "0" or ""): look up in users map
|
|
return (g.users?.get(userId)?.isGM ?? false);
|
|
},
|
|
/** @returns {object|null} */
|
|
current: () => g.user ?? null,
|
|
/**
|
|
* Gets a user flag for a specific user.
|
|
* @param {string} userId - The user ID to get the flag for.
|
|
* @param {string} scope - The flag scope/namespace.
|
|
* @param {string} key - The flag key.
|
|
* @returns {unknown|null} The flag value, or null if not found.
|
|
*/
|
|
getFlag: (userId, scope, key) => {
|
|
const user = g.users?.get(userId);
|
|
if (user && typeof user.getFlag === 'function') {
|
|
return user.getFlag(scope, key) ?? null;
|
|
}
|
|
return null;
|
|
},
|
|
/**
|
|
* Sets a user flag for a specific user.
|
|
* Note: In FoundryVTT, users can only set their own flags. GM can set flags for any user.
|
|
* This method wraps the native user.setFlag() which returns a Promise.
|
|
* @param {string} userId - The user ID to set the flag for.
|
|
* @param {string} scope - The flag scope/namespace.
|
|
* @param {string} key - The flag key.
|
|
* @param {unknown} value - The flag value to set.
|
|
* @returns {Promise<unknown>|null} The promise from user.setFlag(), or null if user not found.
|
|
*/
|
|
setFlag: (userId, scope, key, value) => {
|
|
const user = g.users?.get(userId);
|
|
if (user && typeof user.setFlag === 'function') {
|
|
return /** @type {Promise<unknown>} */ (user.setFlag(scope, key, value));
|
|
}
|
|
return null;
|
|
},
|
|
/**
|
|
* Convenience method to get a module-scoped flag.
|
|
* @param {string} userId - The user ID to get the flag for.
|
|
* @param {string} key - The flag key (will be scoped to FoundryAdapter.FLAG_SCOPE).
|
|
* @returns {unknown|null} The flag value, or null if not found.
|
|
*/
|
|
getFlagModule: (userId, key) => {
|
|
const user = g.users?.get(userId);
|
|
if (user && typeof user.getFlag === 'function') {
|
|
return user.getFlag(FoundryAdapter.FLAG_SCOPE, key) ?? null;
|
|
}
|
|
return null;
|
|
},
|
|
/**
|
|
* Convenience method to set a module-scoped flag.
|
|
* @param {string} userId - The user ID to set the flag for.
|
|
* @param {string} key - The flag key (will be scoped to FoundryAdapter.FLAG_SCOPE).
|
|
* @param {unknown} value - The flag value to set.
|
|
* @returns {Promise<unknown>|null} The promise from user.setFlag(), or null if user not found.
|
|
*/
|
|
setFlagModule: (userId, key, value) => {
|
|
const user = g.users?.get(userId);
|
|
if (user && typeof user.setFlag === 'function') {
|
|
return /** @type {Promise<unknown>} */ (user.setFlag(FoundryAdapter.FLAG_SCOPE, key, value));
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
|
|
/** Scenes surface — wraps game.scenes. */
|
|
this.scenes = {
|
|
/**
|
|
* Get the currently active scene.
|
|
* @returns {object|null} The active scene or null.
|
|
*/
|
|
current: () => g.scenes?.active ?? null,
|
|
/**
|
|
* Get a scene by ID.
|
|
* @param {string} id - The scene ID.
|
|
* @returns {object|null} The scene object or null.
|
|
*/
|
|
get: (id) => g.scenes?.get(id) ?? null,
|
|
};
|
|
|
|
/**
|
|
* Notifications surface — wraps ui.notifications.
|
|
* Errors are caught silently — notification failures must never crash visibility logic.
|
|
*/
|
|
this.notifications = {
|
|
/**
|
|
* Show an info notification.
|
|
* @param {string} msg - The message to display.
|
|
* @returns {void}
|
|
*/
|
|
info: (msg) => { try { ui.notifications?.info(msg); } catch (err) { console.warn('[ScryingPool] notifications.info:', err); } },
|
|
/**
|
|
* Show a warning notification.
|
|
* @param {string} msg - The message to display.
|
|
* @returns {void}
|
|
*/
|
|
warn: (msg) => { try { ui.notifications?.warn(msg); } catch (err) { console.warn('[ScryingPool] notifications.warn:', err); } },
|
|
/**
|
|
* Show an error notification.
|
|
* @param {string} msg - The message to display.
|
|
* @returns {void}
|
|
*/
|
|
error: (msg) => { try { ui.notifications?.error(msg); } catch (err) { console.warn('[ScryingPool] notifications.error:', err); } },
|
|
};
|
|
|
|
/** Hooks surface — wraps FoundryVTT Hooks global. */
|
|
this.hooks = {
|
|
/**
|
|
* Subscribe to a hook event.
|
|
* @param {string} event - The event name.
|
|
* @param {(...args: unknown[]) => void} handler - The event handler.
|
|
* @returns {void|undefined}
|
|
*/
|
|
on: (event, handler) => {
|
|
if (typeof Hooks !== 'undefined') return Hooks.on(event, handler);
|
|
},
|
|
/**
|
|
* Subscribe to a hook event once.
|
|
* @param {string} event - The event name.
|
|
* @param {(...args: unknown[]) => void} handler - The event handler.
|
|
* @returns {void|undefined}
|
|
*/
|
|
once: (event, handler) => {
|
|
if (typeof Hooks !== 'undefined') return Hooks.once(event, handler);
|
|
},
|
|
/**
|
|
* Unsubscribe from a hook event.
|
|
* @param {string} event - The event name.
|
|
* @param {(...args: unknown[]) => void} handler - The event handler.
|
|
* @returns {void|undefined}
|
|
*/
|
|
off: (event, handler) => {
|
|
if (typeof Hooks !== 'undefined') return Hooks.off(event, handler);
|
|
},
|
|
/**
|
|
* Call all handlers for a hook event.
|
|
* @param {string} event - The event name.
|
|
* @param {...unknown[]} args - The event arguments.
|
|
* @returns {boolean|undefined}
|
|
*/
|
|
callAll: (event, ...args) => {
|
|
if (typeof Hooks !== 'undefined') return Hooks.callAll(event, ...args);
|
|
return false;
|
|
},
|
|
};
|
|
|
|
/** i18n surface — wraps game.i18n for localization. */
|
|
this.i18n = {
|
|
/**
|
|
* Localize a string using the module's translation keys.
|
|
* @param {string} key - The translation key (e.g., 'video-view-manager.notifications.gmHid')
|
|
* @param {object} [data] - Optional data for string interpolation
|
|
* @returns {string} The localized string
|
|
*/
|
|
localize: (key, data) => {
|
|
if (g.i18n && typeof g.i18n.localize === 'function') {
|
|
return g.i18n.localize(key, data);
|
|
}
|
|
return key; // Fallback: return the key if i18n not available
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Probes the game.webrtc (AVMaster) instance for WebRTC capability.
|
|
*
|
|
* Probe logic and results (FoundryVTT v14, 2026-05-21):
|
|
* - If game.webrtc is null/falsy → AV disabled or not yet initialised → `'unsupported'`
|
|
* - If game.webrtc is not an object → invalid type → `'unsupported'`
|
|
* - If game.webrtc.client lacks getMediaStreamForUser() → non-standard backend → `'unsupported'`
|
|
* - Otherwise: getMediaStreamForUser() is available for stream access → `'stream-access'`
|
|
*
|
|
* NOTE: For FULL REPLACEMENT architecture (hiding Foundry's AV dock and showing our own):
|
|
* - We use 'stream-access' mode to get actual MediaStream objects via getMediaStreamForUser()
|
|
* - We create our own <video> elements and set srcObject to the stream
|
|
* - We hide Foundry's AV dock with CSS
|
|
*
|
|
* The 'track-disable' and 'css-fallback' modes from the original spike were for OVERLAY
|
|
* architecture (overlaying on Foundry's tiles). For REPLACEMENT, we need 'stream-access'.
|
|
*
|
|
* @param {unknown} gameWebrtc - The game.webrtc value at ready time (may be null/undefined)
|
|
* @returns {'stream-access'|'unsupported'}
|
|
*/
|
|
static probeCapability(gameWebrtc) {
|
|
if (!gameWebrtc || typeof gameWebrtc !== 'object') return 'unsupported';
|
|
const client = /** @type {any} */ (gameWebrtc).client;
|
|
if (!client || typeof client.getMediaStreamForUser !== 'function') return 'unsupported';
|
|
// Stream access is available - we can get MediaStream objects for all users
|
|
return 'stream-access';
|
|
}
|
|
|
|
/**
|
|
* Builds the webrtc surface object for the `'stream-access'` capability path.
|
|
* Provides full access to WebRTC streams for creating custom video tiles.
|
|
*
|
|
* NOTE: This is used for FULL REPLACEMENT architecture where we:
|
|
* 1. Hide Foundry's AV dock
|
|
* 2. Create our own video elements using getMediaStreamForUser()
|
|
* 3. Display actual video feeds in our ScryingPoolStrip
|
|
*
|
|
* @param {{ client: object }} gameWebrtc - The game.webrtc (AVMaster) instance
|
|
* @returns {object} WebRTC surface with stream access methods
|
|
* @throws {TypeError} if gameWebrtc or gameWebrtc.client is invalid
|
|
*/
|
|
static buildWebRTCSurface(gameWebrtc) {
|
|
if (!gameWebrtc || typeof gameWebrtc !== 'object' || !gameWebrtc.client) {
|
|
throw new TypeError('FoundryAdapter.buildWebRTCSurface: gameWebrtc must be an object with a client property');
|
|
}
|
|
|
|
const client = gameWebrtc.client;
|
|
|
|
return {
|
|
/**
|
|
* Get the MediaStream for a specific user.
|
|
* @param {string} userId - The user ID
|
|
* @returns {MediaStream|null} The MediaStream or null if not available
|
|
*/
|
|
getMediaStreamForUser: (userId) => {
|
|
try {
|
|
if (typeof userId !== 'string' || userId.trim() === '') {
|
|
console.warn('[ScryingPool] getMediaStreamForUser: invalid userId:', userId);
|
|
return null;
|
|
}
|
|
return client.getMediaStreamForUser?.(userId) ?? null;
|
|
} catch (err) {
|
|
console.error('[ScryingPool] getMediaStreamForUser failed:', err);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get all connected user IDs.
|
|
* @returns {string[]} Array of connected user IDs
|
|
*/
|
|
getConnectedUsers: () => {
|
|
try {
|
|
return client.getConnectedUsers?.() ?? [];
|
|
} catch (err) {
|
|
console.error('[ScryingPool] getConnectedUsers failed:', err);
|
|
return [];
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get the levels stream for a user (for volume detection).
|
|
* @param {string} userId - The user ID
|
|
* @returns {MediaStream|null} The levels stream or null
|
|
*/
|
|
getLevelsStreamForUser: (userId) => {
|
|
try {
|
|
if (typeof userId !== 'string' || userId.trim() === '') {
|
|
console.warn('[ScryingPool] getLevelsStreamForUser: invalid userId:', userId);
|
|
return null;
|
|
}
|
|
return client.getLevelsStreamForUser?.(userId) ?? null;
|
|
} catch (err) {
|
|
console.error('[ScryingPool] getLevelsStreamForUser failed:', err);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Check if current user's audio is enabled.
|
|
* @returns {boolean}
|
|
*/
|
|
isAudioEnabled: () => {
|
|
try {
|
|
return client.isAudioEnabled?.() ?? false;
|
|
} catch (err) {
|
|
console.error('[ScryingPool] isAudioEnabled failed:', err);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Check if current user's video is enabled.
|
|
* @returns {boolean}
|
|
*/
|
|
isVideoEnabled: () => {
|
|
try {
|
|
return client.isVideoEnabled?.() ?? false;
|
|
} catch (err) {
|
|
console.error('[ScryingPool] isVideoEnabled failed:', err);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Enable or disable current user's audio.
|
|
* @param {boolean} enable - Whether to enable audio
|
|
*/
|
|
toggleAudio: (enable) => {
|
|
try {
|
|
if (typeof client.toggleAudio === 'function') {
|
|
client.toggleAudio(enable);
|
|
}
|
|
} catch (err) {
|
|
console.error('[ScryingPool] toggleAudio failed:', err);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Enable or disable current user's video.
|
|
* @param {boolean} enable - Whether to enable video
|
|
*/
|
|
toggleVideo: (enable) => {
|
|
try {
|
|
if (typeof client.toggleVideo === 'function') {
|
|
client.toggleVideo(enable);
|
|
}
|
|
} catch (err) {
|
|
console.error('[ScryingPool] toggleVideo failed:', err);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Enable or disable current user's broadcast.
|
|
* @param {boolean} enable - Whether to enable broadcast
|
|
*/
|
|
toggleBroadcast: (enable) => {
|
|
try {
|
|
if (typeof client.toggleBroadcast === 'function') {
|
|
client.toggleBroadcast(enable);
|
|
}
|
|
} catch (err) {
|
|
console.error('[ScryingPool] toggleBroadcast failed:', err);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Set a video element for a user with their stream.
|
|
* @param {string} userId - The user ID
|
|
* @param {HTMLVideoElement} videoElement - The video element to set
|
|
* @returns {Promise<void>}
|
|
*/
|
|
setUserVideo: async (userId, videoElement) => {
|
|
try {
|
|
if (typeof userId !== 'string' || userId.trim() === '') {
|
|
console.warn('[ScryingPool] setUserVideo: invalid userId:', userId);
|
|
return;
|
|
}
|
|
if (!(videoElement instanceof HTMLVideoElement)) {
|
|
console.warn('[ScryingPool] setUserVideo: videoElement is not a HTMLVideoElement');
|
|
return;
|
|
}
|
|
if (typeof client.setUserVideo === 'function') {
|
|
await client.setUserVideo(userId, videoElement);
|
|
}
|
|
} catch (err) {
|
|
console.error('[ScryingPool] setUserVideo failed:', err);
|
|
}
|
|
},
|
|
|
|
// Legacy: disable video track (cosmetic only, doesn't reduce bandwidth)
|
|
disableTrack: (userId) => {
|
|
try {
|
|
if (typeof userId !== 'string' || userId.trim() === '') {
|
|
console.warn('[ScryingPool] disableTrack: invalid userId:', userId);
|
|
return;
|
|
}
|
|
const stream = client.getMediaStreamForUser?.(userId);
|
|
const tracks = stream?.getVideoTracks() ?? [];
|
|
for (const track of tracks) track.enabled = false;
|
|
if (tracks.length === 0) {
|
|
console.warn('[ScryingPool] disableTrack: no video tracks found for', userId);
|
|
}
|
|
} catch (err) {
|
|
console.error('[ScryingPool] disableTrack failed:', err);
|
|
}
|
|
},
|
|
|
|
// Legacy: enable video track
|
|
enableTrack: (userId) => {
|
|
try {
|
|
if (typeof userId !== 'string' || userId.trim() === '') {
|
|
console.warn('[ScryingPool] enableTrack: invalid userId:', userId);
|
|
return;
|
|
}
|
|
const stream = client.getMediaStreamForUser?.(userId);
|
|
const tracks = stream?.getVideoTracks() ?? [];
|
|
for (const track of tracks) track.enabled = true;
|
|
if (tracks.length === 0) {
|
|
console.warn('[ScryingPool] enableTrack: no video tracks found for', userId);
|
|
}
|
|
} catch (err) {
|
|
console.error('[ScryingPool] enableTrack failed:', err);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
}
|