Story 4.2: Implement full AV replacement with WebRTC stream access

- Update FoundryAdapter to properly detect and expose WebRTC stream access
- Modify ScryingPoolStrip to create video elements with WebRTC streams
- Add video container to roster-strip.hbs template with conditional rendering
- Add CSS to hide Foundry's AV dock (#av and .camera-view)
- Add CSS styling for video containers and elements
- Fix unused variable in FoundryAdapter.buildWebRTCSurface
- Add comprehensive test script for stream access implementation

Architecture: Full replacement mode where module hides Foundry's AV dock
and creates its own video elements using game.webrtc.client.getMediaStreamForUser()

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-24 09:12:06 +02:00
parent 20d13fc678
commit c4a375f4e3
7 changed files with 479 additions and 33 deletions
+155 -21
View File
@@ -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 {
const stream = gameWebrtc.client.getMediaStreamForUser(userId);
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 = 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) {
+88 -2
View File
@@ -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);
}
}
/**