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
+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);
}
}
/**