Stabilize playback state and cursor feedback
This commit is contained in:
@@ -32,6 +32,7 @@ class AudioManagerModule extends BaseModule {
|
||||
this.ttsQueueEmpty = true;
|
||||
this.pendingMusicPlayback = null;
|
||||
this.currentMusicState = null;
|
||||
this.mediaPreloadTimeoutMs = 60000;
|
||||
this.assetRoots = {
|
||||
images: '/images/',
|
||||
music: '/music/',
|
||||
@@ -493,6 +494,10 @@ class AudioManagerModule extends BaseModule {
|
||||
.then(audio => {
|
||||
this.setMediaVolume(audio, this.getSfxVolume());
|
||||
return audio;
|
||||
})
|
||||
.catch(error => {
|
||||
this.sfxCache.delete(url);
|
||||
throw error;
|
||||
});
|
||||
this.sfxCache.set(url, promise);
|
||||
return promise;
|
||||
@@ -505,6 +510,10 @@ class AudioManagerModule extends BaseModule {
|
||||
.then(audio => {
|
||||
this.setMediaVolume(audio, this.getMusicVolume());
|
||||
return audio;
|
||||
})
|
||||
.catch(error => {
|
||||
this.musicCache.delete(url);
|
||||
throw error;
|
||||
});
|
||||
this.musicCache.set(url, promise);
|
||||
return promise;
|
||||
@@ -517,14 +526,24 @@ class AudioManagerModule extends BaseModule {
|
||||
const finish = (result, error = null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
audio.removeEventListener('canplaythrough', onReady);
|
||||
audio.removeEventListener('loadeddata', onReady);
|
||||
audio.removeEventListener('error', onError);
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
if (error) {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
const onReady = () => finish(audio);
|
||||
const onError = () => finish(null, new Error(`Failed to preload ${label}: ${url}`));
|
||||
const timeoutId = setTimeout(() => {
|
||||
finish(null, new Error(`Timed out preloading ${label}: ${url}`));
|
||||
}, this.mediaPreloadTimeoutMs);
|
||||
audio.preload = 'auto';
|
||||
audio.addEventListener('canplaythrough', onReady, { once: true });
|
||||
audio.addEventListener('loadeddata', onReady, { once: true });
|
||||
@@ -538,16 +557,36 @@ class AudioManagerModule extends BaseModule {
|
||||
if (this.imageCache.has(url)) return this.imageCache.get(url);
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
const finish = (result, error = null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
if (error) {
|
||||
image.src = '';
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
image.decoding = 'async';
|
||||
image.onload = () => {
|
||||
if (typeof image.decode === 'function') {
|
||||
image.decode().catch(() => null).then(() => resolve(image));
|
||||
image.decode().catch(() => null).then(() => finish(image));
|
||||
} else {
|
||||
resolve(image);
|
||||
finish(image);
|
||||
}
|
||||
};
|
||||
image.onerror = () => reject(new Error(`Failed to preload image: ${url}`));
|
||||
image.onerror = () => finish(null, new Error(`Failed to preload image: ${url}`));
|
||||
const timeoutId = setTimeout(() => {
|
||||
finish(null, new Error(`Timed out preloading image: ${url}`));
|
||||
}, this.mediaPreloadTimeoutMs);
|
||||
image.src = url;
|
||||
}).catch(error => {
|
||||
this.imageCache.delete(url);
|
||||
throw error;
|
||||
});
|
||||
this.imageCache.set(url, promise);
|
||||
return promise;
|
||||
@@ -571,7 +610,7 @@ class AudioManagerModule extends BaseModule {
|
||||
throw error;
|
||||
}));
|
||||
|
||||
await Promise.all(tasks);
|
||||
return Promise.all(tasks);
|
||||
}
|
||||
|
||||
handleMediaCue(cue) {
|
||||
|
||||
+174
-41
@@ -44,6 +44,7 @@ const ModuleLoader = (function() {
|
||||
let gameLoopModule = null; // Add variable to hold game loop instance
|
||||
let moduleTimings = {}; // Track timing data for modules
|
||||
let finalizationTimer = null;
|
||||
let moduleExitAnimations = new Map();
|
||||
|
||||
/**
|
||||
* Initialize the loader
|
||||
@@ -598,6 +599,7 @@ const ModuleLoader = (function() {
|
||||
// If no overlay exists in the HTML, create a minimal one
|
||||
loadingOverlay = document.createElement('div');
|
||||
loadingOverlay.className = 'loading-overlay';
|
||||
loadingOverlay.style.backgroundColor = '#000';
|
||||
loadingOverlay.style.transition = 'opacity 0.5s ease-out';
|
||||
document.body.appendChild(loadingOverlay);
|
||||
|
||||
@@ -639,6 +641,7 @@ const ModuleLoader = (function() {
|
||||
modulesList = loadingOverlay.querySelector('#modules-list');
|
||||
|
||||
// Ensure transition is set
|
||||
loadingOverlay.style.backgroundColor = '#000';
|
||||
loadingOverlay.style.transition = 'opacity 0.5s ease-out';
|
||||
}
|
||||
}
|
||||
@@ -728,16 +731,7 @@ const ModuleLoader = (function() {
|
||||
// if (areAllModulesComplete()) {
|
||||
// hideLoadingOverlay();
|
||||
// }
|
||||
const moduleItem = document.getElementById(`module-${moduleId}`);
|
||||
if (moduleItem) {
|
||||
// Ensure module-finished class is added with a small delay to avoid race conditions
|
||||
setTimeout(() => {
|
||||
moduleItem.classList.add('module-finished');
|
||||
moduleItem.addEventListener('animationend', () => {
|
||||
moduleItem.remove();
|
||||
}, { once: true });
|
||||
}, 120);
|
||||
}
|
||||
animateModuleItemExit(moduleId);
|
||||
} else if (state === ModuleState.ERROR) {
|
||||
moduleProgress[moduleId] = 100;
|
||||
}
|
||||
@@ -810,42 +804,40 @@ const ModuleLoader = (function() {
|
||||
/**
|
||||
* Finalize the loading process
|
||||
*/
|
||||
function finalizeLoading() {
|
||||
async function finalizeLoading() {
|
||||
console.log('Loading completed. Finalizing...');
|
||||
try {
|
||||
// Display timing data
|
||||
displayModuleTimings();
|
||||
|
||||
completeFinalization();
|
||||
await completeFinalization();
|
||||
} catch (error) {
|
||||
console.error('Error during finalization:', error);
|
||||
// Force hide the overlay even if there was an error
|
||||
hideOverlay();
|
||||
await hideOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the finalization process
|
||||
*/
|
||||
function completeFinalization() {
|
||||
async function completeFinalization() {
|
||||
isLoadingComplete = true;
|
||||
|
||||
// Call the start method on the game loop module directly
|
||||
// Ensure the game loop module was found during initialization
|
||||
if (gameLoopModule && typeof gameLoopModule.start === 'function') {
|
||||
// Hide the overlay first, then start the game loop
|
||||
hideOverlay(() => {
|
||||
console.log("Loader: Overlay hidden, starting Game Loop.");
|
||||
try {
|
||||
gameLoopModule.start();
|
||||
} catch (error) {
|
||||
console.error("Error starting Game Loop:", error);
|
||||
}
|
||||
});
|
||||
await hideOverlay();
|
||||
console.log("Loader: Overlay hidden, starting Game Loop.");
|
||||
try {
|
||||
gameLoopModule.start();
|
||||
} catch (error) {
|
||||
console.error("Error starting Game Loop:", error);
|
||||
}
|
||||
} else {
|
||||
console.error("Loader: Game Loop module not found or start method missing.");
|
||||
// Hide overlay anyway, but log error
|
||||
hideOverlay();
|
||||
await hideOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,37 +876,178 @@ const ModuleLoader = (function() {
|
||||
* Then completely remove it from the DOM
|
||||
* @param {Function} [callback] - Optional callback to execute after fade completes
|
||||
*/
|
||||
function hideOverlay(callback) { // Added callback parameter
|
||||
async function hideOverlay(callback) { // Added callback parameter
|
||||
if (!loadingOverlay) {
|
||||
if (callback) callback(); // Call callback immediately if no overlay
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForProgressIndicatorsToExit();
|
||||
|
||||
// Set opacity to 0 to trigger the fade-out transition
|
||||
loadingOverlay.style.opacity = '0';
|
||||
|
||||
// Use transition event listener to remove from DOM after fade completes
|
||||
loadingOverlay.addEventListener('transitionend', function handler(e) {
|
||||
// Only handle the opacity transition
|
||||
if (e.propertyName === 'opacity') {
|
||||
console.log('Module Loader: Removing overlay from DOM');
|
||||
await waitForTransition(loadingOverlay, 'opacity');
|
||||
|
||||
// Remove from DOM completely
|
||||
if (loadingOverlay.parentNode) {
|
||||
loadingOverlay.parentNode.removeChild(loadingOverlay);
|
||||
console.log('Module Loader: Removing overlay from DOM');
|
||||
|
||||
// Remove from DOM completely
|
||||
if (loadingOverlay.parentNode) {
|
||||
loadingOverlay.parentNode.removeChild(loadingOverlay);
|
||||
}
|
||||
|
||||
// Set to null to allow garbage collection
|
||||
loadingOverlay = null;
|
||||
|
||||
// Execute the callback if provided
|
||||
if (callback) callback();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate one module progress row out and resolve only after its own
|
||||
* fade/collapse animation has finished.
|
||||
* @param {string} moduleId - Module ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function animateModuleItemExit(moduleId) {
|
||||
if (moduleExitAnimations.has(moduleId)) {
|
||||
return moduleExitAnimations.get(moduleId);
|
||||
}
|
||||
|
||||
const moduleItem = document.getElementById(`module-${moduleId}`);
|
||||
if (!moduleItem) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const exitPromise = new Promise(resolve => {
|
||||
let settled = false;
|
||||
let timeoutId = null;
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
moduleItem.removeEventListener('animationend', handleAnimationEnd);
|
||||
if (moduleItem.parentNode) {
|
||||
moduleItem.parentNode.removeChild(moduleItem);
|
||||
}
|
||||
moduleExitAnimations.delete(moduleId);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const handleAnimationEnd = event => {
|
||||
if (event.target === moduleItem && event.animationName === 'fadeOutModule') {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
// Let the finished status paint briefly before the row collapses.
|
||||
setTimeout(() => {
|
||||
if (!moduleItem.isConnected) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
loadingOverlay.removeEventListener('transitionend', handler);
|
||||
moduleItem.addEventListener('animationend', handleAnimationEnd);
|
||||
moduleItem.classList.add('module-finished');
|
||||
|
||||
// Set to null to allow garbage collection
|
||||
loadingOverlay = null;
|
||||
|
||||
// Execute the callback if provided
|
||||
if (callback) callback();
|
||||
}
|
||||
const animationTime = getLongestCssTime(moduleItem, 'animation');
|
||||
timeoutId = setTimeout(finish, Math.max(animationTime + 80, 80));
|
||||
}, 120);
|
||||
});
|
||||
|
||||
moduleExitAnimations.set(moduleId, exitPromise);
|
||||
return exitPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make every remaining progress row leave, then wait for all of them.
|
||||
* This keeps the overlay fade from racing the final row animations.
|
||||
*/
|
||||
async function waitForProgressIndicatorsToExit() {
|
||||
if (modulesList) {
|
||||
modulesList.querySelectorAll('.module-item').forEach(moduleItem => {
|
||||
const moduleId = moduleItem.id.replace(/^module-/, '');
|
||||
animateModuleItemExit(moduleId);
|
||||
});
|
||||
}
|
||||
|
||||
if (moduleExitAnimations.size > 0) {
|
||||
await Promise.allSettled([...moduleExitAnimations.values()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a CSS transition on an element. The timeout is derived from
|
||||
* computed CSS duration/delay so non-animated cases resolve immediately.
|
||||
* @param {Element} element - Element that is transitioning
|
||||
* @param {string} propertyName - CSS property to wait for
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function waitForTransition(element, propertyName) {
|
||||
const transitionTime = getLongestCssTime(element, 'transition');
|
||||
|
||||
if (transitionTime <= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
element.removeEventListener('transitionend', handleTransitionEnd);
|
||||
resolve();
|
||||
};
|
||||
const handleTransitionEnd = event => {
|
||||
if (event.target === element && event.propertyName === propertyName) {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
const timeoutId = setTimeout(finish, transitionTime + 80);
|
||||
element.addEventListener('transitionend', handleTransitionEnd);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the longest duration+delay pair from computed transition/animation CSS.
|
||||
* @param {Element} element - Element to inspect
|
||||
* @param {'transition'|'animation'} kind - CSS timing group
|
||||
* @returns {number} milliseconds
|
||||
*/
|
||||
function getLongestCssTime(element, kind) {
|
||||
const style = window.getComputedStyle(element);
|
||||
const durations = parseCssTimeList(style[`${kind}Duration`]);
|
||||
const delays = parseCssTimeList(style[`${kind}Delay`]);
|
||||
const count = Math.max(durations.length, delays.length);
|
||||
let longest = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const duration = durations[i % durations.length] || 0;
|
||||
const delay = delays[i % delays.length] || 0;
|
||||
longest = Math.max(longest, duration + delay);
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comma separated CSS time list into milliseconds.
|
||||
* @param {string} value - CSS time list
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function parseCssTimeList(value) {
|
||||
return String(value || '0s').split(',').map(part => {
|
||||
const text = part.trim();
|
||||
const amount = Number.parseFloat(text);
|
||||
if (!Number.isFinite(amount)) return 0;
|
||||
return text.endsWith('ms') ? amount : amount * 1000;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,6 +66,13 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
|
||||
this.isPlaying = true;
|
||||
this.currentSentence = sentence;
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: {
|
||||
state: 'playing-ready',
|
||||
reason: 'playback-start',
|
||||
sentenceId: sentence?.id ?? null
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
// Start TTS first, then begin text animation when the audio element
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
const TTS_GENERATION_TIMEOUT_MS = 60000;
|
||||
const ASSET_PRELOAD_TIMEOUT_MS = 60000;
|
||||
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000;
|
||||
|
||||
class SentenceQueueModule extends BaseModule {
|
||||
constructor() {
|
||||
@@ -25,7 +27,9 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.lastContinueAt = 0;
|
||||
this.pauseBeforeNextReason = null;
|
||||
this.ttsGenerationTimeoutMs = TTS_GENERATION_TIMEOUT_MS;
|
||||
this.assetPreloadTimeoutMs = ASSET_PRELOAD_TIMEOUT_MS;
|
||||
this.generationRequests = new Map();
|
||||
this.assetPreloadRequests = new Map();
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
@@ -44,6 +48,8 @@ class SentenceQueueModule extends BaseModule {
|
||||
'runTtsPreloadWithTimeout',
|
||||
'cancelBlockingGeneration',
|
||||
'cancelGenerationRequests',
|
||||
'cancelBlockingAssetPreloads',
|
||||
'cancelAssetPreloads',
|
||||
'isSpeechItem',
|
||||
'getMediaPauseSeconds',
|
||||
'readFirstFiniteNumber',
|
||||
@@ -96,7 +102,12 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.addEventListener(document, 'ui:command', (event) => {
|
||||
if (event.detail?.type === 'continue') {
|
||||
this.lastContinueAt = performance.now();
|
||||
this.cancelBlockingGeneration('user-fast-forward');
|
||||
this.cancelBlockingGeneration('user-fast-forward', {
|
||||
minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS
|
||||
});
|
||||
this.cancelBlockingAssetPreloads('user-fast-forward', {
|
||||
minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
@@ -159,6 +170,16 @@ class SentenceQueueModule extends BaseModule {
|
||||
await this.waitForManualContinue(reason);
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: {
|
||||
state: 'waiting-generating',
|
||||
reason: 'preparing-next-block',
|
||||
sentenceId: item?.id || null,
|
||||
blockId: item?.blockId || null,
|
||||
kind: item?.kind || item?.type || 'paragraph'
|
||||
}
|
||||
}));
|
||||
|
||||
const sentence = await this.getPreparedSentence(item);
|
||||
|
||||
// Prefetch far enough ahead that media pauses do not block TTS
|
||||
@@ -189,6 +210,12 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
} catch (error) {
|
||||
console.error("SentenceQueue: Error processing sentence:", error);
|
||||
const failedItem = this.sentenceQueue.shift();
|
||||
console.warn('SentenceQueue: Dropped failed queue item so playback can continue', {
|
||||
sentenceId: failedItem?.id || item?.id || null,
|
||||
blockId: failedItem?.blockId || item?.blockId || null,
|
||||
error
|
||||
});
|
||||
if (item.callback) item.callback({ success: false, error });
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
@@ -334,8 +361,12 @@ class SentenceQueueModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
cancelBlockingGeneration(reason = 'cancelled') {
|
||||
this.cancelGenerationRequests(reason, request => request.blocking === true);
|
||||
cancelBlockingGeneration(reason = 'cancelled', options = {}) {
|
||||
const minWaitMs = Math.max(0, Number(options.minWaitMs || 0));
|
||||
this.cancelGenerationRequests(reason, request =>
|
||||
request.blocking === true &&
|
||||
(performance.now() - request.startedAt) >= minWaitMs
|
||||
);
|
||||
}
|
||||
|
||||
cancelGenerationRequests(reason = 'cancelled', predicate = () => true) {
|
||||
@@ -358,6 +389,30 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelBlockingAssetPreloads(reason = 'cancelled', options = {}) {
|
||||
const minWaitMs = Math.max(0, Number(options.minWaitMs || 0));
|
||||
this.cancelAssetPreloads(reason, request =>
|
||||
request.blocking === true &&
|
||||
(performance.now() - request.startedAt) >= minWaitMs
|
||||
);
|
||||
}
|
||||
|
||||
cancelAssetPreloads(reason = 'cancelled', predicate = () => true) {
|
||||
for (const [requestId, request] of this.assetPreloadRequests.entries()) {
|
||||
if (!predicate(request)) continue;
|
||||
console.warn('SentenceQueue: Cancelling asset preload request', {
|
||||
requestId,
|
||||
sentenceId: request.sentenceId,
|
||||
reason,
|
||||
elapsedMs: Math.round(performance.now() - request.startedAt),
|
||||
assetType: request.assetType
|
||||
});
|
||||
if (typeof request.finish === 'function') {
|
||||
request.finish({ success: false, reason: 'asset_preload_cancelled', cancelled: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate speech duration based on character count
|
||||
@@ -517,7 +572,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
const layoutText = metadata.layoutText || text;
|
||||
const dropCapText = metadata.dropCap ? this.getDropCapText(layoutText) : '';
|
||||
const dropCapWidth = metadata.dropCap
|
||||
? this.measureDropCapReservation(storyElement, dropCapText, lineHeight)
|
||||
? await this.measureDropCapReservation(storyElement, dropCapText, lineHeight)
|
||||
: 0;
|
||||
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
|
||||
const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0
|
||||
@@ -598,7 +653,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
async preloadAssetsForItem(item = {}, context = {}) {
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (!audioManager) return;
|
||||
if (!audioManager) return { success: true, reason: 'audio_manager_unavailable' };
|
||||
|
||||
const tasks = [];
|
||||
const type = String(item.type || item.kind || '').toLowerCase();
|
||||
@@ -610,28 +665,82 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
|
||||
const pending = tasks.filter(Boolean);
|
||||
if (pending.length === 0) return;
|
||||
if (pending.length === 0) return { success: true, reason: 'no_assets' };
|
||||
|
||||
const state = context.blocking ? 'waiting-generating' : 'playing-generating';
|
||||
const sentenceId = context.sentenceId || item.id || null;
|
||||
const requestId = `${sentenceId || 'asset'}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
||||
const startedAt = performance.now();
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: {
|
||||
state,
|
||||
reason: 'asset-preload-start',
|
||||
sentenceId: context.sentenceId || item.id || null,
|
||||
sentenceId,
|
||||
assetType: type || 'cue'
|
||||
}
|
||||
}));
|
||||
|
||||
await Promise.all(pending);
|
||||
const result = await new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
this.assetPreloadRequests.delete(requestId);
|
||||
resolve(value);
|
||||
};
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.warn('SentenceQueue: Asset preload timed out; continuing without confirmed asset', {
|
||||
sentenceId,
|
||||
timeoutMs: this.assetPreloadTimeoutMs,
|
||||
assetType: type || 'cue'
|
||||
});
|
||||
finish({ success: false, reason: 'asset_preload_timeout', timedOut: true });
|
||||
}, this.assetPreloadTimeoutMs);
|
||||
|
||||
this.assetPreloadRequests.set(requestId, {
|
||||
blocking: context.blocking !== false,
|
||||
sentenceId,
|
||||
assetType: type || 'cue',
|
||||
startedAt,
|
||||
finish
|
||||
});
|
||||
|
||||
Promise.allSettled(pending)
|
||||
.then(results => {
|
||||
const failures = results.filter(entry => entry.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
console.warn('SentenceQueue: Some assets failed to preload; continuing without them', {
|
||||
sentenceId,
|
||||
assetType: type || 'cue',
|
||||
failures: failures.map(entry => entry.reason)
|
||||
});
|
||||
finish({ success: false, reason: 'asset_preload_failed', failures });
|
||||
return;
|
||||
}
|
||||
finish({ success: true, reason: 'asset_preload_complete' });
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('SentenceQueue: Asset preload failed unexpectedly; continuing', {
|
||||
sentenceId,
|
||||
assetType: type || 'cue',
|
||||
error
|
||||
});
|
||||
finish({ success: false, reason: 'asset_preload_error', error });
|
||||
});
|
||||
});
|
||||
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: {
|
||||
state: 'playing-ready',
|
||||
reason: 'asset-preload-complete',
|
||||
sentenceId: context.sentenceId || item.id || null,
|
||||
assetType: type || 'cue'
|
||||
reason: result.success ? 'asset-preload-complete' : result.reason,
|
||||
sentenceId,
|
||||
assetType: type || 'cue',
|
||||
degraded: !result.success
|
||||
}
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
shouldPauseAfterSentence(sentence) {
|
||||
@@ -688,7 +797,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
async getPreparedSentence(item) {
|
||||
const pending = this.prefetchingSpeech.get(this.getCacheKey(item));
|
||||
if (pending) {
|
||||
await pending.catch(() => null);
|
||||
pending.catch(() => null);
|
||||
}
|
||||
|
||||
return this.prepareSentence(item);
|
||||
@@ -882,7 +991,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
return String(text).replace(dropCap, '').trimStart();
|
||||
}
|
||||
|
||||
measureDropCapReservation(container, dropCapText, lineHeight) {
|
||||
async measureDropCapReservation(container, dropCapText, lineHeight) {
|
||||
if (!container || !dropCapText) {
|
||||
return lineHeight * 1.34;
|
||||
}
|
||||
@@ -905,8 +1014,25 @@ class SentenceQueueModule extends BaseModule {
|
||||
probeParagraph.appendChild(probe);
|
||||
container.appendChild(probeParagraph);
|
||||
|
||||
const rect = probe.getBoundingClientRect();
|
||||
const computed = window.getComputedStyle(probe);
|
||||
if (document.fonts && typeof document.fonts.load === 'function') {
|
||||
const fontDescriptor = [
|
||||
computed.fontStyle,
|
||||
computed.fontVariant,
|
||||
computed.fontWeight,
|
||||
computed.fontSize,
|
||||
computed.fontFamily
|
||||
].filter(Boolean).join(' ');
|
||||
try {
|
||||
await document.fonts.load(fontDescriptor, dropCapText);
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
} catch (error) {
|
||||
console.warn('SentenceQueue: Drop-cap font load check failed; measuring current font state', error);
|
||||
}
|
||||
}
|
||||
|
||||
const rect = probe.getBoundingClientRect();
|
||||
let inkRight = 0;
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
const GAME_API_TIMEOUT_MS = 60000;
|
||||
const PLAYER_COMMAND_TIMEOUT_MS = 60000;
|
||||
|
||||
class SocketClientModule extends BaseModule {
|
||||
constructor() {
|
||||
super('socket-client', 'Socket Client');
|
||||
@@ -23,6 +26,10 @@ class SocketClientModule extends BaseModule {
|
||||
this.defaultHost = 'localhost:3000';
|
||||
this.receivedBlockCounter = 0;
|
||||
this.receivedParagraphCounter = 0;
|
||||
this.pendingCommandTimer = null;
|
||||
this.pendingCommand = null;
|
||||
this.gameApiTimeoutMs = GAME_API_TIMEOUT_MS;
|
||||
this.playerCommandTimeoutMs = PLAYER_COMMAND_TIMEOUT_MS;
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
@@ -54,6 +61,9 @@ class SocketClientModule extends BaseModule {
|
||||
'cueMarkersFromTags',
|
||||
'dispatchChoices',
|
||||
'dispatchInputMode',
|
||||
'handleServerError',
|
||||
'clearPendingCommand',
|
||||
'translate',
|
||||
'isStructuralTag',
|
||||
'blocksFromTags',
|
||||
'enqueueStructuredBlock',
|
||||
@@ -191,8 +201,13 @@ class SocketClientModule extends BaseModule {
|
||||
|
||||
// Special handling for narrative text
|
||||
this.socket.on('narrativeResponse', (data) => {
|
||||
this.clearPendingCommand('narrative-response');
|
||||
this.processTurnResult(data);
|
||||
});
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
this.handleServerError(error);
|
||||
});
|
||||
|
||||
this.socket.on('gameConfig', (data) => {
|
||||
document.dispatchEvent(new CustomEvent('game:config', {
|
||||
@@ -300,6 +315,45 @@ class SocketClientModule extends BaseModule {
|
||||
}));
|
||||
}
|
||||
|
||||
handleServerError(error) {
|
||||
const message = String(error?.message || error?.error || error || 'The game server reported an error.');
|
||||
console.error('Socket Client: Server error event:', error);
|
||||
this.clearPendingCommand('server-error');
|
||||
document.dispatchEvent(new CustomEvent('story:tag', {
|
||||
detail: {
|
||||
key: 'error',
|
||||
value: message,
|
||||
source: 'server'
|
||||
}
|
||||
}));
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'ready', reason: 'server-error', message }
|
||||
}));
|
||||
}
|
||||
|
||||
clearPendingCommand(reason = 'cleared') {
|
||||
if (this.pendingCommandTimer) {
|
||||
clearTimeout(this.pendingCommandTimer);
|
||||
this.pendingCommandTimer = null;
|
||||
}
|
||||
if (this.pendingCommand) {
|
||||
console.log('Socket Client: Command wait cleared', {
|
||||
reason,
|
||||
command: this.pendingCommand
|
||||
});
|
||||
}
|
||||
this.pendingCommand = null;
|
||||
}
|
||||
|
||||
translate(key, fallback, params = {}) {
|
||||
const localization = this.getModule('localization');
|
||||
if (localization && typeof localization.translate === 'function') {
|
||||
const translated = localization.translate(key, params);
|
||||
if (translated && translated !== key) return translated;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
processParagraphResult(paragraph, turnId, pendingParagraph = null) {
|
||||
const pending = pendingParagraph && typeof pendingParagraph === 'object'
|
||||
? pendingParagraph
|
||||
@@ -652,7 +706,30 @@ class SocketClientModule extends BaseModule {
|
||||
}
|
||||
|
||||
try {
|
||||
this.clearPendingCommand('new-command');
|
||||
this.socket.emit('playerCommand', { command });
|
||||
this.pendingCommand = command;
|
||||
this.pendingCommandTimer = setTimeout(() => {
|
||||
const timedOutCommand = this.pendingCommand;
|
||||
this.clearPendingCommand('timeout');
|
||||
console.warn('Socket Client: Player command timed out', {
|
||||
timeoutMs: this.playerCommandTimeoutMs,
|
||||
command: timedOutCommand
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('story:tag', {
|
||||
detail: {
|
||||
key: 'alert',
|
||||
value: this.translate(
|
||||
'popup.commandTimeout',
|
||||
'The game server did not answer in time. You can try again.'
|
||||
),
|
||||
source: 'client-timeout'
|
||||
}
|
||||
}));
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'ready', reason: 'command-timeout', command: timedOutCommand }
|
||||
}));
|
||||
}, this.playerCommandTimeoutMs);
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'command-waiting', reason: 'command-sent', command }
|
||||
}));
|
||||
@@ -677,8 +754,26 @@ class SocketClientModule extends BaseModule {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit('gameApi', { method, args }, (response) => {
|
||||
let settled = false;
|
||||
const finish = (response) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve(response || { success: false, error: 'empty_response' });
|
||||
};
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.warn('Socket Client: gameApi call timed out', {
|
||||
method,
|
||||
timeoutMs: this.gameApiTimeoutMs
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'ready', reason: 'game-api-timeout', method }
|
||||
}));
|
||||
finish({ success: false, error: 'timeout', method });
|
||||
}, this.gameApiTimeoutMs);
|
||||
|
||||
this.socket.emit('gameApi', { method, args }, (response) => {
|
||||
finish(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1224,9 +1224,11 @@ class TTSFactoryModule extends BaseModule {
|
||||
return null;
|
||||
}
|
||||
|
||||
let hash = null;
|
||||
let generationStarted = false;
|
||||
try {
|
||||
// Generate a hash for this speech request
|
||||
const hash = await this.generateSpeechHash(text);
|
||||
hash = await this.generateSpeechHash(text);
|
||||
|
||||
// Check if we have this audio in cache
|
||||
const cachedData = await this.getCachedSpeech(hash);
|
||||
@@ -1242,6 +1244,7 @@ class TTSFactoryModule extends BaseModule {
|
||||
|
||||
// Cache miss - need to generate new speech data
|
||||
this.cacheMisses++;
|
||||
generationStarted = true;
|
||||
this.emitProcessState('waiting-generating', { reason: 'tts-cache-miss', hash });
|
||||
|
||||
// If the handler has a preloadSpeech method, use it
|
||||
@@ -1253,15 +1256,21 @@ class TTSFactoryModule extends BaseModule {
|
||||
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
|
||||
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`);
|
||||
this.emitProcessState('playing-ready', { reason: 'tts-generated', hash });
|
||||
} else if (generationStarted) {
|
||||
this.emitProcessState('playing-ready', { reason: 'tts-generation-unavailable', hash });
|
||||
}
|
||||
|
||||
return preloadData;
|
||||
} else {
|
||||
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`);
|
||||
this.emitProcessState('playing-ready', { reason: 'tts-preload-unsupported', hash });
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("TTS Factory: Error preloading speech:", error);
|
||||
if (generationStarted || hash) {
|
||||
this.emitProcessState('playing-ready', { reason: 'tts-generation-error', hash, error });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ class UIControllerModule extends BaseModule {
|
||||
'hideUI',
|
||||
'clearDisplay',
|
||||
'sendCommand',
|
||||
'isInteractiveClickTarget',
|
||||
'updateButtonStates'
|
||||
]);
|
||||
}
|
||||
@@ -263,7 +264,7 @@ class UIControllerModule extends BaseModule {
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'click', (event) => {
|
||||
if (event.target && event.target.closest && event.target.closest('#options-modal, #controls, #player_input, #command_input, #story_scrollbar')) {
|
||||
if (this.isInteractiveClickTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -635,6 +636,38 @@ class UIControllerModule extends BaseModule {
|
||||
|
||||
console.log('UIController: SentenceQueue pipeline configured');
|
||||
}
|
||||
|
||||
isInteractiveClickTarget(target) {
|
||||
if (!target || typeof target.closest !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(target.closest([
|
||||
'a',
|
||||
'button',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'label',
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[data-control]',
|
||||
'#controls',
|
||||
'#player_input',
|
||||
'#command_input',
|
||||
'#story_scrollbar',
|
||||
'#story_choices',
|
||||
'#options-modal',
|
||||
'.modal',
|
||||
'.modal-content',
|
||||
'.credits-modal',
|
||||
'.credits-dialog',
|
||||
'.story-popup-modal',
|
||||
'.story-popup-dialog',
|
||||
'.choice-button',
|
||||
'.volume-toggle'
|
||||
].join(',')));
|
||||
}
|
||||
|
||||
handleCommand(command) {
|
||||
// Route commands to appropriate handlers
|
||||
|
||||
@@ -1485,7 +1485,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
const dropCapText = typeof sentenceQueue.getDropCapText === 'function'
|
||||
? sentenceQueue.getDropCapText(metadata.layoutText || item.text || '')
|
||||
: String(metadata.layoutText || item.text || '').trim().charAt(0);
|
||||
metadata.dropCapWidth = sentenceQueue.measureDropCapReservation(
|
||||
metadata.dropCapWidth = await sentenceQueue.measureDropCapReservation(
|
||||
this.container || this.paragraphContainer || document.getElementById('story'),
|
||||
dropCapText,
|
||||
this.measureStoryLineHeight()
|
||||
|
||||
@@ -43,6 +43,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
'installMouseCursors',
|
||||
'startMouseCursorAnimation',
|
||||
'stopMouseCursorAnimation',
|
||||
'normalizeProcessState',
|
||||
'clearHistory'
|
||||
]);
|
||||
|
||||
@@ -264,12 +265,14 @@ class UIInputHandlerModule extends BaseModule {
|
||||
setProcessState(state, detail = {}) {
|
||||
const knownStates = [
|
||||
'ready',
|
||||
'paused',
|
||||
'command-waiting',
|
||||
'waiting-generating',
|
||||
'playing-generating',
|
||||
'playing-ready'
|
||||
];
|
||||
const nextState = knownStates.includes(state) ? state : 'ready';
|
||||
const requestedState = knownStates.includes(state) ? state : 'ready';
|
||||
const nextState = this.normalizeProcessState(requestedState);
|
||||
|
||||
this.applyMouseCursor(nextState);
|
||||
|
||||
@@ -302,6 +305,21 @@ class UIInputHandlerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
normalizeProcessState(state) {
|
||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||
const isPlaying = Boolean(playbackCoordinator?.isPlaying);
|
||||
|
||||
if (isPlaying && state === 'ready') {
|
||||
return 'playing-ready';
|
||||
}
|
||||
|
||||
if (isPlaying && state === 'waiting-generating') {
|
||||
return 'playing-generating';
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
applyTextInputAttributes(playerInput) {
|
||||
if (!playerInput) return;
|
||||
|
||||
@@ -358,13 +376,15 @@ class UIInputHandlerModule extends BaseModule {
|
||||
}
|
||||
|
||||
getMouseCursor(state) {
|
||||
if (state === 'ready') {
|
||||
if (state === 'ready' || state === 'paused') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const fallback = state === 'command-waiting' || state === 'waiting-generating' ? 'progress' : 'default';
|
||||
const usesArrowBase = state === 'command-waiting' || state === 'waiting-generating';
|
||||
return this.buildMouseCursor(state, fallback, usesArrowBase ? 4 : 5, usesArrowBase ? 3 : 24, this.cursorAnimationFrame);
|
||||
if (state === 'command-waiting' || state === 'waiting-generating') {
|
||||
return this.buildMouseCursor(state, 'progress', 16, 16, this.cursorAnimationFrame);
|
||||
}
|
||||
|
||||
return this.buildMouseCursor(state, 'default', 5, 24, this.cursorAnimationFrame);
|
||||
}
|
||||
|
||||
buildMouseCursor(state, fallback = 'default', hotspotX = 12, hotspotY = 12, frame = 0) {
|
||||
@@ -401,27 +421,30 @@ class UIInputHandlerModule extends BaseModule {
|
||||
getMouseCursorSvg(state, frame = 0) {
|
||||
const stroke = '#2a1b10';
|
||||
const common = `xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none" stroke="${stroke}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"`;
|
||||
const spinnerSpokes = Array.from({ length: 8 }, (_, index) => {
|
||||
const makeSpinner = (centerX, centerY, innerRadius, outerRadius, strokeWidth = 1.8) => Array.from({ length: 8 }, (_, index) => {
|
||||
const opacity = 0.25 + (((index + frame) % 8) / 7) * 0.75;
|
||||
const angle = (index * 45) * Math.PI / 180;
|
||||
const x1 = 24 + Math.cos(angle) * 3;
|
||||
const y1 = 24 + Math.sin(angle) * 3;
|
||||
const x2 = 24 + Math.cos(angle) * 5;
|
||||
const y2 = 24 + Math.sin(angle) * 5;
|
||||
return `<path opacity="${opacity.toFixed(2)}" d="M${x1.toFixed(2)} ${y1.toFixed(2)} ${x2.toFixed(2)} ${y2.toFixed(2)}"/>`;
|
||||
const x1 = centerX + Math.cos(angle) * innerRadius;
|
||||
const y1 = centerY + Math.sin(angle) * innerRadius;
|
||||
const x2 = centerX + Math.cos(angle) * outerRadius;
|
||||
const y2 = centerY + Math.sin(angle) * outerRadius;
|
||||
return `<path opacity="${opacity.toFixed(2)}" stroke-width="${strokeWidth}" d="M${x1.toFixed(2)} ${y1.toFixed(2)} ${x2.toFixed(2)} ${y2.toFixed(2)}"/>`;
|
||||
}).join('');
|
||||
const spinnerSpokes = makeSpinner(24, 24, 3, 5);
|
||||
const largeSpinnerSpokes = makeSpinner(16, 16, 5.2, 10.2, 2.2);
|
||||
const arrow = '<path fill="#f6efe2" d="M4 3l9 21 2.4-8.5L24 13z"/><path d="M15.4 15.5 21 21"/>';
|
||||
const pointer = '<path fill="#f6efe2" d="M9 14V5a2.2 2.2 0 0 1 4.4 0v7.5"/><path d="M13.4 12V8.5a2.1 2.1 0 0 1 4.2 0v5"/><path d="M17.6 14v-1.7a2.1 2.1 0 0 1 4.2 0v4.2A8.1 8.1 0 0 1 13.7 24h-1.2a6.8 6.8 0 0 1-5.5-3L3.6 15a2.1 2.1 0 0 1 3.4-2.4l2 2.5"/>';
|
||||
const pointer = '<path fill="#f6efe2" d="M9 14.8V5a2.2 2.2 0 0 1 4.4 0v6.8V8.5a2.1 2.1 0 0 1 4.2 0V13v-.7a2.1 2.1 0 0 1 4.2 0v4.2A8.1 8.1 0 0 1 13.7 24h-1.2a6.8 6.8 0 0 1-5.5-3L3.6 15a2.1 2.1 0 0 1 3.4-2.4L9 14.8z"/><path d="M9 14.8V5a2.2 2.2 0 0 1 4.4 0v7.5"/><path d="M13.4 12V8.5a2.1 2.1 0 0 1 4.2 0v5"/><path d="M17.6 14v-1.7a2.1 2.1 0 0 1 4.2 0v4.2A8.1 8.1 0 0 1 13.7 24h-1.2a6.8 6.8 0 0 1-5.5-3L3.6 15a2.1 2.1 0 0 1 3.4-2.4l2 2.5"/>';
|
||||
const feather = '<path fill="#f6efe2" d="M5 26c5.8-1.7 12.5-7.9 18.4-20.7 2.3 7.6-.2 16.1-11.8 19.9"/><path d="M5 26c4.8-4.5 8.7-9.2 13-15"/><path d="M12 25.2 5 26"/>';
|
||||
const speaker = '<g transform="translate(20 2) scale(.48)"><path fill="#f6efe2" d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></g>';
|
||||
const spinner = `<g>${spinnerSpokes}</g>`;
|
||||
const largeSpinner = `<g>${largeSpinnerSpokes}</g>`;
|
||||
const hourglassSand = frame % 4 < 2 ? '<path d="M5.7 4.7h4.6"/><path d="M6.7 7h2.6"/>' : '<path d="M6.7 12h2.6"/><path d="M5.7 14.3h4.6"/>';
|
||||
const hourglass = `<g transform="translate(1 17) scale(.82)"><path fill="#f6efe2" d="M4 2h8M4 16h8M10.8 2v3.1a2 2 0 0 1-.6 1.4L8 8.5l-2.2-2A2 2 0 0 1 5.2 5.1V2M5.2 16v-3.1a2 2 0 0 1 .6-1.4L8 9.5l2.2 2a2 2 0 0 1 .6 1.4V16"/>${hourglassSand}</g>`;
|
||||
const icons = {
|
||||
'default': `<svg ${common}>${arrow}</svg>`,
|
||||
'pointer': `<svg ${common}>${pointer}</svg>`,
|
||||
'command-waiting': `<svg ${common}>${arrow}${hourglass}</svg>`,
|
||||
'waiting-generating': `<svg ${common}>${arrow}${spinner}</svg>`,
|
||||
'command-waiting': `<svg ${common}>${largeSpinner}${hourglass}</svg>`,
|
||||
'waiting-generating': `<svg ${common}>${largeSpinner}</svg>`,
|
||||
'playing-generating': `<svg ${common}>${feather}${speaker}${spinner}</svg>`,
|
||||
'playing-ready': `<svg ${common}>${feather}${speaker}</svg>`
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user