This commit is contained in:
@@ -391,13 +391,31 @@ export class DirectorsBoard extends _AppBase {
|
||||
{ value: '200', label: '200px' },
|
||||
];
|
||||
|
||||
// Tile shape selector
|
||||
const currentTileShape = this._adapter.settings?.get?.('tileShape') ?? 'circle';
|
||||
const TILE_SHAPES = [
|
||||
{ key: 'rounded', icon: 'fa-square', isActive: currentTileShape === 'rounded', label: 'Rounded' },
|
||||
{ key: 'circle', icon: 'fa-circle', isActive: currentTileShape === 'circle', label: 'Circle' },
|
||||
{ key: 'hexagon', icon: 'fa-hexagon', isActive: currentTileShape === 'hexagon', label: 'Hexagon' },
|
||||
{ key: 'octagon', icon: 'fa-shapes', isActive: currentTileShape === 'octagon', label: 'Octagon' },
|
||||
];
|
||||
|
||||
// Tile border around video shapes
|
||||
const currentTileBorderWidth = this._adapter.settings?.get?.('tileBorderWidth') ?? 0;
|
||||
const currentTileBorderColor = this._adapter.settings?.get?.('tileBorderColor') ?? '#ffffff';
|
||||
const TILE_BORDER_WIDTHS = [
|
||||
{ value: 0, label: 'None', isActive: currentTileBorderWidth === 0 },
|
||||
{ value: 1, label: '1px', isActive: currentTileBorderWidth === 1 },
|
||||
{ value: 2, label: '2px', isActive: currentTileBorderWidth === 2 },
|
||||
{ value: 4, label: '4px', isActive: currentTileBorderWidth === 4 },
|
||||
];
|
||||
|
||||
return {
|
||||
...base,
|
||||
hasUndo: this._undoSnapshot !== null,
|
||||
hasRestore: this._spotlightSnapshot !== null,
|
||||
presetCount,
|
||||
hasPresets: presetCount > 0,
|
||||
// Story 3.2: Auto-apply configuration
|
||||
hasScene: !!this._adapter.scenes?.current?.(),
|
||||
autoApplyEnabled: autoApplyConfig.enabled,
|
||||
autoApplyPresetName: autoApplyConfig.presetName,
|
||||
@@ -407,6 +425,13 @@ export class DirectorsBoard extends _AppBase {
|
||||
avModeEnabled: (game.webrtc?.settings?.world?.mode ?? 0) !== 0,
|
||||
// Dock layout selector
|
||||
dockLayouts,
|
||||
// Tile shape selector
|
||||
tileShape: currentTileShape,
|
||||
tileShapes: TILE_SHAPES,
|
||||
// Tile border
|
||||
tileBorderWidth: currentTileBorderWidth,
|
||||
tileBorderColor: currentTileBorderColor,
|
||||
tileBorderWidths: TILE_BORDER_WIDTHS,
|
||||
// Story 5.2: Video widget width customization
|
||||
widthOptions: WIDTH_OPTIONS,
|
||||
widgetWidthSm,
|
||||
@@ -469,6 +494,8 @@ export class DirectorsBoard extends _AppBase {
|
||||
case 'toggle-av-mode': this._onToggleAVMode(); break;
|
||||
case 'open-av-config': this._onOpenAVConfig(); break;
|
||||
case 'set-dock-layout': this._onSetDockLayout(btn.dataset.layout); break;
|
||||
case 'set-tile-shape': this._onSetTileShape(btn.dataset.shape); break;
|
||||
case 'set-tile-border-width': this._onSetTileBorderWidth(parseInt(btn.dataset.width, 10)); break;
|
||||
case 'close': this.close(); break;
|
||||
}
|
||||
};
|
||||
@@ -501,6 +528,15 @@ export class DirectorsBoard extends _AppBase {
|
||||
mdSelect.addEventListener('change', () => this._onSetWidgetWidth(mdSelect.value, 'md'));
|
||||
}
|
||||
|
||||
// Tile border color picker — Foundry overwrites inline template styles via setPosition,
|
||||
// but the color input's value attribute IS preserved. However, click fires when opening
|
||||
// the picker (before the user chooses), so listen for 'change' instead.
|
||||
const borderColorInput = root.querySelector('input[data-action="set-tile-border-color"]');
|
||||
if (borderColorInput) {
|
||||
borderColorInput.value = this._adapter.settings?.get?.('tileBorderColor') ?? '#ffffff';
|
||||
borderColorInput.addEventListener('change', () => this._onSetTileBorderColor(borderColorInput.value));
|
||||
}
|
||||
|
||||
// Drag grip — custom drag (ApplicationV2 header is hidden)
|
||||
const grip = root.querySelector('[data-action="drag-grip"]');
|
||||
if (grip) {
|
||||
@@ -737,6 +773,8 @@ export class DirectorsBoard extends _AppBase {
|
||||
if (!layoutKey) return;
|
||||
try {
|
||||
await this._adapter.settings?.set?.('dockLayout', layoutKey);
|
||||
// Reset per-user size override so the world layout takes effect
|
||||
await this._adapter.settings?.set?.('dockLayoutExpanded', '');
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] Failed to set dockLayout:', err);
|
||||
}
|
||||
@@ -760,6 +798,48 @@ export class DirectorsBoard extends _AppBase {
|
||||
if (this.rendered) this.render({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tile shape for participant avatars.
|
||||
* @param {string} shape - 'rounded', 'circle', 'hexagon', or 'octagon'
|
||||
*/
|
||||
async _onSetTileShape(shape) {
|
||||
if (!shape) return;
|
||||
try {
|
||||
await this._adapter.settings?.set?.('tileShape', shape);
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] Failed to set tile shape:', err);
|
||||
}
|
||||
if (this.rendered) this.render({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the border width around video shapes.
|
||||
* @param {number} width - 0, 1, 2, or 4
|
||||
*/
|
||||
async _onSetTileBorderWidth(width) {
|
||||
if (width == null) return;
|
||||
try {
|
||||
await this._adapter.settings?.set?.('tileBorderWidth', width);
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] Failed to set tile border width:', err);
|
||||
}
|
||||
if (this.rendered) this.render({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the border color around video shapes.
|
||||
* @param {string} color - hex color string e.g. '#ff0000'
|
||||
*/
|
||||
async _onSetTileBorderColor(color) {
|
||||
if (!color) return;
|
||||
try {
|
||||
await this._adapter.settings?.set?.('tileBorderColor', color);
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] Failed to set tile border color:', err);
|
||||
}
|
||||
if (this.rendered) this.render({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the PresetSaveDialog for saving the current visibility matrix as a preset.
|
||||
*/
|
||||
|
||||
@@ -143,6 +143,13 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
/** @type {StripOverlayLayer|null} */
|
||||
this._stripOverlayLayer = null;
|
||||
|
||||
/** @type {Map<string, {retryCount: number, retryTimer: number|null}>} */
|
||||
this._videoStreamRetries = new Map();
|
||||
/** @type {number|null} */
|
||||
this._healthCheckInterval = null;
|
||||
/** @type {number|string|null} */
|
||||
this._userConnectedHookId = null;
|
||||
|
||||
// Load saved position from user flags
|
||||
this._loadPosition();
|
||||
}
|
||||
@@ -224,6 +231,13 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
|
||||
const isGM = this._adapter.users.isGM?.() ?? false;
|
||||
|
||||
// Tile shape for participant avatars
|
||||
const tileShape = this._adapter.settings?.get?.('tileShape') ?? 'circle';
|
||||
|
||||
// Tile border around video shapes
|
||||
const tileBorderWidth = this._adapter.settings?.get?.('tileBorderWidth') ?? 0;
|
||||
const tileBorderColor = this._adapter.settings?.get?.('tileBorderColor') ?? '#ffffff';
|
||||
|
||||
return {
|
||||
participants: visibleParticipants,
|
||||
isExpanded,
|
||||
@@ -235,6 +249,11 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
isGM,
|
||||
// Story 5.2: Video widget width customization
|
||||
widgetWidth: effectiveWidth,
|
||||
// Tile shape
|
||||
tileShape,
|
||||
// Tile border
|
||||
tileBorderWidth,
|
||||
tileBorderColor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -316,6 +335,22 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
this._attachVideoStreams(el);
|
||||
}
|
||||
|
||||
// Start stream monitoring on first render
|
||||
if (this._healthCheckInterval === null) {
|
||||
this._initStreamMonitoring();
|
||||
}
|
||||
|
||||
// Set CSS custom properties — Foundry's setPosition() overwrites inline template styles
|
||||
const setVar = (name, value) => {
|
||||
if (value !== undefined && value !== null) el.style.setProperty(name, value);
|
||||
};
|
||||
const widgetWidth = context?.widgetWidth ?? '150';
|
||||
setVar('--sp-widget-width', `${widgetWidth}px`);
|
||||
const bw = context?.tileBorderWidth ?? 0;
|
||||
setVar('--sp-tile-border-width', `${bw}px`);
|
||||
setVar('--sp-tile-border-color', context?.tileBorderColor ?? '#ffffff');
|
||||
setVar('--sp-tile-border-active', bw > 0 ? '1' : '0');
|
||||
|
||||
// Sync the outer Application window width with the selected dock layout.
|
||||
if (typeof this.setPosition === 'function') {
|
||||
const layout = context?.dockLayout ?? 'vertical-sm';
|
||||
@@ -355,8 +390,8 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
const mdWidth = this._getWidgetWidth('md');
|
||||
|
||||
switch (layout) {
|
||||
case 'vertical-sm': return smWidth + 2; // widget + 2px border
|
||||
case 'vertical-md': return 242; // 240px strip + 2px border (expanded view has fixed width)
|
||||
case 'vertical-sm': return rowWidth(smWidth, 1);
|
||||
case 'vertical-md': return rowWidth(mdWidth, 1);
|
||||
case 'horizontal-sm': return rowWidth(smWidth, Math.min(maxCols, n));
|
||||
case 'horizontal-md': return rowWidth(mdWidth, Math.min(maxCols, n));
|
||||
case 'mosaic-sm': return rowWidth(smWidth, Math.min(maxCols, Math.ceil(Math.sqrt(n))));
|
||||
@@ -410,6 +445,9 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
this._activePopover = null;
|
||||
}
|
||||
|
||||
// Tear down stream monitoring
|
||||
this._teardownStreamMonitoring();
|
||||
|
||||
// Clean up video elements and streams when closing
|
||||
this._cleanupVideoStreams();
|
||||
|
||||
@@ -424,18 +462,24 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all video elements and their associated streams.
|
||||
* Called when the strip is closed to prevent memory leaks.
|
||||
* Removes video elements from the DOM when the strip closes.
|
||||
* Does NOT stop the underlying MediaStream tracks — those belong to
|
||||
* FoundryVTT's WebRTC system (`game.webrtc.client`) and must stay alive
|
||||
* so they can be re-attached if the strip re-opens later.
|
||||
*/
|
||||
_cleanupVideoStreams() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const videoElements = document.querySelectorAll?.('.sp-participant-video__element') ?? [];
|
||||
videoElements.forEach(videoEl => {
|
||||
// Stop all tracks in the stream
|
||||
if (videoEl.srcObject instanceof MediaStream) {
|
||||
videoEl.srcObject.getTracks().forEach(track => track.stop());
|
||||
// Clear all retry timers
|
||||
for (const [, retryInfo] of this._videoStreamRetries) {
|
||||
if (retryInfo.retryTimer !== null) {
|
||||
clearTimeout(retryInfo.retryTimer);
|
||||
}
|
||||
}
|
||||
this._videoStreamRetries.clear();
|
||||
|
||||
const videoElements = this.element?.querySelectorAll?.('.sp-participant-video__element') ?? [];
|
||||
videoElements.forEach(videoEl => {
|
||||
videoEl.srcObject = null;
|
||||
videoEl.remove();
|
||||
});
|
||||
@@ -626,9 +670,13 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
// Set up error handling
|
||||
videoElement.addEventListener('error', () => {
|
||||
console.warn('[ScryingPool] Video element error for user:', userId);
|
||||
this._onStreamError(userId);
|
||||
});
|
||||
|
||||
videoContainer.appendChild(videoElement);
|
||||
|
||||
// Monitor this stream for loss/failure
|
||||
this._monitorStream(userId, videoElement, stream);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -649,6 +697,243 @@ export class ScryingPoolStrip extends _AppBase {
|
||||
this.render({ force: false });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stream monitoring and recovery ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialises periodic stream health checks and hooks for user connection changes.
|
||||
* Safe to call multiple times — guards against double-init via _healthCheckInterval.
|
||||
*/
|
||||
_initStreamMonitoring() {
|
||||
// Periodic health check every 15 seconds
|
||||
this._healthCheckInterval = setInterval(() => this._checkVideoStreamHealth(), 15000);
|
||||
|
||||
// Watch for user connection changes to refresh streams
|
||||
if (typeof Hooks !== 'undefined') {
|
||||
this._userConnectedHookId = Hooks.on('userConnected', (userId, connected) => {
|
||||
if (connected) {
|
||||
setTimeout(() => this._refreshVideoStreams(), 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down stream monitoring: clears interval, removes hooks, cancels retries.
|
||||
*/
|
||||
_teardownStreamMonitoring() {
|
||||
// Clear health check interval
|
||||
if (this._healthCheckInterval !== null) {
|
||||
clearInterval(this._healthCheckInterval);
|
||||
this._healthCheckInterval = null;
|
||||
}
|
||||
|
||||
// Remove userConnected hook
|
||||
if (this._userConnectedHookId !== null && typeof Hooks !== 'undefined') {
|
||||
Hooks.off('userConnected', this._userConnectedHookId);
|
||||
this._userConnectedHookId = null;
|
||||
}
|
||||
|
||||
// Cancel all pending retries
|
||||
for (const [, retryInfo] of this._videoStreamRetries) {
|
||||
if (retryInfo.retryTimer !== null) {
|
||||
clearTimeout(retryInfo.retryTimer);
|
||||
}
|
||||
}
|
||||
this._videoStreamRetries.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors a video stream for loss or failure events.
|
||||
* Sets up listeners on the track lifecycle and starts health tracking.
|
||||
* @param {string} userId
|
||||
* @param {HTMLVideoElement} videoEl
|
||||
* @param {MediaStream} stream
|
||||
*/
|
||||
_monitorStream(userId, videoEl, stream) {
|
||||
// Watch for video track ended (stream truly lost)
|
||||
const videoTracks = stream.getVideoTracks();
|
||||
for (const track of videoTracks) {
|
||||
track.addEventListener('ended', () => this._onVideoTrackEnded(userId), { once: true });
|
||||
}
|
||||
|
||||
// Watch for track removal (mute event may indicate transient issue)
|
||||
for (const track of videoTracks) {
|
||||
track.addEventListener('mute', () => {
|
||||
// Brief delay to see if unmute fires naturally
|
||||
setTimeout(() => {
|
||||
if (track.readyState === 'ended') {
|
||||
this._onVideoTrackEnded(userId);
|
||||
}
|
||||
}, 2000);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// Watch for playback stall
|
||||
videoEl.addEventListener('stalled', () => {
|
||||
setTimeout(() => {
|
||||
if (videoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
this._scheduleStreamRetry(userId);
|
||||
}
|
||||
}, 3000);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a video track is ended (stream disconnected).
|
||||
* Shows a warning and schedules a retry.
|
||||
* @param {string} userId
|
||||
*/
|
||||
_onVideoTrackEnded(userId) {
|
||||
this._notify('warn', 'scrying-pool.stream.lost', userId);
|
||||
this._scheduleStreamRetry(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a video element fires an error event.
|
||||
* @param {string} userId
|
||||
*/
|
||||
_onStreamError(userId) {
|
||||
this._scheduleStreamRetry(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a retry for a user's video stream with exponential backoff.
|
||||
* @param {string} userId
|
||||
* @param {number} [delay=2000] - Initial delay in ms
|
||||
*/
|
||||
_scheduleStreamRetry(userId, delay = 2000) {
|
||||
// Cancel any existing retry for this user
|
||||
this._clearStreamRetry(userId);
|
||||
|
||||
const retryInfo = this._videoStreamRetries.get(userId) ?? { retryCount: 0, retryTimer: null };
|
||||
|
||||
if (retryInfo.retryCount >= 3) {
|
||||
this._notify('error', 'scrying-pool.stream.failed', userId);
|
||||
return;
|
||||
}
|
||||
|
||||
this._notify('info', 'scrying-pool.stream.recovering', userId);
|
||||
|
||||
retryInfo.retryTimer = setTimeout(() => {
|
||||
this._retryStream(userId);
|
||||
}, delay);
|
||||
|
||||
retryInfo.retryCount++;
|
||||
this._videoStreamRetries.set(userId, retryInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to re-acquire and re-attach a user's video stream.
|
||||
* On success, clears retry state and notifies. On failure, schedules next backoff.
|
||||
* @param {string} userId
|
||||
*/
|
||||
_retryStream(userId) {
|
||||
const stream = this._adapter.webrtc?.getMediaStreamForUser?.(userId);
|
||||
const avatar = this.element?.querySelector(`[data-user-id="${userId}"]`);
|
||||
|
||||
if (stream instanceof MediaStream && avatar) {
|
||||
// Clean up the old video container state
|
||||
const videoContainer = avatar.querySelector('.sp-participant-video');
|
||||
if (videoContainer) {
|
||||
const oldVideo = videoContainer.querySelector('video');
|
||||
if (oldVideo) {
|
||||
oldVideo.srcObject = null;
|
||||
oldVideo.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-attach with the (possibly new) stream
|
||||
this._attachVideoStream(userId, avatar);
|
||||
this._clearStreamRetry(userId);
|
||||
this._notify('info', 'scrying-pool.stream.recovered', userId);
|
||||
} else {
|
||||
// Exponential backoff: 2s, 4s, 8s
|
||||
const retryInfo = this._videoStreamRetries.get(userId);
|
||||
const nextDelay = Math.pow(2, (retryInfo?.retryCount ?? 1)) * 1000;
|
||||
this._scheduleStreamRetry(userId, nextDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels any pending retry timer for a user and resets retry state.
|
||||
* @param {string} userId
|
||||
*/
|
||||
_clearStreamRetry(userId) {
|
||||
const retryInfo = this._videoStreamRetries.get(userId);
|
||||
if (retryInfo) {
|
||||
if (retryInfo.retryTimer !== null) {
|
||||
clearTimeout(retryInfo.retryTimer);
|
||||
}
|
||||
this._videoStreamRetries.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic health check — inspects all participant video elements and
|
||||
* triggers retries for any that are in a failed or stuck state.
|
||||
*/
|
||||
_checkVideoStreamHealth() {
|
||||
if (!this.element) return;
|
||||
|
||||
this.element.querySelectorAll('.sp-participant-avatar').forEach(avatar => {
|
||||
const userId = avatar.dataset.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const videoContainer = avatar.querySelector('.sp-participant-video');
|
||||
const videoEl = avatar.querySelector('.sp-participant-video__element');
|
||||
|
||||
// Expected video container but no video element → needs attach
|
||||
if (videoContainer && !videoEl) {
|
||||
const stream = this._adapter.webrtc?.getMediaStreamForUser?.(userId);
|
||||
if (stream instanceof MediaStream) {
|
||||
this._attachVideoStream(userId, avatar);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Video element exists but has no stream or ended tracks → trigger retry
|
||||
if (videoEl) {
|
||||
const stream = videoEl.srcObject;
|
||||
if (stream instanceof MediaStream) {
|
||||
const hasEndedTrack = stream.getVideoTracks().some(t => t.readyState === 'ended');
|
||||
if (hasEndedTrack && !this._videoStreamRetries.has(userId)) {
|
||||
this._onVideoTrackEnded(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification with i18n support and safe fallback for test environments.
|
||||
* @param {'info'|'warn'|'error'} level
|
||||
* @param {string} i18nKey
|
||||
* @param {string} userId
|
||||
*/
|
||||
_notify(level, i18nKey, userId) {
|
||||
const userName = this._getUserName(userId);
|
||||
let msg;
|
||||
try {
|
||||
msg = game.i18n?.format?.(i18nKey, { name: userName }) ?? `${i18nKey}: ${userName}`;
|
||||
} catch {
|
||||
msg = `${i18nKey}: ${userName}`;
|
||||
}
|
||||
this._adapter.notifications?.[level]?.(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a display name for a user ID.
|
||||
* @param {string} userId
|
||||
* @returns {string}
|
||||
*/
|
||||
_getUserName(userId) {
|
||||
try {
|
||||
return this._adapter.users.get?.(userId)?.name ?? userId;
|
||||
} catch {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user