Now manage to replace the Foundry native AV dock
CI / ci (push) Failing after 16s

This commit is contained in:
2026-05-24 09:39:53 +02:00
parent c4a375f4e3
commit 6d7a0b5fd7
94 changed files with 6027 additions and 20 deletions
@@ -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) --}}