This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
# Blind Hunter — WebRTC Full AV Replacement Code Review
|
||||
|
||||
**Diff Source:** Commit range HEAD~1..HEAD (c4a375f)
|
||||
**Review Target:** WebRTC full AV replacement implementation
|
||||
**Date:** 2026-05-26
|
||||
|
||||
---
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
You are the **Blind Hunter** — a cynical, jaded code reviewer with zero patience for sloppy work. You expect to find problems. Be skeptical of everything. Look for what's missing, not just what's wrong. Use a precise, professional tone — no profanity or personal attacks.
|
||||
|
||||
**YOU RECEIVE ONLY THIS DIFF.** You have NO project context, NO spec, NO access to the codebase. Review ONLY what is shown in the diff below.
|
||||
|
||||
## DIFF TO REVIEW
|
||||
|
||||
```diff
|
||||
diff --git a/module.js b/module.js
|
||||
index 86d48ac..c5fe82f 100644
|
||||
--- a/module.js
|
||||
+++ b/module.js
|
||||
@@ -57,15 +57,17 @@ let directorsBoardButtonAdded = false;
|
||||
Hooks.once("init", () => {
|
||||
console.log("[ScryingPool] init — module loading");
|
||||
|
||||
- // OQ-1 resolved (Story 1.2 spike): probe result is 'css-fallback' — see FoundryAdapter.js
|
||||
+ // WebRTC mode setting — determines how the module handles AV integration
|
||||
+ // Updated for FULL REPLACEMENT architecture (hiding Foundry's dock, showing our own)
|
||||
game.settings.register("scrying-pool", "webrtcMode", {
|
||||
scope: "world",
|
||||
config: false,
|
||||
type: String,
|
||||
- default: "css-fallback",
|
||||
+ default: "stream-access",
|
||||
choices: {
|
||||
- "track-disable": "Track Disable (bandwidth-saving)",
|
||||
- "css-fallback": "CSS Fallback (cosmetic hiding)",
|
||||
+ "stream-access": "Stream Access (full replacement - hide Foundry dock, show VVM dock with actual video)",
|
||||
+ "track-disable": "Track Disable (bandwidth-saving) - DEPRECATED",
|
||||
+ "css-fallback": "CSS Fallback (cosmetic hiding) - DEPRECATED",
|
||||
"unsupported": "Unsupported (AV not available)",
|
||||
},
|
||||
});
|
||||
@@ -175,14 +177,25 @@ Hooks.once("ready", () => {
|
||||
// Hydrate StateStore from persisted world setting (AC-6, AC-7)
|
||||
stateStore.init();
|
||||
|
||||
- // Probe WebRTC capability and set adapter.webrtc (AC-8)
|
||||
+ // Probe WebRTC capability and set adapter.webrtc
|
||||
+ // For FULL REPLACEMENT: we need stream access to create our own video tiles
|
||||
const outcome = FoundryAdapter.probeCapability(game.webrtc);
|
||||
- adapter.webrtc = outcome === 'track-disable'
|
||||
- ? FoundryAdapter.buildWebRTCSurface(game.webrtc)
|
||||
- : null;
|
||||
adapter.settings.set(FoundryAdapter.SETTING_WEBRTC_MODE, outcome).catch(err => {
|
||||
console.error('[ScryingPool] Failed to set webrtcMode setting:', err);
|
||||
});
|
||||
+
|
||||
+ // Build WebRTC surface for stream access (full replacement mode)
|
||||
+ if (outcome === 'stream-access') {
|
||||
+ adapter.webrtc = FoundryAdapter.buildWebRTCSurface(game.webrtc);
|
||||
+ console.log('[ScryingPool] WebRTC stream access available - full replacement mode enabled');
|
||||
+ } else if (outcome === 'unsupported') {
|
||||
+ adapter.webrtc = null;
|
||||
+ console.log('[ScryingPool] WebRTC not available - AV features disabled');
|
||||
+ } else {
|
||||
+ // Legacy: track-disable or css-fallback (deprecated)
|
||||
+ adapter.webrtc = FoundryAdapter.buildWebRTCSurface(game.webrtc);
|
||||
+ console.warn('[ScryingPool] WebRTC mode is deprecated:', outcome, '- consider using stream-access');
|
||||
+ }
|
||||
|
||||
// Wire core managers — construct both before setReady so handler can reference both
|
||||
visibilityManager = new VisibilityManager(stateStore, adapter);
|
||||
diff --git a/scripts/test-stream-access.mjs b/scripts/test-stream-access.mjs
|
||||
new file mode 100644
|
||||
index 0000000..8d91db7
|
||||
--- /dev/null
|
||||
+++ b/scripts/test-stream-access.mjs
|
||||
@@ -0,0 +1,159 @@
|
||||
+/**
|
||||
+ * Test script to verify WebRTC stream access implementation
|
||||
+ * This script tests the full AV replacement architecture
|
||||
+ *
|
||||
+ * Run with: node scripts/test-stream-access.mjs
|
||||
+ *
|
||||
+ * Tests:
|
||||
+ * 1. FoundryAdapter.probeCapability() returns 'stream-access' when getMediaStreamForUser is available
|
||||
+ * 2. FoundryAdapter.buildWebRTCSurface() creates proper WebRTC surface
|
||||
+ * 3. ScryingPoolStrip.getData() includes hasStreamAccess flag
|
||||
+ * 4. buildParticipantList() includes hasStreamAccess in participant objects
|
||||
+ * 5. CSS includes rules to hide Foundry's AV dock
|
||||
+ * 6. Template includes video container element
|
||||
+ */
|
||||
+
|
||||
+import { FoundryAdapter } from '../src/foundry/FoundryAdapter.js';
|
||||
+import { buildParticipantList } from '../src/ui/gm/ScryingPoolStrip.js';
|
||||
+
|
||||
+console.log('=== WebRTC Stream Access Implementation Tests ===\n');
|
||||
+
|
||||
+// Test 1: probeCapability returns 'stream-access' when getMediaStreamForUser is available
|
||||
+console.log('Test 1: probeCapability with stream access');
|
||||
+const mockWebRTCWithStream = {
|
||||
+ client: {
|
||||
+ getMediaStreamForUser: (userId) => new MediaStream(),
|
||||
+ getConnectedUsers: () => ['user1', 'user2'],
|
||||
+ },
|
||||
+};
|
||||
+const result1 = FoundryAdapter.probeCapability(mockWebRTCWithStream);
|
||||
+console.log(` probeCapability result: ${result1}`);
|
||||
+console.assert(result1 === 'stream-access', 'Should return stream-access');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 2: probeCapability returns 'unsupported' when getMediaStreamForUser is missing
|
||||
+console.log('Test 2: probeCapability without stream access');
|
||||
+const mockWebRTCWithoutStream = {
|
||||
+ client: {},
|
||||
+};
|
||||
+const result2 = FoundryAdapter.probeCapability(mockWebRTCWithoutStream);
|
||||
+console.log(` probeCapability result: ${result2}`);
|
||||
+console.assert(result2 === 'unsupported', 'Should return unsupported');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 3: buildWebRTCSurface creates proper surface with all methods
|
||||
+console.log('Test 3: buildWebRTCSurface creates complete WebRTC surface');
|
||||
+const surface = FoundryAdapter.buildWebRTCSurface(mockWebRTCWithStream);
|
||||
+console.log(' WebRTC surface methods:');
|
||||
+console.log(' - getMediaStreamForUser:', typeof surface.getMediaStreamForUser === 'function' ? '✓' : '✗');
|
||||
+console.log(' - getConnectedUsers:', typeof surface.getConnectedUsers === 'function' ? '✓' : '✗');
|
||||
+console.log(' - getLevelsStreamForUser:', typeof surface.getLevelsStreamForUser === 'function' ? '✓' : '✗');
|
||||
+console.log(' - isAudioEnabled:', typeof surface.isAudioEnabled === 'function' ? '✓' : '✗');
|
||||
+console.log(' - isVideoEnabled:', typeof surface.isVideoEnabled === 'function' ? '✓' : '✗');
|
||||
+console.log(' - toggleAudio:', typeof surface.toggleAudio === 'function' ? '✓' : '✗');
|
||||
+console.log(' - toggleVideo:', typeof surface.toggleVideo === 'function' ? '✓' : '✗');
|
||||
+console.log(' - toggleBroadcast:', typeof surface.toggleBroadcast === 'function' ? '✓' : '✗');
|
||||
+console.log(' - setUserVideo:', typeof surface.setUserVideo === 'function' ? '✓' : '✗');
|
||||
+console.log(' - disableTrack:', typeof surface.disableTrack === 'function' ? '✓' : '✗');
|
||||
+console.log(' - enableTrack:', typeof surface.enableTrack === 'function' ? '✓' : '✗');
|
||||
+
|
||||
+const allMethodsPresent =
|
||||
+ typeof surface.getMediaStreamForUser === 'function' &&
|
||||
+ typeof surface.getConnectedUsers === 'function' &&
|
||||
+ typeof surface.getLevelsStreamForUser === 'function' &&
|
||||
+ typeof surface.isAudioEnabled === 'function' &&
|
||||
+ typeof surface.isVideoEnabled === 'function' &&
|
||||
+ typeof surface.toggleAudio === 'function' &&
|
||||
+ typeof surface.toggleVideo === 'function' &&
|
||||
+ typeof surface.toggleBroadcast === 'function' &&
|
||||
+ typeof surface.setUserVideo === 'function' &&
|
||||
+ typeof surface.disableTrack === 'function' &&
|
||||
+ typeof surface.enableTrack === 'function';
|
||||
+
|
||||
+console.assert(allMethodsPresent, 'All WebRTC surface methods should be functions');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 4: buildParticipantList includes hasStreamAccess
|
||||
+console.log('Test 4: buildParticipantList includes hasStreamAccess flag');
|
||||
+const mockAdapter = {
|
||||
+ users: {
|
||||
+ get: (userId) => ({ name: userId, avatar: null }),
|
||||
+ },
|
||||
+};
|
||||
+const mockStateStore = {
|
||||
+ getState: (userId) => 'active',
|
||||
+};
|
||||
+const mockController = {
|
||||
+ hasPendingOp: (userId) => false,
|
||||
+};
|
||||
+
|
||||
+const participants = buildParticipantList(
|
||||
+ ['user1', 'user2'],
|
||||
+ mockStateStore,
|
||||
+ mockController,
|
||||
+ mockAdapter,
|
||||
+ true // hasStreamAccess
|
||||
+);
|
||||
+
|
||||
+console.log(` Participant count: ${participants.length}`);
|
||||
+console.log(` First participant hasStreamAccess: ${participants[0].hasStreamAccess}`);
|
||||
+console.assert(participants.length === 2, 'Should have 2 participants');
|
||||
+console.assert(participants[0].hasStreamAccess === true, 'Participant should have hasStreamAccess flag');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 5: CSS includes rules to hide Foundry's AV dock
|
||||
+console.log('Test 5: CSS includes AV dock hiding rules');
|
||||
+import fs from 'fs';
|
||||
+import path from 'path';
|
||||
+
|
||||
+const cssPath = path.join(process.cwd(), 'styles', 'scrying-pool.less');
|
||||
+const cssContent = fs.readFileSync(cssPath, 'utf8');
|
||||
+
|
||||
+const hasAvHiding = cssContent.includes('#av') && cssContent.includes('display: none');
|
||||
+const hasCameraViewHiding = cssContent.includes('.camera-view') && cssContent.includes('display: none');
|
||||
+
|
||||
+console.log(` Has #av hiding rule: ${hasAvHiding ? '✓' : '✗'}`);
|
||||
+console.log(` Has .camera-view hiding rule: ${hasCameraViewHiding ? '✓' : '✗'}`);
|
||||
+console.assert(hasAvHiding, 'CSS should include #av hiding rule');
|
||||
+console.assert(hasCameraViewHiding, 'CSS should include .camera-view hiding rule');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 6: Template includes video container
|
||||
+console.log('Test 6: Template includes video container element');
|
||||
+const templatePath = path.join(process.cwd(), 'templates', 'roster-strip.hbs');
|
||||
+const templateContent = fs.readFileSync(templatePath, 'utf8');
|
||||
+
|
||||
+const hasVideoContainer = templateContent.includes('sp-participant-video');
|
||||
+const hasStreamAccessCheck = templateContent.includes('hasStreamAccess');
|
||||
+
|
||||
+console.log(` Has video container class: ${hasVideoContainer ? '✓' : '✗'}`);
|
||||
+console.log(` Has stream access check: ${hasStreamAccessCheck ? '✓' : '✗'}`);
|
||||
+console.assert(hasVideoContainer, 'Template should include video container');
|
||||
+console.assert(hasStreamAccessCheck, 'Template should check for stream access');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 7: CSS includes video element styling
|
||||
+console.log('Test 7: CSS includes video element styling');
|
||||
+const rosterCssPath = path.join(process.cwd(), 'styles', 'components', '_roster-strip.less');
|
||||
+const rosterCssContent = fs.readFileSync(rosterCssPath, 'utf8');
|
||||
+
|
||||
+const hasVideoContainerClass = rosterCssContent.includes('.sp-participant-video');
|
||||
+const hasVideoElementClass = rosterCssContent.includes('.sp-participant-video__element');
|
||||
+
|
||||
+console.log(` Has .sp-participant-video class: ${hasVideoContainerClass ? '✓' : '✗'}`);
|
||||
+console.log(` Has .sp-participant-video__element class: ${hasVideoElementClass ? '✓' : '✗'}`);
|
||||
+console.assert(hasVideoContainerClass, 'CSS should include video container class');
|
||||
+console.assert(hasVideoElementClass, 'CSS should include video element class');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Summary
|
||||
+console.log('=== All Tests Passed! ===');
|
||||
+console.log('\nImplementation Summary:');
|
||||
+console.log('✓ FoundryAdapter.probeCapability() correctly identifies stream-access mode');
|
||||
+console.log('✓ FoundryAdapter.buildWebRTCSurface() provides full WebRTC client API');
|
||||
+console.log('✓ ScryingPoolStrip.getData() includes hasStreamAccess flag');
|
||||
+console.log('✓ buildParticipantList() passes hasStreamAccess to participants');
|
||||
+console.log('✓ CSS hides Foundry AV dock (#av and .camera-view)');
|
||||
+console.log('✓ Template includes video container with conditional rendering');
|
||||
+console.log('✓ CSS includes styling for video containers and elements');
|
||||
+console.log('\nFull AV replacement architecture is implemented and ready!');
|
||||
diff --git a/src/foundry/FoundryAdapter.js b/src/foundry/FoundryAdapter.js
|
||||
index 035b75a..680a063 100644
|
||||
--- a/src/foundry/FoundryAdapter.js
|
||||
+++ b/src/foundry/FoundryAdapter.js
|
||||
@@ -308,51 +308,183 @@ export class FoundryAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
- * Probes the game.webrtc (AVMaster) instance for WebRTC track-disabling capability.
|
||||
+ * 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: tracks are technically reachable but track.enabled = false does NOT reduce
|
||||
- * inbound WebRTC bandwidth (RTP packets keep arriving). The `'track-disable'` outcome
|
||||
- * requires true bandwidth elimination, so the result is `'css-fallback'`.
|
||||
+ * - Otherwise: getMediaStreamForUser() is available for stream access → `'stream-access'`
|
||||
*
|
||||
- * The `'track-disable'` branch in buildWebRTCSurface is kept for forward compatibility
|
||||
- * in case a future FoundryVTT version or custom AVClient exposes real bandwidth control.
|
||||
+ * 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 {'track-disable'|'css-fallback'|'unsupported'}
|
||||
+ * @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';
|
||||
- // track.enabled = false on remote inbound tracks does NOT stop WebRTC bandwidth.
|
||||
- // The 'track-disable' branch is unreachable with the current FoundryVTT v14 API.
|
||||
- return 'css-fallback';
|
||||
+ // Stream access is available - we can get MediaStream objects for all users
|
||||
+ return 'stream-access';
|
||||
}
|
||||
|
||||
/**
|
||||
- * Builds the webrtc surface object for the `'track-disable'` capability path.
|
||||
+ * Builds the webrtc surface object for the `'stream-access'` capability path.
|
||||
+ * Provides full access to WebRTC streams for creating custom video tiles.
|
||||
*
|
||||
- * NOTE: As of FoundryVTT v14, {@link FoundryAdapter.probeCapability} never returns
|
||||
- * `'track-disable'` because `track.enabled = false` does not stop inbound RTP bandwidth.
|
||||
- * This method is kept for forward compatibility and as tested documentation of the
|
||||
- * interface contract that Story 1.3+ consumers expect.
|
||||
+ * 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: { getMediaStreamForUser(userId: string): (MediaStream|null|undefined) } }} gameWebrtc
|
||||
- * @returns {{ disableTrack(userId: string): void, enableTrack(userId: string): void }}
|
||||
+ * @param {{ client: object, settings: 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 {
|
||||
- disableTrack(userId) {
|
||||
+ /**
|
||||
+ * 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 {
|
||||
+ 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 {
|
||||
+ 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 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 {
|
||||
- const stream = gameWebrtc.client.getMediaStreamForUser(userId);
|
||||
+ const stream = client.getMediaStreamForUser?.(userId);
|
||||
const tracks = stream?.getVideoTracks() ?? [];
|
||||
for (const track of tracks) track.enabled = false;
|
||||
if (tracks.length === 0) {
|
||||
@@ -362,9 +494,11 @@ export class FoundryAdapter {
|
||||
console.error('[ScryingPool] disableTrack failed:', err);
|
||||
}
|
||||
},
|
||||
- enableTrack(userId) {
|
||||
+
|
||||
+ // Legacy: enable video track
|
||||
+ enableTrack: (userId) => {
|
||||
try {
|
||||
- const stream = gameWebrtc.client.getMediaStreamForUser(userId);
|
||||
+ const stream = client.getMediaStreamForUser?.(userId);
|
||||
const tracks = stream?.getVideoTracks() ?? [];
|
||||
for (const track of tracks) track.enabled = true;
|
||||
if (tracks.length === 0) {
|
||||
diff --git a/src/ui/gm/ScryingPoolStrip.js b/src/ui/gm/ScryingPoolStrip.js
|
||||
index ad5f089..f48fd75 100644
|
||||
--- a/src/ui/gm/ScryingPoolStrip.js
|
||||
+++ b/src/ui/gm/ScryingPoolStrip.js
|
||||
@@ -28,9 +28,10 @@ export function resolveTargetState(currentState) {
|
||||
* @param {object} stateStore
|
||||
* @param {object} controller
|
||||
* @param {object} adapter
|
||||
+ * @param {boolean} hasStreamAccess - Whether stream access is available for video replacement
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
-export function buildParticipantList(userIds, stateStore, controller, adapter) {
|
||||
+export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false) {
|
||||
return userIds.map(userId => {
|
||||
const user = adapter.users.get(userId) ?? { name: userId, avatar: null };
|
||||
const state = stateStore.getState(userId) ?? 'active';
|
||||
@@ -42,6 +43,7 @@ export function buildParticipantList(userIds, stateStore, controller, adapter) {
|
||||
stateLabel: _stateLabel(state),
|
||||
hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false,
|
||||
isCurrentUser: adapter.users.current?.()?.id === userId,
|
||||
+ hasStreamAccess,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -152,11 +154,15 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
? this._adapter.users.all().map(u => u.id)
|
||||
: [];
|
||||
|
||||
+ // Check if we have stream access for video replacement (full AV replacement mode)
|
||||
+ const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined;
|
||||
+
|
||||
const participants = buildParticipantList(
|
||||
userIds,
|
||||
this._stateStore,
|
||||
this._controller,
|
||||
- this._adapter
|
||||
+ this._adapter,
|
||||
+ hasStreamAccess
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -164,6 +170,7 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
isExpanded: this._isExpanded,
|
||||
isEmpty: participants.length === 0,
|
||||
showFirstOpenTip,
|
||||
+ hasStreamAccess,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,6 +214,11 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
if (isFirstOpen) {
|
||||
game.user?.setFlag?.('video-view-manager', 'firstStripOpen', true);
|
||||
}
|
||||
+
|
||||
+ // Attach video streams if we have stream access (full AV replacement mode)
|
||||
+ if (this._adapter.webrtc?.getMediaStreamForUser !== undefined) {
|
||||
+ this._attachVideoStreams(el);
|
||||
+ }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
@@ -323,6 +335,80 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
const baseRevision = this._controller.getRevision?.(participantId) ?? 0;
|
||||
this._controller.action('strip', participantId, targetState, opId, baseRevision);
|
||||
}
|
||||
+
|
||||
+ /**
|
||||
+ * Attaches video stream elements for all participants in the strip.
|
||||
+ * Called from activateListeners() when stream access is available.
|
||||
+ * @param {HTMLElement} container - The container element (html from activateListeners)
|
||||
+ */
|
||||
+ _attachVideoStreams(container) {
|
||||
+ const participantItems = container.querySelectorAll('.sp-strip__participant-item');
|
||||
+ for (const item of participantItems) {
|
||||
+ const userId = item.querySelector('[data-user-id]')?.dataset.userId;
|
||||
+ if (userId) {
|
||||
+ this._attachVideoStream(userId, item);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * Attaches a video element with the WebRTC stream for a specific user.
|
||||
+ * Creates a video element with srcObject set to the user's MediaStream.
|
||||
+ * The video element is appended to the video container within the participant item.
|
||||
+ * @param {string} userId - The user ID to attach video for
|
||||
+ * @param {HTMLElement} participantItem - The participant list item element
|
||||
+ */
|
||||
+ _attachVideoStream(userId, participantItem) {
|
||||
+ const stream = this._adapter.webrtc.getMediaStreamForUser(userId);
|
||||
+
|
||||
+ // Check if video container exists
|
||||
+ const videoContainer = participantItem.querySelector('.sp-participant-video');
|
||||
+ if (!videoContainer) {
|
||||
+ console.warn('[ScryingPool] No video container found for user:', userId);
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Remove any existing video element
|
||||
+ const existingVideo = videoContainer.querySelector('video');
|
||||
+ if (existingVideo) {
|
||||
+ existingVideo.remove();
|
||||
+ }
|
||||
+
|
||||
+ // If no stream available, don't create video element (will show avatar fallback)
|
||||
+ if (!stream) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Create new video element
|
||||
+ const videoElement = document.createElement('video');
|
||||
+ videoElement.srcObject = stream;
|
||||
+ videoElement.autoplay = true;
|
||||
+ videoElement.playsInline = true;
|
||||
+ videoElement.muted = userId === this._adapter.users.current?.()?.id;
|
||||
+
|
||||
+ // Add CSS class for styling
|
||||
+ videoElement.className = 'sp-participant-video__element';
|
||||
+
|
||||
+ // Set up error handling
|
||||
+ videoElement.addEventListener('error', () => {
|
||||
+ console.warn('[ScryingPool] Video element error for user:', userId);
|
||||
+ });
|
||||
+
|
||||
+ videoContainer.appendChild(videoElement);
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * Refreshes all video streams for participants.
|
||||
+ * Called when stream state changes (user joins/leaves, mute/unmute).
|
||||
+ */
|
||||
+ _refreshVideoStreams() {
|
||||
+ if (!this._adapter.webrtc?.getMediaStreamForUser) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Re-render to ensure DOM is up to date
|
||||
+ this.render(false);
|
||||
+ }
|
||||
}
|
||||
|
||||
/**
|
||||
diff --git a/styles/components/_roster-strip.less b/styles/components/_roster-strip.less
|
||||
index d8e22b8..a857ae3 100644
|
||||
--- a/styles/components/_roster-strip.less
|
||||
+++ b/styles/components/_roster-strip.less
|
||||
@@ -93,6 +93,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
+// Video container for WebRTC stream (full AV replacement mode)
|
||||
+.sp-participant-video {
|
||||
+ position: absolute;
|
||||
+ inset: 0;
|
||||
+ width: 100%;
|
||||
+ height: 100%;
|
||||
+ display: flex;
|
||||
+ align-items: center;
|
||||
+ justify-content: center;
|
||||
+ overflow: hidden;
|
||||
+ z-index: 1;
|
||||
+}
|
||||
+
|
||||
.sp-avatar__img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -101,6 +114,24 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
+// Video element styling
|
||||
+.sp-participant-video__element {
|
||||
+ width: 100%;
|
||||
+ height: 100%;
|
||||
+ object-fit: cover;
|
||||
+ border-radius: 50%;
|
||||
+ background: hsl(220, 15%, 18%);
|
||||
+
|
||||
+ .is-expanded & {
|
||||
+ border-radius: 4px;
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+// Hide avatar image when video stream is active (has video element)
|
||||
+.sp-participant-video:not(:empty) ~ .sp-avatar__img {
|
||||
+ display: none;
|
||||
+}
|
||||
+
|
||||
.sp-avatar__corner-badge {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
@@ -229,7 +260,7 @@
|
||||
z-index: 10;
|
||||
|
||||
&::before {
|
||||
- content: '\f023'; // fa-lock
|
||||
+ content: '\f023'; //fa-lock
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
font-size: 1.2rem;
|
||||
diff --git a/styles/scrying-pool.less b/styles/scrying-pool.less
|
||||
index 0fba2b0..d8b0a55 100644
|
||||
--- a/styles/scrying-pool.less
|
||||
+++ b/styles/scrying-pool.less
|
||||
@@ -60,3 +60,21 @@
|
||||
--sp-badge-surface: var(--sp-badge-bg, rgba(0, 0, 0, 0.72));
|
||||
--sp-badge-color: var(--sp-badge-text, #dde2e8);
|
||||
}
|
||||
+
|
||||
+// ============================================================
|
||||
+// Full AV Replacement Mode: Hide Foundry's AV Dock
|
||||
+// ============================================================
|
||||
+// When module is active with stream-access mode, completely hide
|
||||
+// Foundry's native AV dock UI and replace it with our ScryingPoolStrip
|
||||
+// This implements the full replacement architecture (not overlay)
|
||||
+// ============================================================
|
||||
+
|
||||
+// Hide the entire AV dock container
|
||||
+#av {
|
||||
+ display: none !important;
|
||||
+}
|
||||
+
|
||||
+// Also hide individual camera views in case they're rendered elsewhere
|
||||
+.camera-view {
|
||||
+ display: none !important;
|
||||
+}
|
||||
diff --git a/templates/roster-strip.hbs b/templates/roster-strip.hbs
|
||||
index 4c28562..802f115 100644
|
||||
--- a/templates/roster-strip.hbs
|
||||
+++ b/templates/roster-strip.hbs
|
||||
@@ -44,7 +44,12 @@
|
||||
aria-label="{{name}} — {{stateLabel}}"
|
||||
aria-pressed="false">
|
||||
|
||||
- {{!-- Avatar image (32px rounded) --}}
|
||||
+ {{!-- Video container for stream-access mode (full AV replacement) --}}
|
||||
+ {{#if hasStreamAccess}}
|
||||
+ <div class="sp-participant-video" aria-hidden="true"></div>
|
||||
+ {{/if}}
|
||||
+
|
||||
+ {{!-- Avatar image (32px rounded) - shown as fallback when no video --}}
|
||||
<img class="sp-avatar__img" src="{{avatarSrc}}" alt="" aria-hidden="true" />
|
||||
|
||||
{{!-- Corner badge (12px bottom-right) --}}
|
||||
@@ -0,0 +1,753 @@
|
||||
# Edge Case Hunter — WebRTC Full AV Replacement Code Review
|
||||
|
||||
**Diff Source:** Commit range HEAD~1..HEAD (c4a375f)
|
||||
**Review Target:** WebRTC full AV replacement implementation
|
||||
**Date:** 2026-05-26
|
||||
|
||||
---
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
You are the **Edge Case Hunter** — a pure path tracer. Never comment on whether code is good or bad; only list missing handling.
|
||||
|
||||
**YOU RECEIVE:**
|
||||
1. The diff below (primary scope)
|
||||
2. Read access to the project codebase at `/home/morr/work/foundryvtt/video-view-manager/`
|
||||
|
||||
**YOUR METHOD:** Exhaustive path enumeration — mechanically walk every branch, not hunt by intuition. Report ONLY paths and conditions that lack handling — discard handled ones silently. DO NOT editorialize or add filler — findings only.
|
||||
|
||||
**SCOPE RULES:**
|
||||
- Scan only the diff hunks
|
||||
- List boundaries that are directly reachable from the changed lines and lack an explicit guard in the diff
|
||||
- Ignore the rest of the codebase unless the diff explicitly references external functions
|
||||
|
||||
## EXECUTION
|
||||
|
||||
### Step 1: Receive Content
|
||||
The diff below is your scope.
|
||||
|
||||
### Step 2: Exhaustive Path Analysis
|
||||
Walk all branching paths and domain boundaries within the diff. For each path, determine whether it is handled. Collect only the UNHANDLED paths.
|
||||
|
||||
Edge classes to consider (derive from content, not fixed checklist):
|
||||
- missing else/default branches
|
||||
- null/empty/undefined inputs
|
||||
- off-by-one errors in loops
|
||||
- arithmetic overflow/underflow
|
||||
- implicit type coercion
|
||||
- race conditions
|
||||
- timeout gaps
|
||||
- error handling gaps
|
||||
- boundary conditions (min/max values)
|
||||
|
||||
### Step 3: Validate Completeness
|
||||
Revisit every edge class — add newly found unhandled paths, discard confirmed-handled ones.
|
||||
|
||||
### Step 4: Present Findings
|
||||
Output findings as a JSON array following the format below.
|
||||
|
||||
## OUTPUT FORMAT
|
||||
|
||||
Return ONLY a valid JSON array. No extra text, no explanations, no markdown wrapping.
|
||||
|
||||
```json
|
||||
[{
|
||||
"location": "file:start-end (or file:line when single line, or file:hunk when exact line unavailable)",
|
||||
"trigger_condition": "one-line description (max 15 words)",
|
||||
"guard_snippet": "minimal code sketch that closes the gap (single-line escaped string, no raw newlines or unescaped quotes)",
|
||||
"potential_consequence": "what could actually go wrong (max 15 words)"
|
||||
}]
|
||||
```
|
||||
|
||||
An empty array `[]` is valid when no unhandled paths are found.
|
||||
|
||||
---
|
||||
|
||||
## DIFF TO REVIEW
|
||||
|
||||
diff --git a/module.js b/module.js
|
||||
index 86d48ac..c5fe82f 100644
|
||||
--- a/module.js
|
||||
+++ b/module.js
|
||||
@@ -57,15 +57,17 @@ let directorsBoardButtonAdded = false;
|
||||
Hooks.once("init", () => {
|
||||
console.log("[ScryingPool] init — module loading");
|
||||
|
||||
- // OQ-1 resolved (Story 1.2 spike): probe result is 'css-fallback' — see FoundryAdapter.js
|
||||
+ // WebRTC mode setting — determines how the module handles AV integration
|
||||
+ // Updated for FULL REPLACEMENT architecture (hiding Foundry's dock, showing our own)
|
||||
game.settings.register("scrying-pool", "webrtcMode", {
|
||||
scope: "world",
|
||||
config: false,
|
||||
type: String,
|
||||
- default: "css-fallback",
|
||||
+ default: "stream-access",
|
||||
choices: {
|
||||
- "track-disable": "Track Disable (bandwidth-saving)",
|
||||
- "css-fallback": "CSS Fallback (cosmetic hiding)",
|
||||
+ "stream-access": "Stream Access (full replacement - hide Foundry dock, show VVM dock with actual video)",
|
||||
+ "track-disable": "Track Disable (bandwidth-saving) - DEPRECATED",
|
||||
+ "css-fallback": "CSS Fallback (cosmetic hiding) - DEPRECATED",
|
||||
"unsupported": "Unsupported (AV not available)",
|
||||
},
|
||||
});
|
||||
@@ -175,14 +177,25 @@ Hooks.once("ready", () => {
|
||||
// Hydrate StateStore from persisted world setting (AC-6, AC-7)
|
||||
stateStore.init();
|
||||
|
||||
- // Probe WebRTC capability and set adapter.webrtc (AC-8)
|
||||
+ // Probe WebRTC capability and set adapter.webrtc
|
||||
+ // For FULL REPLACEMENT: we need stream access to create our own video tiles
|
||||
const outcome = FoundryAdapter.probeCapability(game.webrtc);
|
||||
- adapter.webrtc = outcome === 'track-disable'
|
||||
- ? FoundryAdapter.buildWebRTCSurface(game.webrtc)
|
||||
- : null;
|
||||
adapter.settings.set(FoundryAdapter.SETTING_WEBRTC_MODE, outcome).catch(err => {
|
||||
console.error('[ScryingPool] Failed to set webrtcMode setting:', err);
|
||||
});
|
||||
+
|
||||
+ // Build WebRTC surface for stream access (full replacement mode)
|
||||
+ if (outcome === 'stream-access') {
|
||||
+ adapter.webrtc = FoundryAdapter.buildWebRTCSurface(game.webrtc);
|
||||
+ console.log('[ScryingPool] WebRTC stream access available - full replacement mode enabled');
|
||||
+ } else if (outcome === 'unsupported') {
|
||||
+ adapter.webrtc = null;
|
||||
+ console.log('[ScryingPool] WebRTC not available - AV features disabled');
|
||||
+ } else {
|
||||
+ // Legacy: track-disable or css-fallback (deprecated)
|
||||
+ adapter.webrtc = FoundryAdapter.buildWebRTCSurface(game.webrtc);
|
||||
+ console.warn('[ScryingPool] WebRTC mode is deprecated:', outcome, '- consider using stream-access');
|
||||
+ }
|
||||
|
||||
// Wire core managers — construct both before setReady so handler can reference both
|
||||
visibilityManager = new VisibilityManager(stateStore, adapter);
|
||||
diff --git a/scripts/test-stream-access.mjs b/scripts/test-stream-access.mjs
|
||||
new file mode 100644
|
||||
index 0000000..8d91db7
|
||||
--- /dev/null
|
||||
+++ b/scripts/test-stream-access.mjs
|
||||
@@ -0,0 +1,159 @@
|
||||
+/**
|
||||
+ * Test script to verify WebRTC stream access implementation
|
||||
+ * This script tests the full AV replacement architecture
|
||||
+ *
|
||||
+ * Run with: node scripts/test-stream-access.mjs
|
||||
+ *
|
||||
+ * Tests:
|
||||
+ * 1. FoundryAdapter.probeCapability() returns 'stream-access' when getMediaStreamForUser is available
|
||||
+ * 2. FoundryAdapter.buildWebRTCSurface() creates proper WebRTC surface
|
||||
+ * 3. ScryingPoolStrip.getData() includes hasStreamAccess flag
|
||||
+ * 4. buildParticipantList() includes hasStreamAccess in participant objects
|
||||
+ * 5. CSS includes rules to hide Foundry's AV dock
|
||||
+ * 6. Template includes video container element
|
||||
+ */
|
||||
+
|
||||
+import { FoundryAdapter } from '../src/foundry/FoundryAdapter.js';
|
||||
+import { buildParticipantList } from '../src/ui/gm/ScryingPoolStrip.js';
|
||||
+
|
||||
+console.log('=== WebRTC Stream Access Implementation Tests ===\n');
|
||||
+
|
||||
+// Test 1: probeCapability returns 'stream-access' when getMediaStreamForUser is available
|
||||
+console.log('Test 1: probeCapability with stream access');
|
||||
+const mockWebRTCWithStream = {
|
||||
+ client: {
|
||||
+ getMediaStreamForUser: (userId) => new MediaStream(),
|
||||
+ getConnectedUsers: () => ['user1', 'user2'],
|
||||
+ },
|
||||
+};
|
||||
+const result1 = FoundryAdapter.probeCapability(mockWebRTCWithStream);
|
||||
+console.log(` probeCapability result: ${result1}`);
|
||||
+console.assert(result1 === 'stream-access', 'Should return stream-access');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 2: probeCapability returns 'unsupported' when getMediaStreamForUser is missing
|
||||
+console.log('Test 2: probeCapability without stream access');
|
||||
+const mockWebRTCWithoutStream = {
|
||||
+ client: {},
|
||||
+};
|
||||
+const result2 = FoundryAdapter.probeCapability(mockWebRTCWithoutStream);
|
||||
+console.log(` probeCapability result: ${result2}`);
|
||||
+console.assert(result2 === 'unsupported', 'Should return unsupported');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 3: buildWebRTCSurface creates proper surface with all methods
|
||||
+console.log('Test 3: buildWebRTCSurface creates complete WebRTC surface');
|
||||
+const surface = FoundryAdapter.buildWebRTCSurface(mockWebRTCWithStream);
|
||||
+console.log(' WebRTC surface methods:');
|
||||
+console.log(' - getMediaStreamForUser:', typeof surface.getMediaStreamForUser === 'function' ? '✓' : '✗');
|
||||
+console.log(' - getConnectedUsers:', typeof surface.getConnectedUsers === 'function' ? '✓' : '✗');
|
||||
+console.log(' - getLevelsStreamForUser:', typeof surface.getLevelsStreamForUser === 'function' ? '✓' : '✗');
|
||||
+console.log(' - isAudioEnabled:', typeof surface.isAudioEnabled === 'function' ? '✓' : '✗');
|
||||
+console.log(' - isVideoEnabled:', typeof surface.isVideoEnabled === 'function' ? '✓' : '✗');
|
||||
+console.log(' - toggleAudio:', typeof surface.toggleAudio === 'function' ? '✓' : '✗');
|
||||
+console.log(' - toggleVideo:', typeof surface.toggleVideo === 'function' ? '✓' : '✗');
|
||||
+console.log(' - toggleBroadcast:', typeof surface.toggleBroadcast === 'function' ? '✓' : '✗');
|
||||
+console.log(' - setUserVideo:', typeof surface.setUserVideo === 'function' ? '✓' : '✗');
|
||||
+console.log(' - disableTrack:', typeof surface.disableTrack === 'function' ? '✓' : '✗');
|
||||
+console.log(' - enableTrack:', typeof surface.enableTrack === 'function' ? '✓' : '✗');
|
||||
+
|
||||
+const allMethodsPresent =
|
||||
+ typeof surface.getMediaStreamForUser === 'function' &&
|
||||
+ typeof surface.getConnectedUsers === 'function' &&
|
||||
+ typeof surface.getLevelsStreamForUser === 'function' &&
|
||||
+ typeof surface.isAudioEnabled === 'function' &&
|
||||
+ typeof surface.isVideoEnabled === 'function' &&
|
||||
+ typeof surface.toggleAudio === 'function' &&
|
||||
+ typeof surface.toggleVideo === 'function' &&
|
||||
+ typeof surface.toggleBroadcast === 'function' &&
|
||||
+ typeof surface.setUserVideo === 'function' &&
|
||||
+ typeof surface.disableTrack === 'function' &&
|
||||
+ typeof surface.enableTrack === 'function';
|
||||
+
|
||||
+console.assert(allMethodsPresent, 'All WebRTC surface methods should be functions');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 4: buildParticipantList includes hasStreamAccess
|
||||
+console.log('Test 4: buildParticipantList includes hasStreamAccess flag');
|
||||
+const mockAdapter = {
|
||||
+ users: {
|
||||
+ get: (userId) => ({ name: userId, avatar: null }),
|
||||
+ },
|
||||
+};
|
||||
+const mockStateStore = {
|
||||
+ getState: (userId) => 'active',
|
||||
+};
|
||||
+const mockController = {
|
||||
+ hasPendingOp: (userId) => false,
|
||||
+};
|
||||
+
|
||||
+const participants = buildParticipantList(
|
||||
+ ['user1', 'user2'],
|
||||
+ mockStateStore,
|
||||
+ mockController,
|
||||
+ mockAdapter,
|
||||
+ true // hasStreamAccess
|
||||
+);
|
||||
+
|
||||
+console.log(` Participant count: ${participants.length}`);
|
||||
+console.log(` First participant hasStreamAccess: ${participants[0].hasStreamAccess}`);
|
||||
+console.assert(participants.length === 2, 'Should have 2 participants');
|
||||
+console.assert(participants[0].hasStreamAccess === true, 'Participant should have hasStreamAccess flag');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 5: CSS includes rules to hide Foundry's AV dock
|
||||
+console.log('Test 5: CSS includes AV dock hiding rules');
|
||||
+import fs from 'fs';
|
||||
+import path from 'path';
|
||||
+
|
||||
+const cssPath = path.join(process.cwd(), 'styles', 'scrying-pool.less');
|
||||
+const cssContent = fs.readFileSync(cssPath, 'utf8');
|
||||
+
|
||||
+const hasAvHiding = cssContent.includes('#av') && cssContent.includes('display: none');
|
||||
+const hasCameraViewHiding = cssContent.includes('.camera-view') && cssContent.includes('display: none');
|
||||
+
|
||||
+console.log(` Has #av hiding rule: ${hasAvHiding ? '✓' : '✗'}`);
|
||||
+console.log(` Has .camera-view hiding rule: ${hasCameraViewHiding ? '✓' : '✗'}`);
|
||||
+console.assert(hasAvHiding, 'CSS should include #av hiding rule');
|
||||
+console.assert(hasCameraViewHiding, 'CSS should include .camera-view hiding rule');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 6: Template includes video container
|
||||
+console.log('Test 6: Template includes video container element');
|
||||
+const templatePath = path.join(process.cwd(), 'templates', 'roster-strip.hbs');
|
||||
+const templateContent = fs.readFileSync(templatePath, 'utf8');
|
||||
+
|
||||
+const hasVideoContainer = templateContent.includes('sp-participant-video');
|
||||
+const hasStreamAccessCheck = templateContent.includes('hasStreamAccess');
|
||||
+
|
||||
+console.log(` Has video container class: ${hasVideoContainer ? '✓' : '✗'}`);
|
||||
+console.log(` Has stream access check: ${hasStreamAccessCheck ? '✓' : '✗'}`);
|
||||
+console.assert(hasVideoContainer, 'Template should include video container');
|
||||
+console.assert(hasStreamAccessCheck, 'Template should check for stream access');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Test 7: CSS includes video element styling
|
||||
+console.log('Test 7: CSS includes video element styling');
|
||||
+const rosterCssPath = path.join(process.cwd(), 'styles', 'components', '_roster-strip.less');
|
||||
+const rosterCssContent = fs.readFileSync(rosterCssPath, 'utf8');
|
||||
+
|
||||
+const hasVideoContainerClass = rosterCssContent.includes('.sp-participant-video');
|
||||
+const hasVideoElementClass = rosterCssContent.includes('.sp-participant-video__element');
|
||||
+
|
||||
+console.log(` Has .sp-participant-video class: ${hasVideoContainerClass ? '✓' : '✗'}`);
|
||||
+console.log(` Has .sp-participant-video__element class: ${hasVideoElementClass ? '✓' : '✗'}`);
|
||||
+console.assert(hasVideoContainerClass, 'CSS should include video container class');
|
||||
+console.assert(hasVideoElementClass, 'CSS should include video element class');
|
||||
+console.log(' ✓ PASSED\n');
|
||||
+
|
||||
+// Summary
|
||||
+console.log('=== All Tests Passed! ===');
|
||||
+console.log('\nImplementation Summary:');
|
||||
+console.log('✓ FoundryAdapter.probeCapability() correctly identifies stream-access mode');
|
||||
+console.log('✓ FoundryAdapter.buildWebRTCSurface() provides full WebRTC client API');
|
||||
+console.log('✓ ScryingPoolStrip.getData() includes hasStreamAccess flag');
|
||||
+console.log('✓ buildParticipantList() passes hasStreamAccess to participants');
|
||||
+console.log('✓ CSS hides Foundry AV dock (#av and .camera-view)');
|
||||
+console.log('✓ Template includes video container with conditional rendering');
|
||||
+console.log('✓ CSS includes styling for video containers and elements');
|
||||
+console.log('\nFull AV replacement architecture is implemented and ready!');
|
||||
diff --git a/src/foundry/FoundryAdapter.js b/src/foundry/FoundryAdapter.js
|
||||
index 035b75a..680a063 100644
|
||||
--- a/src/foundry/FoundryAdapter.js
|
||||
+++ b/src/foundry/FoundryAdapter.js
|
||||
@@ -308,51 +308,183 @@ export class FoundryAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
- * Probes the game.webrtc (AVMaster) instance for WebRTC track-disabling capability.
|
||||
+ * 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: tracks are technically reachable but track.enabled = false does NOT reduce
|
||||
- * inbound WebRTC bandwidth (RTP packets keep arriving). The `'track-disable'` outcome
|
||||
- * requires true bandwidth elimination, so the result is `'css-fallback'`.
|
||||
+ * - Otherwise: getMediaStreamForUser() is available for stream access → `'stream-access'`
|
||||
*
|
||||
- * The `'track-disable'` branch in buildWebRTCSurface is kept for forward compatibility
|
||||
- * in case a future FoundryVTT version or custom AVClient exposes real bandwidth control.
|
||||
+ * 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 {'track-disable'|'css-fallback'|'unsupported'}
|
||||
+ * @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';
|
||||
- // track.enabled = false on remote inbound tracks does NOT stop WebRTC bandwidth.
|
||||
- // The 'track-disable' branch is unreachable with the current FoundryVTT v14 API.
|
||||
- return 'css-fallback';
|
||||
+ // Stream access is available - we can get MediaStream objects for all users
|
||||
+ return 'stream-access';
|
||||
}
|
||||
|
||||
/**
|
||||
- * Builds the webrtc surface object for the `'track-disable'` capability path.
|
||||
+ * Builds the webrtc surface object for the `'stream-access'` capability path.
|
||||
+ * Provides full access to WebRTC streams for creating custom video tiles.
|
||||
*
|
||||
- * NOTE: As of FoundryVTT v14, {@link FoundryAdapter.probeCapability} never returns
|
||||
- * `'track-disable'` because `track.enabled = false` does not stop inbound RTP bandwidth.
|
||||
- * This method is kept for forward compatibility and as tested documentation of the
|
||||
- * interface contract that Story 1.3+ consumers expect.
|
||||
+ * 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: { getMediaStreamForUser(userId: string): (MediaStream|null|undefined) } }} gameWebrtc
|
||||
- * @returns {{ disableTrack(userId: string): void, enableTrack(userId: string): void }}
|
||||
+ * @param {{ client: object, settings: 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 {
|
||||
- disableTrack(userId) {
|
||||
+ /**
|
||||
+ * 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 {
|
||||
+ 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 {
|
||||
+ 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 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 {
|
||||
- const stream = gameWebrtc.client.getMediaStreamForUser(userId);
|
||||
+ const stream = client.getMediaStreamForUser?.(userId);
|
||||
const tracks = stream?.getVideoTracks() ?? [];
|
||||
for (const track of tracks) track.enabled = false;
|
||||
if (tracks.length === 0) {
|
||||
@@ -362,9 +494,11 @@ export class FoundryAdapter {
|
||||
console.error('[ScryingPool] disableTrack failed:', err);
|
||||
}
|
||||
},
|
||||
- enableTrack(userId) {
|
||||
+
|
||||
+ // Legacy: enable video track
|
||||
+ enableTrack: (userId) => {
|
||||
try {
|
||||
- const stream = gameWebrtc.client.getMediaStreamForUser(userId);
|
||||
+ const stream = client.getMediaStreamForUser?.(userId);
|
||||
const tracks = stream?.getVideoTracks() ?? [];
|
||||
for (const track of tracks) track.enabled = true;
|
||||
if (tracks.length === 0) {
|
||||
diff --git a/src/ui/gm/ScryingPoolStrip.js b/src/ui/gm/ScryingPoolStrip.js
|
||||
index ad5f089..f48fd75 100644
|
||||
--- a/src/ui/gm/ScryingPoolStrip.js
|
||||
+++ b/src/ui/gm/ScryingPoolStrip.js
|
||||
@@ -28,9 +28,10 @@ export function resolveTargetState(currentState) {
|
||||
* @param {object} stateStore
|
||||
* @param {object} controller
|
||||
* @param {object} adapter
|
||||
+ * @param {boolean} hasStreamAccess - Whether stream access is available for video replacement
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
-export function buildParticipantList(userIds, stateStore, controller, adapter) {
|
||||
+export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false) {
|
||||
return userIds.map(userId => {
|
||||
const user = adapter.users.get(userId) ?? { name: userId, avatar: null };
|
||||
const state = stateStore.getState(userId) ?? 'active';
|
||||
@@ -42,6 +43,7 @@ export function buildParticipantList(userIds, stateStore, controller, adapter) {
|
||||
stateLabel: _stateLabel(state),
|
||||
hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false,
|
||||
isCurrentUser: adapter.users.current?.()?.id === userId,
|
||||
+ hasStreamAccess,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -152,11 +154,15 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
? this._adapter.users.all().map(u => u.id)
|
||||
: [];
|
||||
|
||||
+ // Check if we have stream access for video replacement (full AV replacement mode)
|
||||
+ const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined;
|
||||
+
|
||||
const participants = buildParticipantList(
|
||||
userIds,
|
||||
this._stateStore,
|
||||
this._controller,
|
||||
- this._adapter
|
||||
+ this._adapter,
|
||||
+ hasStreamAccess
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -164,6 +170,7 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
isExpanded: this._isExpanded,
|
||||
isEmpty: participants.length === 0,
|
||||
showFirstOpenTip,
|
||||
+ hasStreamAccess,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,6 +214,11 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
if (isFirstOpen) {
|
||||
game.user?.setFlag?.('video-view-manager', 'firstStripOpen', true);
|
||||
}
|
||||
+
|
||||
+ // Attach video streams if we have stream access (full AV replacement mode)
|
||||
+ if (this._adapter.webrtc?.getMediaStreamForUser !== undefined) {
|
||||
+ this._attachVideoStreams(el);
|
||||
+ }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
@@ -323,6 +335,80 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
const baseRevision = this._controller.getRevision?.(participantId) ?? 0;
|
||||
this._controller.action('strip', participantId, targetState, opId, baseRevision);
|
||||
}
|
||||
+
|
||||
+ /**
|
||||
+ * Attaches video stream elements for all participants in the strip.
|
||||
+ * Called from activateListeners() when stream access is available.
|
||||
+ * @param {HTMLElement} container - The container element (html from activateListeners)
|
||||
+ */
|
||||
+ _attachVideoStreams(container) {
|
||||
+ const participantItems = container.querySelectorAll('.sp-strip__participant-item');
|
||||
+ for (const item of participantItems) {
|
||||
+ const userId = item.querySelector('[data-user-id]')?.dataset.userId;
|
||||
+ if (userId) {
|
||||
+ this._attachVideoStream(userId, item);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * Attaches a video element with the WebRTC stream for a specific user.
|
||||
+ * Creates a video element with srcObject set to the user's MediaStream.
|
||||
+ * The video element is appended to the video container within the participant item.
|
||||
+ * @param {string} userId - The user ID to attach video for
|
||||
+ * @param {HTMLElement} participantItem - The participant list item element
|
||||
+ */
|
||||
+ _attachVideoStream(userId, participantItem) {
|
||||
+ const stream = this._adapter.webrtc.getMediaStreamForUser(userId);
|
||||
+
|
||||
+ // Check if video container exists
|
||||
+ const videoContainer = participantItem.querySelector('.sp-participant-video');
|
||||
+ if (!videoContainer) {
|
||||
+ console.warn('[ScryingPool] No video container found for user:', userId);
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Remove any existing video element
|
||||
+ const existingVideo = videoContainer.querySelector('video');
|
||||
+ if (existingVideo) {
|
||||
+ existingVideo.remove();
|
||||
+ }
|
||||
+
|
||||
+ // If no stream available, don't create video element (will show avatar fallback)
|
||||
+ if (!stream) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Create new video element
|
||||
+ const videoElement = document.createElement('video');
|
||||
+ videoElement.srcObject = stream;
|
||||
+ videoElement.autoplay = true;
|
||||
+ videoElement.playsInline = true;
|
||||
+ videoElement.muted = userId === this._adapter.users.current?.()?.id;
|
||||
+
|
||||
+ // Add CSS class for styling
|
||||
+ videoElement.className = 'sp-participant-video__element';
|
||||
+
|
||||
+ // Set up error handling
|
||||
+ videoElement.addEventListener('error', () => {
|
||||
+ console.warn('[ScryingPool] Video element error for user:', userId);
|
||||
+ });
|
||||
+
|
||||
+ videoContainer.appendChild(videoElement);
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * Refreshes all video streams for participants.
|
||||
+ * Called when stream state changes (user joins/leaves, mute/unmute).
|
||||
+ */
|
||||
+ _refreshVideoStreams() {
|
||||
+ if (!this._adapter.webrtc?.getMediaStreamForUser) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Re-render to ensure DOM is up to date
|
||||
+ this.render(false);
|
||||
+ }
|
||||
}
|
||||
|
||||
/**
|
||||
diff --git a/styles/components/_roster-strip.less b/styles/components/_roster-strip.less
|
||||
index d8e22b8..a857ae3 100644
|
||||
--- a/styles/components/_roster-strip.less
|
||||
+++ b/styles/components/_roster-strip.less
|
||||
@@ -93,6 +93,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
+// Video container for WebRTC stream (full AV replacement mode)
|
||||
+.sp-participant-video {
|
||||
+ position: absolute;
|
||||
+ inset: 0;
|
||||
+ width: 100%;
|
||||
+ height: 100%;
|
||||
+ display: flex;
|
||||
+ align-items: center;
|
||||
+ justify-content: center;
|
||||
+ overflow: hidden;
|
||||
+ z-index: 1;
|
||||
+}
|
||||
+
|
||||
.sp-avatar__img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -101,6 +114,24 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
+// Video element styling
|
||||
+.sp-participant-video__element {
|
||||
+ width: 100%;
|
||||
+ height: 100%;
|
||||
+ object-fit: cover;
|
||||
+ border-radius: 50%;
|
||||
+ background: hsl(220, 15%, 18%);
|
||||
+
|
||||
+ .is-expanded & {
|
||||
+ border-radius: 4px;
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+// Hide avatar image when video stream is active (has video element)
|
||||
+.sp-participant-video:not(:empty) ~ .sp-avatar__img {
|
||||
+ display: none;
|
||||
+}
|
||||
+
|
||||
.sp-avatar__corner-badge {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
@@ -229,7 +260,7 @@
|
||||
z-index: 10;
|
||||
|
||||
&::before {
|
||||
- content: '\f023'; // fa-lock
|
||||
+ content: '\f023'; //fa-lock
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
font-size: 1.2rem;
|
||||
diff --git a/styles/scrying-pool.less b/styles/scrying-pool.less
|
||||
index 0fba2b0..d8b0a55 100644
|
||||
--- a/styles/scrying-pool.less
|
||||
+++ b/styles/scrying-pool.less
|
||||
@@ -60,3 +60,21 @@
|
||||
--sp-badge-surface: var(--sp-badge-bg, rgba(0, 0, 0, 0.72));
|
||||
--sp-badge-color: var(--sp-badge-text, #dde2e8);
|
||||
}
|
||||
+
|
||||
+// ============================================================
|
||||
+// Full AV Replacement Mode: Hide Foundry's AV Dock
|
||||
+// ============================================================
|
||||
+// When module is active with stream-access mode, completely hide
|
||||
+// Foundry's native AV dock UI and replace it with our ScryingPoolStrip
|
||||
+// This implements the full replacement architecture (not overlay)
|
||||
+// ============================================================
|
||||
+
|
||||
+// Hide the entire AV dock container
|
||||
+#av {
|
||||
+ display: none !important;
|
||||
+}
|
||||
+
|
||||
+// Also hide individual camera views in case they're rendered elsewhere
|
||||
+.camera-view {
|
||||
+ display: none !important;
|
||||
+}
|
||||
diff --git a/templates/roster-strip.hbs b/templates/roster-strip.hbs
|
||||
index 4c28562..802f115 100644
|
||||
--- a/templates/roster-strip.hbs
|
||||
+++ b/templates/roster-strip.hbs
|
||||
@@ -44,7 +44,12 @@
|
||||
aria-label="{{name}} — {{stateLabel}}"
|
||||
aria-pressed="false">
|
||||
|
||||
- {{!-- Avatar image (32px rounded) --}}
|
||||
+ {{!-- Video container for stream-access mode (full AV replacement) --}}
|
||||
+ {{#if hasStreamAccess}}
|
||||
+ <div class="sp-participant-video" aria-hidden="true"></div>
|
||||
+ {{/if}}
|
||||
+
|
||||
+ {{!-- Avatar image (32px rounded) - shown as fallback when no video --}}
|
||||
<img class="sp-avatar__img" src="{{avatarSrc}}" alt="" aria-hidden="true" />
|
||||
|
||||
{{!-- Corner badge (12px bottom-right) --}}
|
||||
Reference in New Issue
Block a user