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