Various enhancements, restyling and new options
CI / ci (push) Successful in 47s

This commit is contained in:
2026-05-27 11:07:12 +02:00
parent 069107052d
commit 816b7951fb
51 changed files with 16687 additions and 670 deletions
+81 -1
View File
@@ -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.
*/
+294 -9
View File
@@ -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;
}
}
}
/**