Checkpoint current interactive fiction state
This commit is contained in:
@@ -0,0 +1,931 @@
|
||||
/**
|
||||
* @license Hyphenopoly 5.2.0-beta.1 - client side hyphenation for webbrowsers
|
||||
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
|
||||
* https://github.com/mnater/Hyphenopoly
|
||||
*
|
||||
* Released under the MIT license
|
||||
* http://mnater.github.io/Hyphenopoly/LICENSE
|
||||
*/
|
||||
|
||||
/* globals Hyphenopoly:readonly */
|
||||
((w, o) => {
|
||||
"use strict";
|
||||
const SOFTHYPHEN = "\u00AD";
|
||||
|
||||
/**
|
||||
* Event
|
||||
*/
|
||||
const event = ((H) => {
|
||||
const knownEvents = new Map([
|
||||
["afterElementHyphenation", []],
|
||||
["beforeElementHyphenation", []],
|
||||
["engineReady", []],
|
||||
[
|
||||
"error", [
|
||||
(e) => {
|
||||
if (e.runDefault) {
|
||||
w.console.warn(e);
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
["hyphenopolyEnd", []],
|
||||
["hyphenopolyStart", []]
|
||||
]);
|
||||
if (H.hev) {
|
||||
const userEvents = new Map(o.entries(H.hev));
|
||||
knownEvents.forEach((eventFuncs, eventName) => {
|
||||
if (userEvents.has(eventName)) {
|
||||
eventFuncs.unshift(userEvents.get(eventName));
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
"fire": ((eventName, eventData) => {
|
||||
eventData.runDefault = true;
|
||||
eventData.preventDefault = () => {
|
||||
eventData.runDefault = false;
|
||||
};
|
||||
knownEvents.get(eventName).forEach((eventFn) => {
|
||||
eventFn(eventData);
|
||||
});
|
||||
})
|
||||
};
|
||||
})(Hyphenopoly);
|
||||
|
||||
/**
|
||||
* Register copy event on element
|
||||
* @param {Object} el The element
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function registerOnCopy(el) {
|
||||
el.addEventListener(
|
||||
"copy",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const sel = w.getSelection();
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(sel.getRangeAt(0).cloneContents());
|
||||
e.clipboardData.setData("text/plain", sel.toString().replace(RegExp(SOFTHYPHEN, "g"), ""));
|
||||
e.clipboardData.setData("text/html", div.innerHTML.replace(RegExp(SOFTHYPHEN, "g"), ""));
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert settings from H.setup-Object to Map
|
||||
* This is a IIFE to keep complexity low.
|
||||
*/
|
||||
((H) => {
|
||||
/**
|
||||
* Create a Map with a default Map behind the scenes. This mimics
|
||||
* kind of a prototype chain of an object, but without the object-
|
||||
* injection security risk.
|
||||
*
|
||||
* @param {Map} defaultsMap - A Map with default values
|
||||
* @returns {Proxy} - A Proxy for the Map (dot-notation or get/set)
|
||||
*/
|
||||
function createMapWithDefaults(defaultsMap) {
|
||||
const userMap = new Map();
|
||||
|
||||
/**
|
||||
* The get-trap: get the value from userMap or else from defaults
|
||||
* @param {Sring} key - The key to retrieve the value for
|
||||
* @returns {*}
|
||||
*/
|
||||
function get(key) {
|
||||
return (userMap.has(key))
|
||||
? userMap.get(key)
|
||||
: defaultsMap.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The set-trap: set the value to userMap and don't touch defaults
|
||||
* @param {Sring} key - The key for the value
|
||||
* @param {*} value - The value
|
||||
* @returns {*}
|
||||
*/
|
||||
function set(key, value) {
|
||||
userMap.set(key, value);
|
||||
}
|
||||
return new Proxy(defaultsMap, {
|
||||
"get": (_target, prop) => {
|
||||
if (prop === "set") {
|
||||
return set;
|
||||
}
|
||||
if (prop === "get") {
|
||||
return get;
|
||||
}
|
||||
return get(prop);
|
||||
},
|
||||
"ownKeys": () => {
|
||||
return [
|
||||
...new Set(
|
||||
[...defaultsMap.keys(), ...userMap.keys()]
|
||||
)
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const settings = createMapWithDefaults(new Map([
|
||||
["defaultLanguage", "en-us"],
|
||||
[
|
||||
"dontHyphenate", (() => {
|
||||
const list = "abbr,acronym,audio,br,button,code,img,input,kbd,label,math,option,pre,samp,script,style,sub,sup,svg,textarea,var,video";
|
||||
return createMapWithDefaults(
|
||||
new Map(list.split(",").map((val) => {
|
||||
return [val, true];
|
||||
}))
|
||||
);
|
||||
})()
|
||||
],
|
||||
["dontHyphenateClass", "donthyphenate"],
|
||||
["exceptions", new Map()],
|
||||
["keepAlive", true],
|
||||
["normalize", false],
|
||||
["processShadows", false],
|
||||
["safeCopy", true],
|
||||
["substitute", new Map()],
|
||||
["timeout", 1000]
|
||||
]));
|
||||
o.entries(H.s).forEach(([key, value]) => {
|
||||
switch (key) {
|
||||
case "selectors":
|
||||
// Set settings.selectors to array of selectors
|
||||
settings.set("selectors", o.keys(value));
|
||||
|
||||
/*
|
||||
* For each selector add a property to settings with
|
||||
* selector specific settings
|
||||
*/
|
||||
o.entries(value).forEach(([sel, selSettings]) => {
|
||||
const selectorSettings = createMapWithDefaults(new Map([
|
||||
["compound", "hyphen"],
|
||||
["hyphen", SOFTHYPHEN],
|
||||
["leftmin", 0],
|
||||
["leftminPerLang", 0],
|
||||
["minWordLength", 6],
|
||||
["mixedCase", true],
|
||||
["orphanControl", 1],
|
||||
["rightmin", 0],
|
||||
["rightminPerLang", 0]
|
||||
]));
|
||||
o.entries(selSettings).forEach(
|
||||
([selSetting, setVal]) => {
|
||||
if (typeof setVal === "object") {
|
||||
selectorSettings.set(
|
||||
selSetting,
|
||||
new Map(o.entries(setVal))
|
||||
);
|
||||
} else {
|
||||
selectorSettings.set(selSetting, setVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
settings.set(sel, selectorSettings);
|
||||
});
|
||||
break;
|
||||
case "dontHyphenate":
|
||||
case "exceptions":
|
||||
o.entries(value).forEach(([k, v]) => {
|
||||
settings.get(key).set(k, v);
|
||||
});
|
||||
break;
|
||||
case "substitute":
|
||||
o.entries(value).forEach(([lang, subst]) => {
|
||||
settings.substitute.set(
|
||||
lang,
|
||||
new Map(o.entries(subst))
|
||||
);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
settings.set(key, value);
|
||||
}
|
||||
});
|
||||
H.c = settings;
|
||||
})(Hyphenopoly);
|
||||
|
||||
((H) => {
|
||||
const C = H.c;
|
||||
let mainLanguage = null;
|
||||
|
||||
event.fire(
|
||||
"hyphenopolyStart",
|
||||
{
|
||||
"msg": "hyphenopolyStart"
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Factory for elements
|
||||
* @returns {Object} elements-object
|
||||
*/
|
||||
function makeElementCollection() {
|
||||
const list = new Map();
|
||||
|
||||
/*
|
||||
* Counter counts the elements to be hyphenated.
|
||||
* Needs to be an object (Pass by reference)
|
||||
*/
|
||||
const counter = [0];
|
||||
|
||||
/**
|
||||
* Add element to elements
|
||||
* @param {object} el The element
|
||||
* @param {string} lang The language of the element
|
||||
* @param {string} sel The selector of the element
|
||||
* @returns {Object} An element-object
|
||||
*/
|
||||
function add(el, lang, sel) {
|
||||
const elo = {
|
||||
"element": el,
|
||||
"selector": sel
|
||||
};
|
||||
if (!list.has(lang)) {
|
||||
list.set(lang, []);
|
||||
}
|
||||
list.get(lang).push(elo);
|
||||
counter[0] += 1;
|
||||
return elo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes elements from the list and updates the counter
|
||||
* @param {string} lang - The lang of the elements to remove
|
||||
*/
|
||||
function rem(lang) {
|
||||
let langCount = 0;
|
||||
if (list.has(lang)) {
|
||||
langCount = list.get(lang).length;
|
||||
list.delete(lang);
|
||||
counter[0] -= langCount;
|
||||
if (counter[0] === 0) {
|
||||
event.fire(
|
||||
"hyphenopolyEnd",
|
||||
{
|
||||
"msg": "hyphenopolyEnd"
|
||||
}
|
||||
);
|
||||
if (!C.keepAlive) {
|
||||
window.Hyphenopoly = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
counter,
|
||||
list,
|
||||
rem
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language of element by searching its parents or fallback
|
||||
* @param {Object} el The element
|
||||
* @param {string} parentLang Lang of parent if available
|
||||
* @param {boolean} fallback Will falback to mainlanguage
|
||||
* @returns {string|null} The language or null
|
||||
*/
|
||||
function getLang(el, parentLang = "", fallback = true) {
|
||||
// Find closest el with lang attr not empty
|
||||
el = el.closest("[lang]:not([lang=''])");
|
||||
if (el && el.lang) {
|
||||
return el.lang.toLowerCase();
|
||||
}
|
||||
if (parentLang) {
|
||||
return parentLang;
|
||||
}
|
||||
return (fallback)
|
||||
? mainLanguage
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect elements that have a selector defined in C.selectors
|
||||
* and add them to elements.
|
||||
* @param {Object} [parent = null] The start point element
|
||||
* @param {string} [selector = null] The selector matching the parent
|
||||
* @returns {Object} elements-object
|
||||
*/
|
||||
function collectElements(parent = null, selector = null) {
|
||||
const elements = makeElementCollection();
|
||||
|
||||
const dontHyphenateSelector = (() => {
|
||||
let s = "." + C.dontHyphenateClass;
|
||||
o.getOwnPropertyNames(C.dontHyphenate).forEach((tag) => {
|
||||
if (C.dontHyphenate.get(tag)) {
|
||||
s += "," + tag;
|
||||
}
|
||||
});
|
||||
return s;
|
||||
})();
|
||||
const matchingSelectors = C.selectors.join(",") + "," + dontHyphenateSelector;
|
||||
|
||||
/**
|
||||
* Recursively walk all elements in el, lending lang and selName
|
||||
* add them to elements if necessary.
|
||||
* @param {Object} el The element to scan
|
||||
* @param {string} pLang The language of the parent element
|
||||
* @param {string} sel The selector of the parent element
|
||||
* @param {boolean} isChild If el is a child element
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function processElements(el, pLang, sel, isChild = false) {
|
||||
const eLang = getLang(el, pLang);
|
||||
const langDef = H.cf.langs.get(eLang);
|
||||
if (langDef === "H9Y") {
|
||||
elements.add(el, eLang, sel);
|
||||
if (!isChild && C.safeCopy) {
|
||||
registerOnCopy(el);
|
||||
}
|
||||
} else if (!langDef && eLang !== "zxx") {
|
||||
event.fire(
|
||||
"error",
|
||||
Error(`Element with '${eLang}' found, but '${eLang}.wasm' not loaded. Check language tags!`)
|
||||
);
|
||||
}
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 1 && !n.matches(matchingSelectors)) {
|
||||
processElements(n, eLang, sel, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches the DOM for each sel
|
||||
* @param {object} root The DOM root
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function getElems(root) {
|
||||
C.selectors.forEach((sel) => {
|
||||
root.querySelectorAll(sel).forEach((n) => {
|
||||
processElements(n, getLang(n), sel, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (parent === null) {
|
||||
if (C.processShadows) {
|
||||
w.document.querySelectorAll("*").forEach((m) => {
|
||||
if (m.shadowRoot) {
|
||||
getElems(m.shadowRoot);
|
||||
}
|
||||
});
|
||||
}
|
||||
getElems(w.document);
|
||||
} else {
|
||||
processElements(parent, getLang(parent), selector);
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
const wordHyphenatorPool = new Map();
|
||||
|
||||
/**
|
||||
* Factory for hyphenatorFunctions for a specific language and selector
|
||||
* @param {Object} lo Language-Object
|
||||
* @param {string} lang The language
|
||||
* @param {string} sel The selector
|
||||
* @returns {function} The hyphenate function
|
||||
*/
|
||||
function createWordHyphenator(lo, lang, sel) {
|
||||
const poolKey = lang + "-" + sel;
|
||||
if (wordHyphenatorPool.has(poolKey)) {
|
||||
return wordHyphenatorPool.get(poolKey);
|
||||
}
|
||||
|
||||
const selSettings = C.get(sel);
|
||||
lo.cache.set(sel, new Map());
|
||||
|
||||
/**
|
||||
* HyphenateFunction for non-compound words
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated word
|
||||
*/
|
||||
function hyphenateNormal(word) {
|
||||
if (word.length > 61) {
|
||||
event.fire(
|
||||
"error",
|
||||
Error("Found word longer than 61 characters")
|
||||
);
|
||||
} else if (!lo.reNotAlphabet.test(word)) {
|
||||
return lo.hyphenate(
|
||||
word,
|
||||
selSettings.hyphen.charCodeAt(0),
|
||||
selSettings.leftminPerLang.get(lang),
|
||||
selSettings.rightminPerLang.get(lang)
|
||||
);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* HyphenateFunction for compound words
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated compound word
|
||||
*/
|
||||
function hyphenateCompound(word) {
|
||||
const zeroWidthSpace = "\u200B";
|
||||
let parts = null;
|
||||
let wordHyphenator = null;
|
||||
if (selSettings.compound === "auto" ||
|
||||
selSettings.compound === "all") {
|
||||
wordHyphenator = createWordHyphenator(lo, lang, sel);
|
||||
parts = word.split("-").map((p) => {
|
||||
if (p.length >= selSettings.minWordLength) {
|
||||
return wordHyphenator(p);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
if (selSettings.compound === "auto") {
|
||||
word = parts.join("-");
|
||||
} else {
|
||||
word = parts.join("-" + zeroWidthSpace);
|
||||
}
|
||||
} else {
|
||||
word = word.replace("-", "-" + zeroWidthSpace);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is mixed case
|
||||
* @param {string} s The string
|
||||
* @returns {boolean} true if s is mixed case
|
||||
*/
|
||||
function isMixedCase(s) {
|
||||
return [...s].map((c) => {
|
||||
return (c === c.toLowerCase());
|
||||
}).some((v, i, a) => {
|
||||
return (v !== a[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HyphenateFunction for words (compound or not)
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated word
|
||||
*/
|
||||
function hyphenator(word) {
|
||||
let hw = lo.cache.get(sel).get(word);
|
||||
if (!hw) {
|
||||
if (lo.exc.has(word)) {
|
||||
hw = lo.exc.get(word).replace(
|
||||
/-/g,
|
||||
selSettings.hyphen
|
||||
);
|
||||
} else if (!selSettings.mixedCase && isMixedCase(word)) {
|
||||
hw = word;
|
||||
} else if (word.indexOf("-") === -1) {
|
||||
hw = hyphenateNormal(word);
|
||||
} else {
|
||||
hw = hyphenateCompound(word);
|
||||
}
|
||||
lo.cache.get(sel).set(word, hw);
|
||||
}
|
||||
return hw;
|
||||
}
|
||||
wordHyphenatorPool.set(poolKey, hyphenator);
|
||||
return hyphenator;
|
||||
}
|
||||
|
||||
const orphanControllerPool = new Map();
|
||||
|
||||
/**
|
||||
* Factory for function that handles orphans
|
||||
* @param {string} sel The selector
|
||||
* @returns {function} The function created
|
||||
*/
|
||||
function createOrphanController(sel) {
|
||||
if (orphanControllerPool.has(sel)) {
|
||||
return orphanControllerPool.get(sel);
|
||||
}
|
||||
const selSettings = C.get(sel);
|
||||
|
||||
/**
|
||||
* Function template
|
||||
* @param {string} ignore unused result of replace
|
||||
* @param {string} leadingWhiteSpace The leading whiteSpace
|
||||
* @param {string} lastWord The last word
|
||||
* @param {string} trailingWhiteSpace The trailing whiteSpace
|
||||
* @returns {string} Treated end of text
|
||||
*/
|
||||
function controlOrphans(
|
||||
ignore,
|
||||
leadingWhiteSpace,
|
||||
lastWord,
|
||||
trailingWhiteSpace
|
||||
) {
|
||||
if (selSettings.orphanControl === 3 && leadingWhiteSpace === " ") {
|
||||
// \u00A0 = no-break space (nbsp)
|
||||
leadingWhiteSpace = "\u00A0";
|
||||
}
|
||||
return leadingWhiteSpace + lastWord.replace(RegExp(selSettings.hyphen, "g"), "") + trailingWhiteSpace;
|
||||
}
|
||||
orphanControllerPool.set(sel, controlOrphans);
|
||||
return controlOrphans;
|
||||
}
|
||||
|
||||
const wordRegExpPool = new Map();
|
||||
|
||||
/**
|
||||
* Hyphenate an entitiy (text string or Element-Object)
|
||||
* @param {string} lang - the language of the string
|
||||
* @param {string} sel - the selectorName of settings
|
||||
* @param {string} entity - the entity to be hyphenated
|
||||
* @returns {string | null} hyphenated str according to setting of sel
|
||||
*/
|
||||
function hyphenate(lang, sel, entity) {
|
||||
const lo = H.languages.get(lang);
|
||||
const selSettings = C.get(sel);
|
||||
const minWordLength = selSettings.minWordLength;
|
||||
|
||||
|
||||
const regExpWord = (() => {
|
||||
const key = lang + minWordLength;
|
||||
if (wordRegExpPool.has(key)) {
|
||||
return wordRegExpPool.get(key);
|
||||
}
|
||||
|
||||
/*
|
||||
* Transpiled RegExp of
|
||||
* /[${alphabet}\p{Mn}Subset\p{Letter}\00AD-]
|
||||
* {${minwordlength},}/gui
|
||||
*/
|
||||
const reWord = RegExp(
|
||||
`[${lo.alphabet}a-z\u0300-\u036F\u0483-\u0487\u00DF-\u00F6\u00F8-\u00FE\u0101\u0103\u0105\u0107\u0109\u010D\u010F\u0111\u0113\u0117\u0119\u011B\u011D\u011F\u0123\u0125\u012B\u012F\u0131\u0135\u0137\u013C\u013E\u0142\u0144\u0146\u0148\u014D\u0151\u0153\u0155\u0159\u015B\u015D\u015F\u0161\u0165\u016B\u016D\u016F\u0171\u0173\u017A\u017C\u017E\u017F\u01CE\u01D0\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0219\u021B\u02BC\u0390\u03AC-\u03CE\u03D0\u03E3\u03E5\u03E7\u03E9\u03EB\u03ED\u03EF\u03F2\u0430-\u044F\u0451-\u045C\u045E\u045F\u0491\u04AF\u04E9\u0561-\u0585\u0587\u0905-\u090C\u090F\u0910\u0913-\u0928\u092A-\u0930\u0932\u0933\u0935-\u0939\u093D\u0960\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A85-\u0A8B\u0A8F\u0A90\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B60\u0B61\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0D7A-\u0D7F\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u10D0-\u10F0\u1200-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u1E0D\u1E37\u1E41\u1E43\u1E45\u1E47\u1E6D\u1F00-\u1F07\u1F10-\u1F15\u1F20-\u1F27\u1F30-\u1F37\u1F40-\u1F45\u1F50-\u1F57\u1F60-\u1F67\u1F70-\u1F7D\u1F80-\u1F87\u1F90-\u1F97\u1FA0-\u1FA7\u1FB2-\u1FB4\u1FB6\u1FB7\u1FC2-\u1FC4\u1FC6\u1FC7\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2-\u1FE7\u1FF2-\u1FF4\u1FF6\u1FF7\u2C81\u2C83\u2C85\u2C87\u2C89\u2C8D\u2C8F\u2C91\u2C93\u2C95\u2C97\u2C99\u2C9B\u2C9D\u2C9F\u2CA1\u2CA3\u2CA5\u2CA7\u2CA9\u2CAB\u2CAD\u2CAF\u2CB1\u2CC9\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\u00AD\u200B-\u200D-]{${minWordLength},}`, "gui"
|
||||
);
|
||||
wordRegExpPool.set(key, reWord);
|
||||
return reWord;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Hyphenate text according to setting in sel
|
||||
* @param {string} text - the strint to be hyphenated
|
||||
* @returns {string} hyphenated string according to setting of sel
|
||||
*/
|
||||
function hyphenateText(text) {
|
||||
if (C.normalize) {
|
||||
text = text.normalize("NFC");
|
||||
}
|
||||
let tn = text.replace(
|
||||
regExpWord,
|
||||
createWordHyphenator(lo, lang, sel)
|
||||
);
|
||||
if (selSettings.orphanControl !== 1) {
|
||||
tn = tn.replace(
|
||||
/(\u0020*)(\S+)(\s*)$/,
|
||||
createOrphanController(sel)
|
||||
);
|
||||
}
|
||||
return tn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hyphenate element according to setting in sel
|
||||
* @param {object} el - the HTMLElement to be hyphenated
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function hyphenateElement(el) {
|
||||
event.fire(
|
||||
"beforeElementHyphenation",
|
||||
{
|
||||
el,
|
||||
lang
|
||||
}
|
||||
);
|
||||
el.childNodes.forEach((n) => {
|
||||
if (
|
||||
n.nodeType === 3 &&
|
||||
(/\S/).test(n.data) &&
|
||||
n.data.length >= minWordLength
|
||||
) {
|
||||
n.data = hyphenateText(n.data);
|
||||
}
|
||||
});
|
||||
H.res.els.counter[0] -= 1;
|
||||
event.fire(
|
||||
"afterElementHyphenation",
|
||||
{
|
||||
el,
|
||||
lang
|
||||
}
|
||||
);
|
||||
}
|
||||
let r = null;
|
||||
if (typeof entity === "string") {
|
||||
r = hyphenateText(entity);
|
||||
} else if (entity instanceof HTMLElement) {
|
||||
hyphenateElement(entity);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a language-specific string hyphenator
|
||||
* @param {String} lang - The language this hyphenator hyphenates
|
||||
*/
|
||||
function createStringHyphenator(lang) {
|
||||
return ((entity, sel = ".hyphenate") => {
|
||||
if (typeof entity !== "string") {
|
||||
event.fire(
|
||||
"error",
|
||||
Error("This use of hyphenators is deprecated. See https://mnater.github.io/Hyphenopoly/Hyphenators.html")
|
||||
);
|
||||
}
|
||||
return hyphenate(lang, sel, entity);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a polyglot HTML hyphenator
|
||||
*/
|
||||
function createDOMHyphenator() {
|
||||
return ((entity, sel = ".hyphenate") => {
|
||||
collectElements(entity, sel).list.forEach((els, l) => {
|
||||
els.forEach((elo) => {
|
||||
hyphenate(l, elo.selector, elo.element);
|
||||
});
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
H.unhyphenate = () => {
|
||||
H.res.els.list.forEach((els) => {
|
||||
els.forEach((elo) => {
|
||||
const n = elo.element.firstChild;
|
||||
n.data = n.data.replace(RegExp(C[elo.selector].hyphen, "g"), "");
|
||||
});
|
||||
});
|
||||
return Promise.resolve(H.res.els);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hyphenate all elements with a given language
|
||||
* @param {string} lang The language
|
||||
* @param {Array} elArr Array of elements
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function hyphenateLangElements(lang, elements) {
|
||||
const elArr = elements.list.get(lang);
|
||||
if (elArr) {
|
||||
elArr.forEach((elo) => {
|
||||
hyphenate(lang, elo.selector, elo.element);
|
||||
});
|
||||
} else {
|
||||
event.fire(
|
||||
"error",
|
||||
Error(`Engine for language '${lang}' loaded, but no elements found.`)
|
||||
);
|
||||
}
|
||||
if (elements.counter[0] === 0) {
|
||||
w.clearTimeout(H.timeOutHandler);
|
||||
H.hide(0, null);
|
||||
event.fire(
|
||||
"hyphenopolyEnd",
|
||||
{
|
||||
"msg": "hyphenopolyEnd"
|
||||
}
|
||||
);
|
||||
if (!C.keepAlive) {
|
||||
window.Hyphenopoly = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the exceptions from user input to Map
|
||||
* @param {string} lang - The language for which the Map is created
|
||||
* @return {Map}
|
||||
*/
|
||||
function createExceptionMap(lang) {
|
||||
let exc = "";
|
||||
if (C.exceptions.has(lang)) {
|
||||
exc = C.exceptions.get(lang);
|
||||
}
|
||||
if (C.exceptions.has("global")) {
|
||||
if (exc === "") {
|
||||
exc = C.exceptions.get("global");
|
||||
} else {
|
||||
exc += ", " + C.exceptions.get("global");
|
||||
}
|
||||
}
|
||||
if (exc === "") {
|
||||
return new Map();
|
||||
}
|
||||
return new Map(exc.split(", ").map((e) => {
|
||||
return [e.replace(/-/g, ""), e];
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup lo
|
||||
* @param {string} lang The language
|
||||
* @param {function} hyphenateFunction The hyphenateFunction
|
||||
* @param {string} alphabet List of used characters
|
||||
* @param {number} leftmin leftmin
|
||||
* @param {number} rightmin rightmin
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function prepareLanguagesObj(
|
||||
lang,
|
||||
hyphenateFunction,
|
||||
alphabet,
|
||||
patternLeftmin,
|
||||
patternRightmin
|
||||
) {
|
||||
C.selectors.forEach((sel) => {
|
||||
const selSettings = C.get(sel);
|
||||
if (selSettings.leftminPerLang === 0) {
|
||||
selSettings.set("leftminPerLang", new Map());
|
||||
}
|
||||
if (selSettings.rightminPerLang === 0) {
|
||||
selSettings.set("rightminPerLang", new Map());
|
||||
}
|
||||
selSettings.leftminPerLang.set(lang, Math.max(
|
||||
patternLeftmin,
|
||||
selSettings.leftmin,
|
||||
Number(selSettings.leftminPerLang.get(lang)) || 0
|
||||
));
|
||||
|
||||
selSettings.rightminPerLang.set(lang, Math.max(
|
||||
patternRightmin,
|
||||
selSettings.rightmin,
|
||||
Number(selSettings.rightminPerLang.get(lang)) || 0
|
||||
));
|
||||
});
|
||||
if (!H.languages) {
|
||||
H.languages = new Map();
|
||||
}
|
||||
alphabet = alphabet.replace(/\\*-/g, "\\-");
|
||||
H.languages.set(lang, {
|
||||
alphabet,
|
||||
"cache": new Map(),
|
||||
"exc": createExceptionMap(lang),
|
||||
"hyphenate": hyphenateFunction,
|
||||
"ready": true,
|
||||
"reNotAlphabet": RegExp(`[^${alphabet}]`, "i")
|
||||
});
|
||||
H.hy6ors.get(lang).resolve(createStringHyphenator(lang));
|
||||
event.fire(
|
||||
"engineReady",
|
||||
{
|
||||
lang
|
||||
}
|
||||
);
|
||||
if (H.res.els) {
|
||||
hyphenateLangElements(lang, H.res.els);
|
||||
}
|
||||
}
|
||||
|
||||
const decode = (() => {
|
||||
const utf16ledecoder = new TextDecoder("utf-16le");
|
||||
return ((ui16) => {
|
||||
return utf16ledecoder.decode(ui16);
|
||||
});
|
||||
})();
|
||||
|
||||
/**
|
||||
* Setup env for hyphenateFunction
|
||||
* @param {ArrayBuffer} buf Memory buffer
|
||||
* @param {function} hyphenateFunc hyphenateFunction
|
||||
* @returns {function} hyphenateFunction with closured environment
|
||||
*/
|
||||
function encloseHyphenateFunction(buf, hyphenateFunc) {
|
||||
const wordStore = new Uint16Array(buf, 0, 64);
|
||||
return ((word, hyphencc, leftmin, rightmin) => {
|
||||
wordStore.set([
|
||||
...[...word].map((c) => {
|
||||
return c.charCodeAt(0);
|
||||
}),
|
||||
0
|
||||
]);
|
||||
const len = hyphenateFunc(leftmin, rightmin, hyphencc);
|
||||
if (len > 0) {
|
||||
word = decode(
|
||||
new Uint16Array(buf, 0, len)
|
||||
);
|
||||
}
|
||||
return word;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate Wasm Engine
|
||||
* @param {string} lang The language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function instantiateWasmEngine(heProm, lang) {
|
||||
const wa = window.WebAssembly;
|
||||
|
||||
/**
|
||||
* Register character substitutions in the .wasm-hyphenEngine
|
||||
* @param {number} alphalen - The length of the alphabet
|
||||
* @param {object} exp - Export-object of the hyphenEngine
|
||||
*/
|
||||
function registerSubstitutions(alphalen, exp) {
|
||||
if (C.substitute.has(lang)) {
|
||||
const subst = C.substitute.get(lang);
|
||||
subst.forEach((substituer, substituted) => {
|
||||
const substitutedU = substituted.toUpperCase();
|
||||
const substitutedUcc = (substitutedU === substituted)
|
||||
? 0
|
||||
: substitutedU.charCodeAt(0);
|
||||
alphalen = exp.subst(
|
||||
substituted.charCodeAt(0),
|
||||
substitutedUcc,
|
||||
substituer.charCodeAt(0)
|
||||
);
|
||||
});
|
||||
}
|
||||
return alphalen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the hyphenEngine
|
||||
* @param {object} res - The fetched ressource
|
||||
*/
|
||||
function handleWasm(res) {
|
||||
const exp = res.instance.exports;
|
||||
// eslint-disable-next-line multiline-ternary
|
||||
let alphalen = (wa.Global) ? exp.lct.value : exp.lct;
|
||||
alphalen = registerSubstitutions(alphalen, exp);
|
||||
heProm.l.forEach((l) => {
|
||||
prepareLanguagesObj(
|
||||
l,
|
||||
encloseHyphenateFunction(
|
||||
exp.mem.buffer,
|
||||
exp.hyphenate
|
||||
),
|
||||
decode(new Uint16Array(exp.mem.buffer, 1408, alphalen)),
|
||||
/* eslint-disable multiline-ternary */
|
||||
(wa.Global) ? exp.lmi.value : exp.lmi,
|
||||
(wa.Global) ? exp.rmi.value : exp.rmi
|
||||
/* eslint-enable multiline-ternary */
|
||||
);
|
||||
});
|
||||
}
|
||||
heProm.w.then((response) => {
|
||||
if (response.ok) {
|
||||
if (
|
||||
wa.instantiateStreaming &&
|
||||
(response.headers.get("Content-Type") === "application/wasm")
|
||||
) {
|
||||
return wa.instantiateStreaming(response);
|
||||
}
|
||||
return response.arrayBuffer().then((ab) => {
|
||||
return wa.instantiate(ab);
|
||||
});
|
||||
}
|
||||
return Promise.reject(Error(`File ${lang}.wasm can't be loaded from ${H.paths.patterndir}`));
|
||||
}).then(handleWasm, (e) => {
|
||||
event.fire("error", e);
|
||||
H.res.els.rem(lang);
|
||||
});
|
||||
}
|
||||
|
||||
H.main = () => {
|
||||
H.res.DOM.then(() => {
|
||||
mainLanguage = getLang(w.document.documentElement, "", false);
|
||||
if (!mainLanguage && C.defaultLanguage !== "") {
|
||||
mainLanguage = C.defaultLanguage;
|
||||
}
|
||||
const elements = collectElements();
|
||||
H.res.els = elements;
|
||||
elements.list.forEach((ignore, lang) => {
|
||||
if (H.languages &&
|
||||
H.languages.has(lang) &&
|
||||
H.languages.get(lang).ready
|
||||
) {
|
||||
hyphenateLangElements(lang, elements);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
H.res.he.forEach(instantiateWasmEngine);
|
||||
|
||||
Promise.all(
|
||||
// Make sure all lang specific hyphenators and DOM are ready
|
||||
[...H.hy6ors.entries()].
|
||||
reduce((accumulator, value) => {
|
||||
if (value[0] !== "HTML") {
|
||||
return accumulator.concat(value[1]);
|
||||
}
|
||||
return accumulator;
|
||||
}, []).
|
||||
concat(H.res.DOM)
|
||||
).then(() => {
|
||||
H.hy6ors.get("HTML").resolve(createDOMHyphenator());
|
||||
}, (e) => {
|
||||
event.fire("error", e);
|
||||
});
|
||||
};
|
||||
H.main();
|
||||
})(Hyphenopoly);
|
||||
})(window, Object);
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* @license Hyphenopoly_Loader 5.2.0-beta.1 - client side hyphenation
|
||||
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
|
||||
* https://github.com/mnater/Hyphenopoly
|
||||
*
|
||||
* Released under the MIT license
|
||||
* http://mnater.github.io/Hyphenopoly/LICENSE
|
||||
*/
|
||||
/* globals Hyphenopoly:readonly */
|
||||
window.Hyphenopoly = {};
|
||||
|
||||
((w, d, H, o) => {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Shortcut for new Map
|
||||
* @param {any} init - initialiser for new Map
|
||||
* @returns {Map}
|
||||
*/
|
||||
const mp = (init) => {
|
||||
return new Map(init);
|
||||
};
|
||||
|
||||
const scriptName = "Hyphenopoly_Loader.js";
|
||||
const thisScript = d.currentScript.src;
|
||||
const store = sessionStorage;
|
||||
let mainScriptLoaded = false;
|
||||
|
||||
/**
|
||||
* The main function runs the feature test and loads Hyphenopoly if
|
||||
* necessary.
|
||||
*/
|
||||
const main = (() => {
|
||||
const shortcuts = {
|
||||
"ac": "appendChild",
|
||||
"ce": "createElement",
|
||||
"ct": "createTextNode"
|
||||
};
|
||||
|
||||
/**
|
||||
* Create deferred Promise
|
||||
*
|
||||
* From http://lea.verou.me/2016/12/resolve-promises-externally-with-
|
||||
* this-one-weird-trick/
|
||||
* @return {promise}
|
||||
*/
|
||||
const defProm = () => {
|
||||
let res = null;
|
||||
let rej = null;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
res = resolve;
|
||||
rej = reject;
|
||||
});
|
||||
promise.resolve = res;
|
||||
promise.reject = rej;
|
||||
return promise;
|
||||
};
|
||||
|
||||
H.ac = new AbortController();
|
||||
const fetchOptions = {
|
||||
"credentials": H.s.CORScredentials,
|
||||
"signal": H.ac.signal
|
||||
};
|
||||
|
||||
let stylesNode = null;
|
||||
|
||||
/**
|
||||
* Define function H.hide.
|
||||
* This function hides (state = 1) or unhides (state = 0)
|
||||
* the whole document (mode == 0) or
|
||||
* each selected element (mode == 1) or
|
||||
* text of each selected element (mode == 2) or
|
||||
* nothing (mode == -1)
|
||||
* @param {integer} state - State
|
||||
* @param {integer} mode - Mode
|
||||
*/
|
||||
H.hide = (state, mode) => {
|
||||
if (state) {
|
||||
let vis = "{visibility:hidden!important}";
|
||||
stylesNode = d[shortcuts.ce]("style");
|
||||
let myStyle = "";
|
||||
if (mode === 0) {
|
||||
myStyle = "html" + vis;
|
||||
} else if (mode !== -1) {
|
||||
if (mode === 2) {
|
||||
vis = "{color:transparent!important}";
|
||||
}
|
||||
o.keys(H.s.selectors).forEach((sel) => {
|
||||
myStyle += sel + vis;
|
||||
});
|
||||
}
|
||||
stylesNode[shortcuts.ac](d[shortcuts.ct](myStyle));
|
||||
d.head[shortcuts.ac](stylesNode);
|
||||
} else if (stylesNode) {
|
||||
stylesNode.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const tester = (() => {
|
||||
let fakeBody = null;
|
||||
return {
|
||||
|
||||
/**
|
||||
* Append fakeBody with tests to document
|
||||
* @returns {Object|null} The body element or null, if no tests
|
||||
*/
|
||||
"ap": () => {
|
||||
if (fakeBody) {
|
||||
d.documentElement[shortcuts.ac](fakeBody);
|
||||
return fakeBody;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove fakeBody
|
||||
* @returns {undefined}
|
||||
*/
|
||||
"cl": () => {
|
||||
if (fakeBody) {
|
||||
fakeBody.remove();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create and append div with CSS-hyphenated word
|
||||
* @param {string} lang Language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
"cr": (lang) => {
|
||||
if (H.cf.langs.has(lang)) {
|
||||
return;
|
||||
}
|
||||
fakeBody = fakeBody || d[shortcuts.ce]("body");
|
||||
const testDiv = d[shortcuts.ce]("div");
|
||||
const ha = "hyphens:auto";
|
||||
testDiv.lang = lang;
|
||||
testDiv.style.cssText = `visibility:hidden;-webkit-${ha};-ms-${ha};${ha};width:48px;font-size:12px;line-height:12px;border:none;padding:0;word-wrap:normal`;
|
||||
testDiv[shortcuts.ac](
|
||||
d[shortcuts.ct](H.lrq.get(lang).wo.toLowerCase())
|
||||
);
|
||||
fakeBody[shortcuts.ac](testDiv);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* Checks if hyphens (ev.prefixed) is set to auto for the element.
|
||||
* @param {Object} elm - the element
|
||||
* @returns {Boolean} result of the check
|
||||
*/
|
||||
const checkCSSHyphensSupport = (elmStyle) => {
|
||||
const h = elmStyle.hyphens ||
|
||||
elmStyle.webkitHyphens ||
|
||||
elmStyle.msHyphens;
|
||||
return (h === "auto");
|
||||
};
|
||||
|
||||
H.res = {
|
||||
"he": mp()
|
||||
};
|
||||
|
||||
/**
|
||||
* Load hyphenEngines to H.res.he
|
||||
*
|
||||
* Make sure each .wasm is loaded exactly once, even for fallbacks
|
||||
* Store a list of languages to by hyphenated with each .wasm
|
||||
* @param {string} lang The language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
const loadhyphenEngine = (lang) => {
|
||||
const fn = H.lrq.get(lang).fn;
|
||||
H.cf.pf = true;
|
||||
H.cf.langs.set(lang, "H9Y");
|
||||
if (H.res.he.has(fn)) {
|
||||
H.res.he.get(fn).l.push(lang);
|
||||
} else {
|
||||
H.res.he.set(
|
||||
fn,
|
||||
{
|
||||
"l": [lang],
|
||||
"w": w.fetch(H.paths.patterndir + fn + ".wasm", fetchOptions)
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
H.lrq.forEach((value, lang) => {
|
||||
if (value.wo === "FORCEHYPHENOPOLY" || H.cf.langs.get(lang) === "H9Y") {
|
||||
loadhyphenEngine(lang);
|
||||
} else {
|
||||
tester.cr(lang);
|
||||
}
|
||||
});
|
||||
const testContainer = tester.ap();
|
||||
if (testContainer) {
|
||||
testContainer.querySelectorAll("div").forEach((n) => {
|
||||
if (checkCSSHyphensSupport(n.style) && n.offsetHeight > 12) {
|
||||
H.cf.langs.set(n.lang, "CSS");
|
||||
} else {
|
||||
loadhyphenEngine(n.lang);
|
||||
}
|
||||
});
|
||||
tester.cl();
|
||||
}
|
||||
const hev = H.hev;
|
||||
if (H.cf.pf) {
|
||||
H.res.DOM = new Promise((res) => {
|
||||
if (d.readyState === "loading") {
|
||||
d.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
res,
|
||||
{
|
||||
"once": true,
|
||||
"passive": true
|
||||
}
|
||||
);
|
||||
} else {
|
||||
res();
|
||||
}
|
||||
});
|
||||
H.hide(1, H.s.hide);
|
||||
H.timeOutHandler = w.setTimeout(() => {
|
||||
H.hide(0, null);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (H.s.timeout & 1) {
|
||||
H.ac.abort();
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(scriptName + " timed out.");
|
||||
}, H.s.timeout);
|
||||
if (mainScriptLoaded) {
|
||||
H.main();
|
||||
} else {
|
||||
// Load main script
|
||||
fetch(H.paths.maindir + "Hyphenopoly.js", fetchOptions).
|
||||
then((response) => {
|
||||
if (response.ok) {
|
||||
response.blob().then((blb) => {
|
||||
const script = d[shortcuts.ce]("script");
|
||||
script.src = URL.createObjectURL(blb);
|
||||
d.head[shortcuts.ac](script);
|
||||
mainScriptLoaded = true;
|
||||
URL.revokeObjectURL(script.src);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
H.hy6ors = mp();
|
||||
H.cf.langs.forEach((langDef, lang) => {
|
||||
if (langDef === "H9Y") {
|
||||
H.hy6ors.set(lang, defProm());
|
||||
}
|
||||
});
|
||||
H.hy6ors.set("HTML", defProm());
|
||||
H.hyphenators = new Proxy(H.hy6ors, {
|
||||
"get": (target, key) => {
|
||||
return target.get(key);
|
||||
},
|
||||
"set": () => {
|
||||
// Inhibit setting of hyphenators
|
||||
return true;
|
||||
}
|
||||
});
|
||||
(() => {
|
||||
if (hev && hev.polyfill) {
|
||||
hev.polyfill();
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
(() => {
|
||||
if (hev && hev.tearDown) {
|
||||
hev.tearDown();
|
||||
}
|
||||
w.Hyphenopoly = null;
|
||||
})();
|
||||
}
|
||||
(() => {
|
||||
if (H.cft) {
|
||||
store.setItem(scriptName, JSON.stringify(
|
||||
{
|
||||
"langs": [...H.cf.langs.entries()],
|
||||
"pf": H.cf.pf
|
||||
}
|
||||
));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
H.config = (c) => {
|
||||
/**
|
||||
* Sets default properties for an Object
|
||||
* @param {object} obj - The object to set defaults to
|
||||
* @param {object} defaults - The defaults to set
|
||||
* @returns {object}
|
||||
*/
|
||||
const setDefaults = (obj, defaults) => {
|
||||
if (obj) {
|
||||
o.entries(defaults).forEach(([k, v]) => {
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
obj[k] = obj[k] || v;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
return defaults;
|
||||
};
|
||||
|
||||
H.cft = Boolean(c.cacheFeatureTests);
|
||||
if (H.cft && store.getItem(scriptName)) {
|
||||
H.cf = JSON.parse(store.getItem(scriptName));
|
||||
H.cf.langs = mp(H.cf.langs);
|
||||
} else {
|
||||
H.cf = {
|
||||
"langs": mp(),
|
||||
"pf": false
|
||||
};
|
||||
}
|
||||
|
||||
const maindir = thisScript.slice(0, (thisScript.lastIndexOf("/") + 1));
|
||||
const patterndir = maindir + "patterns/";
|
||||
H.paths = setDefaults(c.paths, {
|
||||
maindir,
|
||||
patterndir
|
||||
});
|
||||
H.s = setDefaults(c.setup, {
|
||||
"CORScredentials": "include",
|
||||
"hide": "all",
|
||||
"selectors": {".hyphenate": {}},
|
||||
"timeout": 1000
|
||||
});
|
||||
// Change mode string to mode int
|
||||
H.s.hide = ["all", "element", "text"].indexOf(H.s.hide);
|
||||
if (c.handleEvent) {
|
||||
H.hev = c.handleEvent;
|
||||
}
|
||||
|
||||
const fallbacks = mp(o.entries(c.fallbacks || {}));
|
||||
H.lrq = mp();
|
||||
o.entries(c.require).forEach(([lang, wo]) => {
|
||||
H.lrq.set(lang.toLowerCase(), {
|
||||
"fn": fallbacks.get(lang) || lang,
|
||||
wo
|
||||
});
|
||||
});
|
||||
|
||||
main();
|
||||
};
|
||||
})(window, document, Hyphenopoly, Object);
|
||||
@@ -10,19 +10,18 @@ class AnimationQueueModule extends BaseModule {
|
||||
super('animation-queue', 'Animation Queue');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['tts-player'];
|
||||
this.dependencies = [];
|
||||
|
||||
// Queue of scheduled animations/functions
|
||||
this.timeoutQueue = [];
|
||||
|
||||
// Animation timing properties - use parent's config system
|
||||
this.updateConfig({
|
||||
speed: 0.05, // Base animation speed (seconds per character)
|
||||
speed: 1.0, // Speed multiplier for delays (1.0 = no scaling, delays are pre-calculated)
|
||||
fastForwardEnabled: false
|
||||
});
|
||||
|
||||
this.delay = 0; // Current accumulated delay
|
||||
this.tts = null; // TTS module reference
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
@@ -33,22 +32,23 @@ class AnimationQueueModule extends BaseModule {
|
||||
'beginFastForward',
|
||||
'endFastForward',
|
||||
'emitAnimationComplete',
|
||||
'cleanupStaleTasks',
|
||||
'isAnyTtsSpeaking'
|
||||
'cleanupStaleTasks'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(20, "Initializing Animation Queue");
|
||||
|
||||
// Try to get the TTS module, but it's not a hard dependency
|
||||
// We'll check for it again at runtime when needed
|
||||
this.tts = this.getModule('tts-player');
|
||||
if (!this.tts) {
|
||||
console.log("Animation Queue: TTS Player module not found yet, will try again when needed");
|
||||
}
|
||||
|
||||
|
||||
// Listen for speed changes from UI
|
||||
document.addEventListener('animation:speed:change', (event) => {
|
||||
if (event.detail && typeof event.detail.speed === 'number') {
|
||||
// Speed from UI is a rate multiplier (0.5-2.0 typically)
|
||||
this.config.speed = event.detail.speed;
|
||||
console.log(`AnimationQueue: Speed updated to ${this.config.speed}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.reportProgress(100, "Animation Queue ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -76,29 +76,6 @@ class AnimationQueueModule extends BaseModule {
|
||||
// Record the delay for tracking
|
||||
this.delay = Math.max(this.delay, delay);
|
||||
|
||||
// If we don't have a reference to the TTS module yet, try to get it
|
||||
if (!this.tts) {
|
||||
this.tts = this.getModule('tts-player');
|
||||
}
|
||||
|
||||
// Handle TTS if text is provided and TTS is available and enabled
|
||||
let ttsSpeaking = false;
|
||||
if (options.text && this.tts && typeof this.tts.isEnabled === 'function' && this.tts.isEnabled()) {
|
||||
// If we're fast forwarding, don't speak
|
||||
if (!this.config.fastForwardEnabled) {
|
||||
ttsSpeaking = true;
|
||||
// Request TTS to speak the text
|
||||
this.tts.speak(options.text, (result) => {
|
||||
ttsSpeaking = false;
|
||||
|
||||
// Check if this was keeping the queue busy
|
||||
if (this.timeoutQueue.length === 0) {
|
||||
this.emitAnimationComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create a timeout object
|
||||
const timeoutObject = {
|
||||
func: func,
|
||||
@@ -106,8 +83,7 @@ class AnimationQueueModule extends BaseModule {
|
||||
timeoutId: null,
|
||||
executed: false,
|
||||
startTime: Date.now(),
|
||||
ttsSpeaking: ttsSpeaking,
|
||||
|
||||
|
||||
// Add an execute method that marks the timeout as executed
|
||||
execute: function() {
|
||||
if (!this.executed) {
|
||||
@@ -138,7 +114,7 @@ class AnimationQueueModule extends BaseModule {
|
||||
}
|
||||
|
||||
// If queue is empty and no TTS is speaking, emit animation complete
|
||||
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
|
||||
if (this.timeoutQueue.length === 0 && true) {
|
||||
this.emitAnimationComplete();
|
||||
}
|
||||
|
||||
@@ -152,7 +128,7 @@ class AnimationQueueModule extends BaseModule {
|
||||
*/
|
||||
emitAnimationComplete() {
|
||||
// Only emit if queue is empty and no TTS is speaking
|
||||
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
|
||||
if (this.timeoutQueue.length === 0 && true) {
|
||||
// Use parent's dispatchEvent method
|
||||
this.dispatchEvent('ui:animation:complete', {
|
||||
timestamp: Date.now()
|
||||
@@ -197,45 +173,16 @@ class AnimationQueueModule extends BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any TTS is currently speaking
|
||||
* @returns {boolean} - True if TTS is speaking
|
||||
*/
|
||||
isAnyTtsSpeaking() {
|
||||
// If we don't have a reference to the TTS module yet, try to get it
|
||||
if (!this.tts) {
|
||||
this.tts = this.getModule('tts-player');
|
||||
}
|
||||
|
||||
// Check if TTS is speaking
|
||||
if (this.tts && typeof this.tts.isSpeaking === 'function') {
|
||||
return this.tts.isSpeaking();
|
||||
}
|
||||
|
||||
// Default to false if TTS module is not available
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast forward all pending animations and stop TTS
|
||||
* Fast forward all pending animations
|
||||
*/
|
||||
fastForward() {
|
||||
if (this.timeoutQueue.length === 0) {
|
||||
console.log('AnimationQueue: No animations to fast forward');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log(`AnimationQueue: Fast forwarding ${this.timeoutQueue.length} pending items`);
|
||||
|
||||
// If we don't have a reference to the TTS module yet, try to get it
|
||||
if (!this.tts) {
|
||||
this.tts = this.getModule('tts-player');
|
||||
}
|
||||
|
||||
// Stop any active TTS
|
||||
if (this.tts && typeof this.tts.stop === 'function') {
|
||||
this.tts.stop();
|
||||
}
|
||||
|
||||
// Execute all pending animations immediately
|
||||
this.timeoutQueue.forEach(timeout => {
|
||||
// Clear the timeout
|
||||
@@ -275,23 +222,13 @@ class AnimationQueueModule extends BaseModule {
|
||||
*/
|
||||
beginFastForward() {
|
||||
if (this.config.fastForwardEnabled) return;
|
||||
|
||||
|
||||
// Update config using parent's updateConfig method
|
||||
this.updateConfig({ fastForwardEnabled: true });
|
||||
|
||||
// If we don't have a reference to the TTS module yet, try to get it
|
||||
if (!this.tts) {
|
||||
this.tts = this.getModule('tts-player');
|
||||
}
|
||||
|
||||
// Stop any active TTS
|
||||
if (this.tts && typeof this.tts.stop === 'function') {
|
||||
this.tts.stop();
|
||||
}
|
||||
|
||||
|
||||
// Use parent's dispatchEvent method
|
||||
this.dispatchEvent('ui:animation:fastforward', { state: true });
|
||||
|
||||
|
||||
console.log('AnimationQueue: Fast forward mode activated');
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
|
||||
// State
|
||||
this.currentAudio = null;
|
||||
this.currentPlaybackFinish = null;
|
||||
|
||||
// Bind additional methods
|
||||
this.bindMethods([
|
||||
@@ -33,7 +34,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
'selectVoiceForLocale',
|
||||
'selectDefaultVoice',
|
||||
'generateSpeechAudio',
|
||||
'preprocessText'
|
||||
'preprocessText',
|
||||
'getPlaybackVolume',
|
||||
'applyCurrentVolume'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -77,6 +80,16 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
// Set up event listeners for API key and URL changes
|
||||
document.addEventListener('tts:api:keyChanged', this.handleApiKeyChanged);
|
||||
document.addEventListener('tts:api:urlChanged', this.handleApiUrlChanged);
|
||||
this.addEventListener(document, 'preference-updated', (event) => {
|
||||
const { category, key } = event.detail || {};
|
||||
if (category !== 'audio') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (['masterVolume', 'ttsVolume', 'master_volume', 'tts_volume'].includes(key)) {
|
||||
this.applyCurrentVolume();
|
||||
}
|
||||
});
|
||||
|
||||
// Load voices
|
||||
await this.loadVoices();
|
||||
@@ -90,9 +103,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
this.isReady = !!this.apiKey;
|
||||
|
||||
if (!this.isReady) {
|
||||
console.error(`${this.name}: Missing API key, initialization failed`);
|
||||
this.reportProgress(100, `${this.name} initialization failed - missing API key`);
|
||||
return false; // Properly report failure when API key is missing
|
||||
console.info(`${this.name}: API key not configured; provider unavailable until configured`);
|
||||
this.reportProgress(100, `${this.name} not configured`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only mark as complete if we have an API key
|
||||
@@ -190,49 +203,106 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
* Speak preloaded audio data
|
||||
* @param {Object} preloadData - Preloaded audio data
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
* @returns {Promise<Object>} - Resolves when audio finishes playing
|
||||
*/
|
||||
speakPreloaded(preloadData, callback = null) {
|
||||
async speakPreloaded(preloadData, callback = null) {
|
||||
if (!preloadData || !preloadData.audioData) {
|
||||
console.error(`${this.name}: Invalid preloaded data`);
|
||||
if (callback) callback({ success: false, reason: 'invalid_data' });
|
||||
return false;
|
||||
const result = { success: false, reason: 'invalid_data' };
|
||||
if (callback) callback(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Create an audio element to play the audio
|
||||
const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' });
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
|
||||
// Set up state
|
||||
this.isSpeaking = true;
|
||||
this.currentAudio = audio;
|
||||
|
||||
// Set up event handlers
|
||||
audio.onended = () => {
|
||||
this.isSpeaking = false;
|
||||
this.currentAudio = null;
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
|
||||
if (callback) callback({ success: true });
|
||||
};
|
||||
|
||||
audio.onerror = (error) => {
|
||||
console.error(`${this.name}: Audio playback error:`, error);
|
||||
this.isSpeaking = false;
|
||||
this.currentAudio = null;
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
|
||||
if (callback) callback({ success: false, reason: 'playback_error', error });
|
||||
};
|
||||
|
||||
// Play the audio
|
||||
audio.play().catch(error => {
|
||||
console.error(`${this.name}: Failed to play audio:`, error);
|
||||
if (callback) callback({ success: false, reason: 'playback_error', error });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Create an audio element to play the audio
|
||||
const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' });
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
let settled = false;
|
||||
|
||||
audio.volume = this.getPlaybackVolume();
|
||||
console.log(`${this.name}: Playback volume set to ${audio.volume.toFixed(2)}`);
|
||||
|
||||
// Set up state
|
||||
this.isSpeaking = true;
|
||||
this.currentAudio = audio;
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
this.isSpeaking = false;
|
||||
if (this.currentAudio === audio) {
|
||||
this.currentAudio = null;
|
||||
}
|
||||
if (this.currentPlaybackFinish === finish) {
|
||||
this.currentPlaybackFinish = null;
|
||||
}
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
|
||||
if (callback) callback(result);
|
||||
resolve(result);
|
||||
};
|
||||
this.currentPlaybackFinish = finish;
|
||||
|
||||
// Set up event handlers
|
||||
audio.onended = () => {
|
||||
finish({ success: true });
|
||||
};
|
||||
|
||||
audio.onerror = (error) => {
|
||||
console.error(`${this.name}: Audio playback error:`, error);
|
||||
finish({ success: false, reason: 'playback_error', error });
|
||||
};
|
||||
|
||||
// Play the audio
|
||||
audio.play().then(() => {
|
||||
document.dispatchEvent(new CustomEvent('tts:audio-started', {
|
||||
detail: { provider: this.id || this.name }
|
||||
}));
|
||||
}).catch(error => {
|
||||
console.error(`${this.name}: Failed to play audio:`, error);
|
||||
finish({ success: false, reason: 'playback_error', error });
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current effective TTS playback volume.
|
||||
* @returns {number} Volume from 0 to 1.
|
||||
*/
|
||||
getPlaybackVolume() {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (!persistenceManager) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const masterVolume = persistenceManager.getPreference(
|
||||
'audio',
|
||||
'masterVolume',
|
||||
persistenceManager.getPreference('audio', 'master_volume', 1.0)
|
||||
);
|
||||
const ttsVolume = persistenceManager.getPreference(
|
||||
'audio',
|
||||
'ttsVolume',
|
||||
persistenceManager.getPreference('audio', 'tts_volume', 1.0)
|
||||
);
|
||||
|
||||
return Math.max(0, Math.min(1, masterVolume * ttsVolume));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply updated volume settings to currently playing audio.
|
||||
*/
|
||||
applyCurrentVolume() {
|
||||
if (!this.currentAudio) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentAudio.volume = this.getPlaybackVolume();
|
||||
console.log(`${this.name}: Updated current playback volume to ${this.currentAudio.volume.toFixed(2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,6 +315,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
// Stop current audio
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.currentTime = 0;
|
||||
if (this.currentPlaybackFinish) {
|
||||
this.currentPlaybackFinish({ success: false, reason: 'stopped' });
|
||||
}
|
||||
|
||||
// Clean up
|
||||
this.isSpeaking = false;
|
||||
@@ -258,6 +331,33 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
}
|
||||
return true; // Already stopped
|
||||
}
|
||||
|
||||
fadeOutCurrentAudio(duration = 1000) {
|
||||
if (!this.currentAudio) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const audio = this.currentAudio;
|
||||
const startVolume = audio.volume;
|
||||
const startedAt = performance.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const tick = () => {
|
||||
const progress = Math.min(1, (performance.now() - startedAt) / duration);
|
||||
audio.volume = startVolume * (1 - progress);
|
||||
|
||||
if (progress >= 1) {
|
||||
this.stop();
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text
|
||||
@@ -290,6 +390,29 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate audio duration from audio buffer
|
||||
* @param {ArrayBuffer} audioData - Audio data buffer
|
||||
* @returns {Promise<number>} - Duration in milliseconds
|
||||
*/
|
||||
async calculateAudioDuration(audioData) {
|
||||
try {
|
||||
// Use Web Audio API to decode audio and get duration
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const audioBuffer = await audioContext.decodeAudioData(audioData.slice(0));
|
||||
const durationMs = audioBuffer.duration * 1000;
|
||||
|
||||
// Close the audio context to free resources
|
||||
await audioContext.close();
|
||||
|
||||
console.log(`${this.name}: Calculated audio duration: ${durationMs.toFixed(0)}ms`);
|
||||
return durationMs;
|
||||
} catch (error) {
|
||||
console.warn(`${this.name}: Failed to calculate audio duration:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload speech for later playback
|
||||
* @param {string} text - Text to preload
|
||||
@@ -299,20 +422,26 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
if (!this.isReady) {
|
||||
return { success: false, reason: 'not_ready' };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Generate speech
|
||||
const result = await this.generateSpeechAudio(text);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, reason: 'generation_failed' };
|
||||
}
|
||||
|
||||
|
||||
// Calculate actual audio duration if not provided
|
||||
let duration = result.duration || 0;
|
||||
if (duration === 0 && result.audioData) {
|
||||
duration = await this.calculateAudioDuration(result.audioData);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
audioData: result.audioData,
|
||||
text,
|
||||
duration: result.duration || 0
|
||||
duration: duration
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, reason: 'generation_error', error };
|
||||
@@ -366,7 +495,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
|
||||
// Save to preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
if (persistenceManager && oldKey !== newKey) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey);
|
||||
}
|
||||
|
||||
@@ -384,12 +513,16 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
// If we have a key now (and didn't before), try initializing voices
|
||||
if (this.isReady && !wasReady) {
|
||||
// Reload voices with the new API key
|
||||
this.loadVoices().then(() => {
|
||||
this.loadVoices().then((voicesLoaded) => {
|
||||
this.isReady = voicesLoaded !== false && !!this.apiKey;
|
||||
// Then set up voice from preferences
|
||||
this.setupVoiceFromPreferences().then(() => {
|
||||
console.log(`${this.name}: Successfully initialized with new API key`);
|
||||
console.log(`${this.name}: API key status: ${this.isReady ? 'ready' : 'not ready'}`);
|
||||
// Notify the factory of our readiness change
|
||||
ttsFactory.updateTTSAvailability();
|
||||
document.dispatchEvent(new CustomEvent('tts:status:updated', {
|
||||
detail: { provider: this.id, ready: this.isReady }
|
||||
}));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
@@ -415,7 +548,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
|
||||
// Save to preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
if (persistenceManager && oldUrl !== newUrl) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl);
|
||||
}
|
||||
|
||||
@@ -424,16 +557,20 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
console.log(`${this.name}: API URL changed, reinitializing`);
|
||||
|
||||
// Reload voices with the new API URL if we're ready
|
||||
this.loadVoices().then(() => {
|
||||
this.loadVoices().then((voicesLoaded) => {
|
||||
this.isReady = voicesLoaded !== false && !!this.apiKey;
|
||||
// Then set up voice from preferences
|
||||
this.setupVoiceFromPreferences().then(() => {
|
||||
console.log(`${this.name}: Successfully reinitialized with new API URL`);
|
||||
console.log(`${this.name}: API URL status: ${this.isReady ? 'ready' : 'not ready'}`);
|
||||
|
||||
// Notify the TTS factory
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.updateTTSAvailability();
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('tts:status:updated', {
|
||||
detail: { provider: this.id, ready: this.isReady }
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,12 +8,24 @@ class AudioManagerModule extends BaseModule {
|
||||
constructor() {
|
||||
super('audio-manager', 'Audio Manager');
|
||||
this.sounds = new Map();
|
||||
this.sfxCache = new Map();
|
||||
this.currentAudio = null;
|
||||
this.currentLoop = null;
|
||||
this.currentMusic = null;
|
||||
this.queuedMusic = null;
|
||||
this.masterVolume = 1.0;
|
||||
this.musicVolume = 1.0;
|
||||
this.sfxVolume = 1.0;
|
||||
this.ttsVolume = 1.0;
|
||||
this.musicDuckingFactor = 1.0;
|
||||
this.activeTtsPlaybackCount = 0;
|
||||
this.ttsQueueEmpty = true;
|
||||
this.pendingMusicPlayback = null;
|
||||
this.assetRoots = {
|
||||
images: '/images/',
|
||||
music: '/music/',
|
||||
sounds: '/sounds/'
|
||||
};
|
||||
|
||||
// Add persistence-manager as a dependency
|
||||
this.dependencies = ['persistence-manager'];
|
||||
@@ -41,6 +53,8 @@ class AudioManagerModule extends BaseModule {
|
||||
try {
|
||||
// Set up audio context if needed
|
||||
this.setupAudioContext();
|
||||
this.loadPersistedVolumes();
|
||||
this.setupEventListeners();
|
||||
|
||||
// Load some basic sound effects
|
||||
this.reportProgress(80, "Loading sound effects");
|
||||
@@ -52,6 +66,60 @@ class AudioManagerModule extends BaseModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
loadPersistedVolumes() {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (!persistenceManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.masterVolume = this.clampVolume(persistenceManager.getPreference('audio', 'masterVolume', this.masterVolume));
|
||||
this.musicVolume = this.clampVolume(persistenceManager.getPreference('audio', 'musicVolume', this.musicVolume));
|
||||
this.sfxVolume = this.clampVolume(persistenceManager.getPreference('audio', 'sfxVolume', this.sfxVolume));
|
||||
this.ttsVolume = this.clampVolume(persistenceManager.getPreference('audio', 'ttsVolume', this.ttsVolume));
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.addEventListener(document, 'story:media-cue', (event) => {
|
||||
this.handleMediaCue(event.detail || {});
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'story:media-block', (event) => {
|
||||
this.handleMediaBlock(event.detail || {});
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'preference-updated', (event) => {
|
||||
const { category, key, value } = event.detail || {};
|
||||
if (category !== 'audio') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'masterVolume') this.setMasterVolume(value);
|
||||
if (key === 'musicVolume') this.setMusicVolume(value);
|
||||
if (key === 'sfxVolume') this.setSfxVolume(value);
|
||||
if (key === 'ttsVolume') this.setTtsVolume(value);
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'tts:playback-start', () => {
|
||||
this.activeTtsPlaybackCount += 1;
|
||||
this.ttsQueueEmpty = false;
|
||||
this.duckMusicForSpeech();
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'tts:playback-end', () => {
|
||||
this.activeTtsPlaybackCount = Math.max(0, this.activeTtsPlaybackCount - 1);
|
||||
this.restoreMusicIfSpeechFinished();
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'tts:queue-empty', () => {
|
||||
this.ttsQueueEmpty = true;
|
||||
this.restoreMusicIfSpeechFinished();
|
||||
});
|
||||
|
||||
const unlock = () => this.unlockPendingAudio();
|
||||
document.addEventListener('pointerdown', unlock, { passive: true });
|
||||
document.addEventListener('keydown', unlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Web Audio API context if needed
|
||||
@@ -73,6 +141,7 @@ class AudioManagerModule extends BaseModule {
|
||||
loadSound(id, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio(url);
|
||||
audio.preload = 'auto';
|
||||
audio.addEventListener('canplaythrough', () => {
|
||||
this.sounds.set(id, audio);
|
||||
resolve(audio);
|
||||
@@ -105,13 +174,15 @@ class AudioManagerModule extends BaseModule {
|
||||
audio.loop = true;
|
||||
this.currentLoop = audio;
|
||||
} else {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.currentTime = 0;
|
||||
}
|
||||
this.currentAudio = audio;
|
||||
this.currentAudio = audio.cloneNode(true);
|
||||
this.currentAudio.volume = this.getSfxVolume();
|
||||
this.currentAudio.play().catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
});
|
||||
return this.currentAudio;
|
||||
}
|
||||
|
||||
audio.volume = this.getMusicVolume();
|
||||
audio.play().catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
});
|
||||
@@ -134,17 +205,14 @@ class AudioManagerModule extends BaseModule {
|
||||
}
|
||||
this.currentLoop = new Audio(url);
|
||||
this.currentLoop.loop = true;
|
||||
this.currentLoop.volume = this.getMusicVolume();
|
||||
this.currentLoop.play().catch(error => {
|
||||
console.error('Error playing audio loop:', error);
|
||||
});
|
||||
return this.currentLoop;
|
||||
} else {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.removeAttribute('src');
|
||||
this.currentAudio.load();
|
||||
}
|
||||
this.currentAudio = new Audio(url);
|
||||
this.currentAudio.volume = this.getSfxVolume();
|
||||
this.currentAudio.play().catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
});
|
||||
@@ -191,7 +259,7 @@ class AudioManagerModule extends BaseModule {
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setMasterVolume(volume) {
|
||||
this.masterVolume = Math.max(0, Math.min(1, volume));
|
||||
this.masterVolume = this.clampVolume(volume);
|
||||
this.updateVolumes();
|
||||
}
|
||||
|
||||
@@ -200,11 +268,7 @@ class AudioManagerModule extends BaseModule {
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setTtsVolume(volume) {
|
||||
this.ttsVolume = Math.max(0, Math.min(1, volume));
|
||||
// Apply to current non-loop audio if it exists
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.volume = this.masterVolume * this.ttsVolume;
|
||||
}
|
||||
this.ttsVolume = this.clampVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,11 +276,8 @@ class AudioManagerModule extends BaseModule {
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setMusicVolume(volume) {
|
||||
this.musicVolume = Math.max(0, Math.min(1, volume));
|
||||
// Apply to current loop if it exists
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.volume = this.masterVolume * this.musicVolume;
|
||||
}
|
||||
this.musicVolume = this.clampVolume(volume);
|
||||
this.updateVolumes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,11 +285,8 @@ class AudioManagerModule extends BaseModule {
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setSfxVolume(volume) {
|
||||
this.sfxVolume = Math.max(0, Math.min(1, volume));
|
||||
// Apply to current non-loop audio if it exists
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.volume = this.masterVolume * this.sfxVolume;
|
||||
}
|
||||
this.sfxVolume = this.clampVolume(volume);
|
||||
this.updateVolumes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,6 +305,258 @@ class AudioManagerModule extends BaseModule {
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.volume = this.masterVolume * this.musicVolume;
|
||||
}
|
||||
|
||||
if (this.currentMusic) {
|
||||
this.currentMusic.volume = this.getMusicVolume();
|
||||
}
|
||||
}
|
||||
|
||||
clampVolume(volume) {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(Number(volume)) ? Number(volume) : 1));
|
||||
}
|
||||
|
||||
getSfxVolume() {
|
||||
return this.masterVolume * this.sfxVolume;
|
||||
}
|
||||
|
||||
getMusicVolume() {
|
||||
return this.masterVolume * this.musicVolume * this.musicDuckingFactor;
|
||||
}
|
||||
|
||||
getUnduckedMusicVolume() {
|
||||
return this.masterVolume * this.musicVolume;
|
||||
}
|
||||
|
||||
duckMusicForSpeech() {
|
||||
console.log('AudioManager: Ducking music for TTS playback');
|
||||
this.fadeMusicTo(0.7, 500);
|
||||
}
|
||||
|
||||
restoreMusicAfterSpeech() {
|
||||
console.log('AudioManager: Restoring music after TTS queue drained');
|
||||
this.fadeMusicTo(1.0, 900);
|
||||
}
|
||||
|
||||
restoreMusicIfSpeechFinished() {
|
||||
if (this.activeTtsPlaybackCount === 0 && this.ttsQueueEmpty) {
|
||||
this.restoreMusicAfterSpeech();
|
||||
}
|
||||
}
|
||||
|
||||
fadeMusicTo(factor, duration = 700) {
|
||||
this.musicDuckingFactor = Math.max(0, Math.min(1, factor));
|
||||
if (!this.currentMusic) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = this.currentMusic;
|
||||
const startVolume = audio.volume;
|
||||
const targetVolume = this.getUnduckedMusicVolume() * this.musicDuckingFactor;
|
||||
const start = performance.now();
|
||||
|
||||
const tick = () => {
|
||||
const progress = Math.min(1, (performance.now() - start) / duration);
|
||||
audio.volume = startVolume + ((targetVolume - startVolume) * progress);
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
}
|
||||
|
||||
getAssetUrl(kind, filename) {
|
||||
const root = this.assetRoots[kind];
|
||||
if (!root) {
|
||||
throw new Error(`Unknown audio asset kind: ${kind}`);
|
||||
}
|
||||
|
||||
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
|
||||
throw new Error(`Unsafe asset filename: ${filename}`);
|
||||
}
|
||||
|
||||
return root + safeName.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
async preloadSfx(filename) {
|
||||
const url = this.getAssetUrl('sounds', filename);
|
||||
if (this.sfxCache.has(url)) {
|
||||
return this.sfxCache.get(url);
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const audio = new Audio(url);
|
||||
audio.preload = 'auto';
|
||||
audio.volume = this.getSfxVolume();
|
||||
audio.addEventListener('canplaythrough', () => resolve(audio), { once: true });
|
||||
audio.addEventListener('error', () => reject(new Error(`Failed to preload sound effect: ${url}`)), { once: true });
|
||||
audio.load();
|
||||
});
|
||||
|
||||
this.sfxCache.set(url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async preloadMediaCues(cues = []) {
|
||||
const tasks = cues
|
||||
.filter(cue => cue && cue.type === 'sfx' && cue.filename)
|
||||
.map(cue => this.preloadSfx(cue.filename).catch(error => {
|
||||
console.warn('AudioManager: SFX preload failed:', error);
|
||||
return null;
|
||||
}));
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
handleMediaCue(cue) {
|
||||
if (!cue || !cue.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue.type === 'sfx') {
|
||||
this.playSfx(cue.filename);
|
||||
} else if (cue.type === 'music') {
|
||||
this.playMusic(cue.filename, cue.mode || 'crossfade', { loop: cue.loop !== false });
|
||||
}
|
||||
}
|
||||
|
||||
handleMediaBlock(block) {
|
||||
if (!block || block.type !== 'music') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playMusic(block.filename, block.mode || 'crossfade', { loop: block.loop !== false });
|
||||
}
|
||||
|
||||
async playSfx(filename) {
|
||||
try {
|
||||
const template = await this.preloadSfx(filename);
|
||||
const audio = template.cloneNode(true);
|
||||
audio.volume = this.getSfxVolume();
|
||||
this.currentAudio = audio;
|
||||
audio.addEventListener('ended', () => {
|
||||
if (this.currentAudio === audio) {
|
||||
this.currentAudio = null;
|
||||
}
|
||||
}, { once: true });
|
||||
await audio.play();
|
||||
console.log(`AudioManager: Playing sound effect ${filename}`);
|
||||
return audio;
|
||||
} catch (error) {
|
||||
console.error('AudioManager: Failed to play sound effect:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async playMusic(filename, mode = 'crossfade', options = {}) {
|
||||
const url = this.getAssetUrl('music', filename);
|
||||
const shouldLoop = options.loop !== false;
|
||||
|
||||
if (mode === 'queue' && this.currentMusic && !this.currentMusic.paused) {
|
||||
this.queuedMusic = { filename, mode: 'cut', options: { loop: shouldLoop } };
|
||||
this.currentMusic.addEventListener('ended', () => {
|
||||
const queued = this.queuedMusic;
|
||||
this.queuedMusic = null;
|
||||
if (queued) this.playMusic(queued.filename, queued.mode, queued.options);
|
||||
}, { once: true });
|
||||
console.log(`AudioManager: Queued music ${filename}`);
|
||||
return this.currentMusic;
|
||||
}
|
||||
|
||||
const next = new Audio(url);
|
||||
next.loop = shouldLoop;
|
||||
next.volume = mode === 'crossfade' && this.currentMusic ? 0 : this.getMusicVolume();
|
||||
next.addEventListener('ended', () => {
|
||||
if (this.currentMusic === next) {
|
||||
this.currentMusic = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (mode === 'cut' || !this.currentMusic) {
|
||||
this.stopCurrentMusic();
|
||||
this.currentMusic = next;
|
||||
await this.startMusicAudio(next, filename);
|
||||
return next;
|
||||
}
|
||||
|
||||
const previous = this.currentMusic;
|
||||
this.currentMusic = next;
|
||||
await this.startMusicAudio(next, filename);
|
||||
this.crossfade(previous, next, 1500);
|
||||
console.log(`AudioManager: Crossfading music to ${filename}`);
|
||||
return next;
|
||||
}
|
||||
|
||||
async startMusicAudio(audio, filename) {
|
||||
try {
|
||||
await audio.play();
|
||||
console.log(`AudioManager: Playing music ${filename}`);
|
||||
} catch (error) {
|
||||
this.pendingMusicPlayback = { audio, filename };
|
||||
console.warn('AudioManager: Music playback is waiting for user interaction:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async unlockPendingAudio() {
|
||||
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||
try {
|
||||
await this.audioContext.resume();
|
||||
} catch (error) {
|
||||
console.warn('AudioManager: Failed to resume audio context:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.pendingMusicPlayback) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pendingMusicPlayback;
|
||||
this.pendingMusicPlayback = null;
|
||||
pending.audio.volume = this.getMusicVolume();
|
||||
|
||||
try {
|
||||
await pending.audio.play();
|
||||
console.log(`AudioManager: Resumed pending music ${pending.filename}`);
|
||||
} catch (error) {
|
||||
this.pendingMusicPlayback = pending;
|
||||
console.warn('AudioManager: Pending music still blocked:', error);
|
||||
}
|
||||
}
|
||||
|
||||
stopCurrentMusic() {
|
||||
if (!this.currentMusic) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentMusic.pause();
|
||||
this.currentMusic.currentTime = 0;
|
||||
this.currentMusic = null;
|
||||
}
|
||||
|
||||
crossfade(previous, next, duration = 1500) {
|
||||
const start = performance.now();
|
||||
const previousStart = previous ? previous.volume : 0;
|
||||
const target = this.getMusicVolume();
|
||||
|
||||
const tick = () => {
|
||||
const progress = Math.min(1, (performance.now() - start) / duration);
|
||||
if (previous) previous.volume = previousStart * (1 - progress);
|
||||
next.volume = target * progress;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
if (previous) {
|
||||
previous.pause();
|
||||
previous.currentTime = 0;
|
||||
}
|
||||
next.volume = this.getMusicVolume();
|
||||
};
|
||||
|
||||
tick();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -248,6 +248,9 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
||||
this.utteranceHandlers = {
|
||||
start: () => {
|
||||
this.isSpeaking = true;
|
||||
document.dispatchEvent(new CustomEvent('tts:audio-started', {
|
||||
detail: { provider: this.id || this.name }
|
||||
}));
|
||||
},
|
||||
end: () => {
|
||||
this.isSpeaking = false;
|
||||
@@ -443,13 +446,8 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
||||
}
|
||||
|
||||
if (typeof options.speed === 'number') {
|
||||
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
|
||||
|
||||
// Save speed preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'browser_speed', options.speed);
|
||||
}
|
||||
// Web Speech rate uses 1.0 as normal, matching the app-wide slider.
|
||||
this.voiceOptions.speed = Math.max(0.1, Math.min(10.0, options.speed));
|
||||
}
|
||||
|
||||
if (typeof options.pitch === 'number') {
|
||||
|
||||
@@ -9,7 +9,7 @@ export class DebugUtilsModule extends BaseModule {
|
||||
super('debug-utils', 'Debug Utilities');
|
||||
|
||||
// Declare dependencies explicitly
|
||||
this.dependencies = ['text-buffer', 'socket-client', 'tts-player', 'ui-controller', 'game-loop'];
|
||||
this.dependencies = ['text-buffer', 'socket-client', 'tts-factory', 'ui-controller', 'game-loop'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,31 +83,27 @@ export class DebugUtilsModule extends BaseModule {
|
||||
*/
|
||||
testTTS(text = "This is a test of the text to speech system.") {
|
||||
console.log("Debug: Testing TTS with:", text);
|
||||
|
||||
// Get the TTS module properly through dependency system
|
||||
const ttsPlayer = this.getModule('tts-player');
|
||||
if (!ttsPlayer) {
|
||||
console.error("Debug: TTS module not found");
|
||||
|
||||
// Get the TTS factory properly through dependency system
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (!ttsFactory) {
|
||||
console.error("Debug: TTS factory not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
const wasEnabled = ttsPlayer.isEnabled();
|
||||
|
||||
// Enable TTS temporarily if it was disabled
|
||||
if (!wasEnabled && ttsPlayer.toggle) {
|
||||
ttsPlayer.toggle();
|
||||
|
||||
const activeHandler = ttsFactory.getActiveHandler();
|
||||
if (!activeHandler) {
|
||||
console.error("Debug: No active TTS handler");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Speak the text
|
||||
ttsPlayer.speak(text, (result) => {
|
||||
ttsFactory.speak(text).then((result) => {
|
||||
console.log("Debug: TTS completed with result:", result);
|
||||
|
||||
// Restore previous enabled state
|
||||
if (!wasEnabled && ttsPlayer.toggle) {
|
||||
ttsPlayer.toggle();
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error("Debug: TTS error:", error);
|
||||
});
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -123,10 +119,10 @@ export class DebugUtilsModule extends BaseModule {
|
||||
const socketClient = this.getModule('socket-client');
|
||||
const gameLoop = this.getModule('game-loop');
|
||||
const textBuffer = this.getModule('text-buffer');
|
||||
const ttsHandler = this.getModule('tts-player');
|
||||
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
|
||||
// Check if all modules are available
|
||||
if (!uiController || !socketClient || !gameLoop || !textBuffer || !ttsHandler) {
|
||||
if (!uiController || !socketClient || !gameLoop || !textBuffer || !ttsFactory) {
|
||||
console.error("Debug: One or more required modules not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -41,17 +41,25 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
// API key is already loaded in parent initialize() method
|
||||
// Just check if it's available
|
||||
if (!this.apiKey) {
|
||||
console.error('ElevenLabs TTS: API key not configured');
|
||||
return false;
|
||||
console.info('ElevenLabs TTS: API key not configured; provider unavailable until configured');
|
||||
this.isReady = false;
|
||||
this.reportProgress(100, 'ElevenLabs TTS not configured');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Load voices from ElevenLabs
|
||||
try {
|
||||
this.reportProgress(50, 'Loading ElevenLabs voices');
|
||||
await this.loadVoices(this.apiKey);
|
||||
const voicesLoaded = await this.loadVoices(this.apiKey);
|
||||
if (!voicesLoaded) {
|
||||
this.isReady = false;
|
||||
this.reportProgress(100, 'ElevenLabs TTS not ready');
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ElevenLabs TTS: Failed to load voices:', error);
|
||||
return false;
|
||||
this.isReady = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Load preferences
|
||||
@@ -65,9 +73,9 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
this.voiceOptions.model = preferredModel;
|
||||
}
|
||||
|
||||
const preferredSpeed = persistenceManager.getPreference('tts', `${this.id}_speed`, this.voiceOptions.speed);
|
||||
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
|
||||
if (typeof preferredSpeed === 'number') {
|
||||
this.voiceOptions.speed = preferredSpeed;
|
||||
this.voiceOptions.speed = this.getApiSpeed(preferredSpeed);
|
||||
}
|
||||
|
||||
this.isReady = true;
|
||||
@@ -119,7 +127,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`ElevenLabs TTS: API error ${response.status} ${response.statusText}`);
|
||||
return true; // Continue with default voices
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -136,10 +144,10 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true; // Continue with default voices
|
||||
return this.voices.length > 0;
|
||||
} catch (error) {
|
||||
console.error('ElevenLabs TTS: Error loading voices:', error);
|
||||
return true; // Continue with default voices
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +196,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
similarity_boost: 0.75,
|
||||
style: 0.0,
|
||||
use_speaker_boost: true,
|
||||
speed: this.voiceOptions.speed || 1.0
|
||||
speed: this.getApiSpeed(this.voiceOptions.speed)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,13 +206,15 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': this.apiKey,
|
||||
'Accept': 'audio/wav'
|
||||
'Accept': 'audio/mpeg'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
const errorText = await response.text();
|
||||
console.error(`ElevenLabs API error ${response.status}: ${errorText}`);
|
||||
throw new Error(`API error: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
|
||||
// Get audio blob from response
|
||||
@@ -244,7 +254,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
}
|
||||
|
||||
if (typeof options.speed === 'number') {
|
||||
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
|
||||
this.voiceOptions.speed = this.getApiSpeed(options.speed);
|
||||
}
|
||||
|
||||
// Handle ElevenLabs-specific options
|
||||
@@ -258,6 +268,10 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getApiSpeed(speed) {
|
||||
return Math.max(0.7, Math.min(1.2, Number.isFinite(speed) ? speed : 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
const elevenLabsTTSModule = new ElevenLabsTTSModule();
|
||||
|
||||
+100
-20
@@ -9,7 +9,7 @@ class GameLoopModule extends BaseModule {
|
||||
super('game-loop', 'Game Loop');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['ui-controller', 'socket-client', 'tts-player', 'text-buffer'];
|
||||
this.dependencies = ['ui-controller', 'socket-client', 'text-buffer'];
|
||||
|
||||
// Game state
|
||||
this.gameState = {
|
||||
@@ -25,9 +25,11 @@ class GameLoopModule extends BaseModule {
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'start',
|
||||
'setupUiEventListeners',
|
||||
'setupSocketEventListeners',
|
||||
'updateGameState',
|
||||
'updateUIState',
|
||||
'refreshGameApiState',
|
||||
'requestStartGame',
|
||||
'requestSaveGame',
|
||||
'requestLoadGame',
|
||||
@@ -46,6 +48,7 @@ class GameLoopModule extends BaseModule {
|
||||
try {
|
||||
// The dependencies are now automatically available via getModule
|
||||
this.updateUIState();
|
||||
this.setupUiEventListeners();
|
||||
|
||||
console.log("GameLoop: Setting up socket listeners and connecting...");
|
||||
|
||||
@@ -58,6 +61,15 @@ class GameLoopModule extends BaseModule {
|
||||
console.error("Error starting game loop:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setupUiEventListeners() {
|
||||
if (this.uiEventsBound) return;
|
||||
this.uiEventsBound = true;
|
||||
|
||||
document.addEventListener('ui:game:restart', () => this.requestStartGame());
|
||||
document.addEventListener('ui:game:save', () => this.requestSaveGame());
|
||||
document.addEventListener('ui:game:load', () => this.requestLoadGame());
|
||||
}
|
||||
|
||||
setupSocketEventListeners() {
|
||||
// Get the socket client module using parent's getModule method
|
||||
@@ -81,14 +93,7 @@ class GameLoopModule extends BaseModule {
|
||||
socketClient.on('connect', () => {
|
||||
console.log("GameLoop: Socket connected event received.");
|
||||
|
||||
// Request a new game start when we connect
|
||||
// Only request start game if one isn't already in progress
|
||||
if (!this.gameState.started) {
|
||||
console.log("GameLoop: Requesting start game on connect.");
|
||||
this.requestStartGame();
|
||||
} else {
|
||||
console.log("GameLoop: Game already started, skipping duplicate start request.");
|
||||
}
|
||||
this.refreshGameApiState();
|
||||
});
|
||||
|
||||
// Listen for game state updates
|
||||
@@ -107,9 +112,22 @@ class GameLoopModule extends BaseModule {
|
||||
socketClient.on('gameIntroduction', (data) => {
|
||||
console.log("GameLoop: Received gameIntroduction");
|
||||
this.gameState.started = true;
|
||||
this.gameState.canSave = true;
|
||||
this.updateUIState();
|
||||
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
|
||||
});
|
||||
|
||||
socketClient.on('gameSaved', () => {
|
||||
this.gameState.canLoad = true;
|
||||
this.updateUIState();
|
||||
});
|
||||
|
||||
socketClient.on('gameLoaded', () => {
|
||||
this.gameState.started = true;
|
||||
this.gameState.canSave = true;
|
||||
this.gameState.canLoad = true;
|
||||
this.updateUIState();
|
||||
});
|
||||
|
||||
// Connect to the socket server
|
||||
socketClient.connect().then(success => {
|
||||
@@ -120,6 +138,21 @@ class GameLoopModule extends BaseModule {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async refreshGameApiState() {
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient || !socketClient.getConnectionStatus()) return;
|
||||
|
||||
const [running, hasSave] = await Promise.all([
|
||||
socketClient.isGameRunning(),
|
||||
socketClient.hasSaveGame(1)
|
||||
]);
|
||||
|
||||
this.gameState.started = Boolean(running?.result);
|
||||
this.gameState.canSave = this.gameState.started;
|
||||
this.gameState.canLoad = Boolean(hasSave?.result);
|
||||
this.updateUIState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the game state
|
||||
@@ -149,38 +182,85 @@ class GameLoopModule extends BaseModule {
|
||||
if (!uiController) return;
|
||||
|
||||
// Update UI components based on game state
|
||||
uiController.updateButtonStates(this.gameState);
|
||||
const state = {
|
||||
canRestart: true,
|
||||
canSave: Boolean(this.gameState.started),
|
||||
canLoad: Boolean(this.gameState.canLoad),
|
||||
gameStarted: Boolean(this.gameState.started)
|
||||
};
|
||||
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
|
||||
uiController.updateButtonStates(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to start a new game
|
||||
*/
|
||||
requestStartGame() {
|
||||
async requestStartGame() {
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
|
||||
socketClient.requestStartGame();
|
||||
|
||||
const uiController = this.getModule('ui-controller');
|
||||
if (uiController) {
|
||||
uiController.clearDisplay();
|
||||
}
|
||||
const textBuffer = this.getModule('text-buffer');
|
||||
if (textBuffer && typeof textBuffer.clear === 'function') {
|
||||
textBuffer.clear();
|
||||
}
|
||||
const response = await socketClient.newGame();
|
||||
if (!response?.success) {
|
||||
console.error('GameLoop: newGame failed', response);
|
||||
return;
|
||||
}
|
||||
this.gameState.started = true;
|
||||
this.gameState.canSave = true;
|
||||
this.gameState.canLoad = Boolean(response.canLoad);
|
||||
this.updateUIState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to save the current game
|
||||
*/
|
||||
requestSaveGame() {
|
||||
async requestSaveGame() {
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
|
||||
socketClient.requestSaveGame();
|
||||
if (!socketClient || !this.gameState.started) return;
|
||||
|
||||
const response = await socketClient.saveGame(1);
|
||||
if (response?.success) {
|
||||
this.gameState.canLoad = true;
|
||||
this.updateUIState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to load a saved game
|
||||
*/
|
||||
requestLoadGame() {
|
||||
async requestLoadGame() {
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
|
||||
socketClient.requestLoadGame();
|
||||
|
||||
const hasSave = await socketClient.hasSaveGame(1);
|
||||
if (!hasSave?.result) {
|
||||
this.gameState.canLoad = false;
|
||||
this.updateUIState();
|
||||
return;
|
||||
}
|
||||
|
||||
const uiController = this.getModule('ui-controller');
|
||||
if (uiController) {
|
||||
uiController.clearDisplay();
|
||||
}
|
||||
const textBuffer = this.getModule('text-buffer');
|
||||
if (textBuffer && typeof textBuffer.clear === 'function') {
|
||||
textBuffer.clear();
|
||||
}
|
||||
const response = await socketClient.loadGame(1);
|
||||
if (response?.success) {
|
||||
this.gameState.started = true;
|
||||
this.gameState.canSave = true;
|
||||
this.gameState.canLoad = true;
|
||||
this.updateUIState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function kap(text, measureText, measure, hyphenation) {
|
||||
function kap(text, measureText, measure, hyphenation) {
|
||||
console.log("Typesetting hyphenated text:", text, measure);
|
||||
if (!hyphenation) {
|
||||
text = text.replace(/\|/g, '');
|
||||
@@ -48,9 +48,9 @@ function kap(text, measureText, measure, hyphenation) {
|
||||
|
||||
let breaks = linebreak(nodes, measure, { tolerance: 3, demerits });
|
||||
|
||||
if (!breaks.length) {
|
||||
breaks = linebreak(nodes, measure, { tolerance: 10, demerits });
|
||||
}
|
||||
|
||||
return { nodes, breaks };
|
||||
}
|
||||
if (!breaks.length) {
|
||||
breaks = linebreak(nodes, measure, { tolerance: 10, demerits });
|
||||
}
|
||||
|
||||
return { nodes, breaks };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
||||
this.lastProgressTime = null;
|
||||
this.lastProgressValue = null;
|
||||
this.modelLoaded = false;
|
||||
|
||||
// Options for playback
|
||||
this.options = {
|
||||
volume: 1.0,
|
||||
rate: 1.0
|
||||
};
|
||||
|
||||
// Bind additional methods beyond those in TTSHandlerModule
|
||||
this.bindMethods([
|
||||
@@ -30,7 +36,8 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
||||
'preprocessText',
|
||||
'pause',
|
||||
'resume',
|
||||
'getDefaultVoices'
|
||||
'getDefaultVoices',
|
||||
'setVoiceOptions'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -51,6 +58,16 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
||||
console.error('Kokoro TTS: Required dependency persistence-manager not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false);
|
||||
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler', 'none');
|
||||
if (!ttsEnabled || preferredHandler !== this.id) {
|
||||
this.voices = this.getDefaultVoices();
|
||||
this.isReady = false;
|
||||
this.reportProgress(100, 'Kokoro TTS not selected');
|
||||
console.log('Kokoro TTS: Skipping model load because provider is not selected');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to check if the kokoro-js.js resource exists before proceeding
|
||||
try {
|
||||
@@ -206,9 +223,11 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
||||
if (!event.data.success || event.data.error) {
|
||||
resolver.reject(new Error(event.data.error || 'Speech generation failed'));
|
||||
} else {
|
||||
const audioData = event.data.result && event.data.result.buffer;
|
||||
console.log('Kokoro: Generation complete, audioData:', audioData ? `${audioData.byteLength} bytes` : 'UNDEFINED');
|
||||
resolver.resolve({
|
||||
success: true,
|
||||
audioData: event.data.result && event.data.result.buffer,
|
||||
audioData: audioData,
|
||||
duration: event.data.duration || 0
|
||||
});
|
||||
}
|
||||
@@ -333,6 +352,21 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice) {
|
||||
const voice = this.voices.find(v => v.id === options.voice) || { id: options.voice };
|
||||
this.setVoice(voice);
|
||||
}
|
||||
|
||||
if (typeof options.speed === 'number') {
|
||||
this.setOptions({ rate: Math.max(0.5, Math.min(2.0, options.speed)) });
|
||||
}
|
||||
|
||||
if (typeof options.volume === 'number') {
|
||||
this.setOptions({ volume: Math.max(0, Math.min(1, options.volume)) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
@@ -435,7 +469,11 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
||||
// Start playback
|
||||
this.currentAudio = audio;
|
||||
this.isSpeaking = true;
|
||||
audio.play().catch(error => {
|
||||
audio.play().then(() => {
|
||||
document.dispatchEvent(new CustomEvent('tts:audio-started', {
|
||||
detail: { provider: this.id || this.name }
|
||||
}));
|
||||
}).catch(error => {
|
||||
this.isSpeaking = false;
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'playback_error', error });
|
||||
@@ -498,7 +536,11 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
||||
// Start playback
|
||||
this.currentAudio = audio;
|
||||
this.isSpeaking = true;
|
||||
audio.play().catch(error => {
|
||||
audio.play().then(() => {
|
||||
document.dispatchEvent(new CustomEvent('tts:audio-started', {
|
||||
detail: { provider: this.id || this.name }
|
||||
}));
|
||||
}).catch(error => {
|
||||
this.isSpeaking = false;
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'playback_error', error });
|
||||
@@ -645,4 +687,4 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
||||
|
||||
const kokoroTTSModule = new KokoroTTSModule();
|
||||
|
||||
export { kokoroTTSModule };
|
||||
export { kokoroTTSModule };
|
||||
|
||||
+204
-191
@@ -9,7 +9,7 @@ class LayoutRendererModule extends BaseModule {
|
||||
super('layout-renderer', 'Layout Renderer');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['animation-queue', 'tts-player'];
|
||||
this.dependencies = [];
|
||||
|
||||
// Configuration
|
||||
this.updateConfig({
|
||||
@@ -22,8 +22,8 @@ class LayoutRendererModule extends BaseModule {
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'renderParagraph',
|
||||
'renderWord',
|
||||
'scheduleWordAnimation'
|
||||
'initializeContainers',
|
||||
'adjustJustification'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -34,14 +34,6 @@ class LayoutRendererModule extends BaseModule {
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(10, "Initializing Layout Renderer");
|
||||
|
||||
// Check for animation queue dependency
|
||||
const animationQueue = this.getModule('animation-queue');
|
||||
if (!animationQueue) {
|
||||
console.warn("Layout Renderer: Animation Queue module not found in registry");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.reportProgress(100, "Layout Renderer ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -65,199 +57,212 @@ class LayoutRendererModule extends BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a paragraph from layout data
|
||||
* @param {Object} layout - Layout data from paragraph-layout
|
||||
* Render a paragraph from layout data (pure DOM creation, no animation)
|
||||
* @param {Object} layoutData - Layout data containing breaks, nodes, and measures
|
||||
* @param {Object} options - Rendering options
|
||||
* @returns {HTMLElement} - The created paragraph element
|
||||
*/
|
||||
renderParagraph(layout, options = {}) {
|
||||
const animationQueue = this.getModule('animation-queue');
|
||||
renderParagraph(layoutData, options = {}) {
|
||||
const { id = `p-${Date.now()}` } = options;
|
||||
const { breaks, nodes, measures, fontSize, fontFamily, lineHeightPx } = layoutData;
|
||||
|
||||
const {
|
||||
container = document.getElementById('paragraphs'),
|
||||
id = `p-${Date.now()}`,
|
||||
className = '',
|
||||
style = {},
|
||||
animateWords = true,
|
||||
animationSpeed = this.config.animation.defaultSpeed,
|
||||
tts = false,
|
||||
onComplete = null
|
||||
} = options;
|
||||
|
||||
if (!layout || !layout.breaks || !layout.nodes || !container) {
|
||||
console.error('Invalid layout data or container');
|
||||
if (!breaks || !nodes) {
|
||||
console.error('LayoutRenderer: Invalid layout data');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create paragraph element
|
||||
const paragraphElement = document.createElement('p');
|
||||
paragraphElement.id = id;
|
||||
paragraphElement.className = `paragraph ${className}`.trim();
|
||||
paragraphElement.style.position = 'relative';
|
||||
|
||||
// Get line height and container width for positioning
|
||||
const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#story')).lineHeight) || 1.5;
|
||||
const containerWidth = parseFloat(window.getComputedStyle(container).width);
|
||||
|
||||
// Calculate paragraph height based on number of lines
|
||||
const numLines = layout.breaks.length - 1;
|
||||
const paragraphHeight = numLines * lineHeight;
|
||||
paragraphElement.style.height = `${paragraphHeight}em`;
|
||||
|
||||
// Apply custom style properties
|
||||
for (const prop in style) {
|
||||
paragraphElement.style[prop] = style[prop];
|
||||
|
||||
// Create paragraph container
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.id = id;
|
||||
paragraph.className = [
|
||||
layoutData.role ? `story-${layoutData.role}` : '',
|
||||
layoutData.addTopSpace ? 'story-textblock-start' : '',
|
||||
layoutData.dropCap ? 'story-dropcap-paragraph' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
paragraph.style.position = 'relative';
|
||||
paragraph.style.margin = '0';
|
||||
if (fontSize) paragraph.style.fontSize = fontSize;
|
||||
if (fontFamily) paragraph.style.fontFamily = fontFamily;
|
||||
if (Array.isArray(measures) && measures.length > 0) {
|
||||
paragraph.style.width = `${Math.max(...measures)}px`;
|
||||
paragraph.style.maxWidth = '100%';
|
||||
}
|
||||
|
||||
// Populate with words
|
||||
const wordElements = [];
|
||||
let lineIndex = 0;
|
||||
let totalDelay = 0;
|
||||
|
||||
// Calculate each word's position based on layout data
|
||||
for (let i = 0; i < layout.nodes.length; i++) {
|
||||
const wordNode = layout.nodes[i];
|
||||
|
||||
// Get the current line index from breaks array
|
||||
while (lineIndex < layout.breaks.length - 1 && i >= layout.breaks[lineIndex + 1]) {
|
||||
lineIndex++;
|
||||
}
|
||||
|
||||
// Create the word element
|
||||
const wordElement = this.renderWord(wordNode.text, animateWords);
|
||||
wordElements.push(wordElement);
|
||||
|
||||
// Position the word absolutely within paragraph
|
||||
if (wordNode.x !== undefined && wordNode.y !== undefined) {
|
||||
// Use calculated position
|
||||
wordElement.style.position = 'absolute';
|
||||
wordElement.style.left = `${wordNode.x}px`;
|
||||
wordElement.style.top = `${lineIndex * lineHeight}em`;
|
||||
} else {
|
||||
// Fallback for missing positioning data
|
||||
wordElement.style.position = 'relative';
|
||||
wordElement.style.marginRight = '0.25em';
|
||||
}
|
||||
|
||||
// Add to paragraph
|
||||
paragraphElement.appendChild(wordElement);
|
||||
|
||||
// Handle whitespace after the word
|
||||
if (wordNode.spaceAfter) {
|
||||
const spaceElement = document.createElement('span');
|
||||
spaceElement.className = 'space';
|
||||
spaceElement.innerHTML = ' ';
|
||||
|
||||
if (wordNode.x !== undefined) {
|
||||
// Position space after word
|
||||
spaceElement.style.position = 'absolute';
|
||||
const wordWidth = wordElement.offsetWidth || wordNode.width || wordNode.text.length * 8;
|
||||
spaceElement.style.left = `${wordNode.x + wordWidth}px`;
|
||||
spaceElement.style.top = `${lineIndex * lineHeight}em`;
|
||||
} else {
|
||||
spaceElement.style.position = 'relative';
|
||||
}
|
||||
|
||||
paragraphElement.appendChild(spaceElement);
|
||||
}
|
||||
|
||||
// Calculate paragraph height
|
||||
const storyElement = document.getElementById('story');
|
||||
if (!storyElement) {
|
||||
console.error('LayoutRenderer: Story container not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add the paragraph to the container
|
||||
container.appendChild(paragraphElement);
|
||||
|
||||
// Schedule animations for words if enabled
|
||||
if (animateWords && animationQueue) {
|
||||
// Schedule animations for each word with a faster timing
|
||||
const baseDelay = 0; // Starting delay
|
||||
const wordDelay = 20; // Delay between words in ms (reduced from 40)
|
||||
|
||||
wordElements.forEach((wordElement, index) => {
|
||||
const delay = baseDelay + (index * wordDelay);
|
||||
totalDelay = Math.max(totalDelay, delay);
|
||||
|
||||
this.scheduleWordAnimation(wordElement, delay, animationSpeed);
|
||||
});
|
||||
|
||||
// Schedule TTS if enabled - start it earlier in the animation sequence
|
||||
if (tts) {
|
||||
const ttsPlayer = this.getModule('tts-player');
|
||||
if (ttsPlayer) {
|
||||
// Get the full text for TTS
|
||||
const fullText = layout.originalText || layout.processedText || paragraphElement.textContent;
|
||||
|
||||
// Schedule TTS with the animation queue - start after just a few words appear
|
||||
animationQueue.schedule(() => {
|
||||
ttsPlayer.speak(fullText, (result) => {
|
||||
if (!result || !result.success) {
|
||||
console.warn('TTS playback issue:', result ? result.reason : 'unknown');
|
||||
}
|
||||
});
|
||||
}, Math.min(100, wordDelay * 3)); // Start TTS earlier
|
||||
|
||||
const lineHeight = lineHeightPx || parseFloat(window.getComputedStyle(paragraph).lineHeight) || 24;
|
||||
const maxLineWidth = Array.isArray(measures) && measures.length > 0
|
||||
? Math.max(...measures)
|
||||
: storyElement.clientWidth;
|
||||
// Height should include all lines (breaks.length represents number of lines)
|
||||
const numLines = breaks.length - 1;
|
||||
paragraph.style.height = `${lineHeight * numLines}px`;
|
||||
|
||||
console.log(`LayoutRenderer: Rendering paragraph ${id} - ${breaks.length} breaks (${numLines} lines), lineHeight: ${lineHeight}px, total height: ${lineHeight * numLines}px`);
|
||||
|
||||
// Debug: log break ratios
|
||||
breaks.forEach((brk, idx) => {
|
||||
if (idx > 0) {
|
||||
console.log(` Line ${idx - 1}: break position ${brk.position}, ratio ${brk.ratio ? brk.ratio.toFixed(3) : 'undefined'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Position words according to layout with proper justification
|
||||
let wordCount = 0;
|
||||
let lastChild = null;
|
||||
let syllable = "";
|
||||
const stack = [paragraph];
|
||||
|
||||
if (layoutData.dropCap && layoutData.dropCapText) {
|
||||
const dropCap = document.createElement('span');
|
||||
dropCap.className = 'drop-cap story-drop-cap';
|
||||
dropCap.textContent = layoutData.dropCapText;
|
||||
paragraph.appendChild(dropCap);
|
||||
}
|
||||
|
||||
for (let i = 1; i < breaks.length; i++) {
|
||||
const lineIndex = i - 1;
|
||||
const lineWidth = measures[Math.min(lineIndex, measures.length - 1)];
|
||||
const lineOffset = maxLineWidth - lineWidth;
|
||||
const currentBreak = breaks[i];
|
||||
const isFinalLine = i === breaks.length - 1;
|
||||
const ratio = isFinalLine ? 0 : (currentBreak.ratio || 0);
|
||||
|
||||
let currentLeft = 0;
|
||||
lastChild = null;
|
||||
|
||||
// Iterate through nodes on this line (break positions are inclusive)
|
||||
for (let j = breaks[i-1].position; j <= currentBreak.position; j++) {
|
||||
const node = nodes[j];
|
||||
|
||||
if (node.type === 'box' && node.value !== '' && j < currentBreak.position) {
|
||||
const followsGlue = j > 0 && nodes[j - 1].type === 'glue';
|
||||
const isTrailingPunctuation = /^[,.;:!?)]$/.test(node.value) && !followsGlue;
|
||||
|
||||
// Check if this box follows a penalty (hyphenation point)
|
||||
if (lastChild && isTrailingPunctuation) {
|
||||
syllable += node.value;
|
||||
lastChild.innerHTML = syllable;
|
||||
currentLeft += node.width;
|
||||
} else if (j > breaks[i-1].position + 1 &&
|
||||
nodes[j-1].type === 'penalty' &&
|
||||
lastChild) {
|
||||
// Combine with previous syllable using zero-width non-joiner
|
||||
syllable += '\u200c' + node.value;
|
||||
lastChild.innerHTML = syllable;
|
||||
currentLeft += node.width;
|
||||
} else {
|
||||
// Create new word span
|
||||
const word = document.createElement('span');
|
||||
word.className = 'word';
|
||||
word.style.position = 'absolute';
|
||||
word.style.display = 'inline-block';
|
||||
word.style.whiteSpace = 'nowrap';
|
||||
word.dataset.line = String(lineIndex);
|
||||
word.dataset.lineStart = String(lineOffset);
|
||||
word.dataset.lineWidth = String(lineWidth);
|
||||
|
||||
// Calculate position with proper line and justification
|
||||
const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height);
|
||||
const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth;
|
||||
|
||||
word.style.top = `${topPercent}%`;
|
||||
word.style.left = `${leftPercent}%`;
|
||||
word.style.opacity = '0'; // Hidden until animated
|
||||
word.style.visibility = 'hidden';
|
||||
syllable = node.value;
|
||||
word.innerHTML = syllable;
|
||||
lastChild = word;
|
||||
|
||||
if (wordCount < 5 || (wordCount % 20 === 0)) {
|
||||
console.log(` Word ${wordCount} "${node.value}" at line ${lineIndex}, top: ${topPercent.toFixed(1)}%, left: ${leftPercent.toFixed(1)}%`);
|
||||
}
|
||||
wordCount++;
|
||||
|
||||
stack[stack.length - 1].appendChild(word);
|
||||
currentLeft += node.width;
|
||||
}
|
||||
|
||||
} else if (node.type === 'tag') {
|
||||
if (node.value.substr(0, 2) === '</') {
|
||||
if (stack.length > 1) stack.pop();
|
||||
} else {
|
||||
const template = document.createElement('div');
|
||||
template.innerHTML = node.value;
|
||||
const tag = template.firstChild;
|
||||
if (tag) {
|
||||
tag.style.display = 'contents';
|
||||
stack[stack.length - 1].appendChild(tag);
|
||||
stack.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (node.type === 'glue' && j > breaks[i-1].position && node.width !== 0 && j <= currentBreak.position) {
|
||||
// Apply justification: adjust glue width based on line's ratio
|
||||
let adjustedWidth = node.width;
|
||||
|
||||
if (ratio > 0) {
|
||||
// Line needs stretching
|
||||
adjustedWidth = node.width + (node.stretch * ratio);
|
||||
} else if (ratio < 0) {
|
||||
// Line needs shrinking
|
||||
adjustedWidth = node.width + (node.shrink * ratio);
|
||||
}
|
||||
// If ratio === 0, line fits perfectly, use natural width
|
||||
|
||||
if (wordCount < 3) {
|
||||
// Debug first line's glue adjustments
|
||||
console.log(` Glue at position ${j}: natural=${node.width.toFixed(2)}px, adjusted=${adjustedWidth.toFixed(2)}px, ratio=${ratio.toFixed(3)}, left before: ${currentLeft.toFixed(2)}px`);
|
||||
}
|
||||
|
||||
// Increment position by the adjusted glue width
|
||||
currentLeft += adjustedWidth;
|
||||
|
||||
} else if (node.type === 'penalty' && node.penalty === 100 && j === currentBreak.position) {
|
||||
// Add hyphen at line break
|
||||
if (lastChild) {
|
||||
lastChild.innerHTML = `${lastChild.innerHTML}-`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const word = document.createElement('span');
|
||||
word.className = 'word';
|
||||
word.style.position = 'absolute';
|
||||
word.style.display = 'inline-block';
|
||||
word.style.whiteSpace = 'nowrap';
|
||||
word.dataset.line = String(lineIndex);
|
||||
word.dataset.lineStart = String(lineOffset);
|
||||
word.dataset.lineWidth = String(lineWidth);
|
||||
const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height);
|
||||
const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth;
|
||||
word.style.top = `${topPercent}%`;
|
||||
word.style.left = `${leftPercent}%`;
|
||||
word.style.opacity = '0';
|
||||
word.style.visibility = 'hidden';
|
||||
word.innerHTML = "-";
|
||||
stack[stack.length - 1].appendChild(word);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule completion callback
|
||||
if (onComplete && typeof onComplete === 'function') {
|
||||
const completionDelay = totalDelay + 200; // Reduced completion delay
|
||||
animationQueue.schedule(onComplete, completionDelay);
|
||||
}
|
||||
} else if (onComplete && typeof onComplete === 'function') {
|
||||
// If not animating, call onComplete immediately
|
||||
setTimeout(onComplete, 0);
|
||||
}
|
||||
|
||||
return paragraphElement;
|
||||
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paragraph positions are already computed from browser DOM measurements.
|
||||
* Keep this hook for callers that still invoke it, but do not reflow the
|
||||
* prototype layout after rendering.
|
||||
* @param {HTMLElement} paragraph - Rendered paragraph element
|
||||
*/
|
||||
adjustJustification(paragraph) {
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single word
|
||||
* @param {string} word - Word to render
|
||||
* @param {boolean} animate - Whether to prepare for animation
|
||||
* @returns {HTMLElement} - The created word element
|
||||
*/
|
||||
renderWord(word, animate = true) {
|
||||
const wordElement = this.createWordElement(word);
|
||||
|
||||
// Apply initial styles for animation
|
||||
if (animate) {
|
||||
wordElement.style.opacity = '0';
|
||||
wordElement.style.transform = 'translateY(5px)';
|
||||
wordElement.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
return wordElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a word element
|
||||
* @param {string} word - Word to render
|
||||
* @returns {HTMLElement} - The created word element
|
||||
*/
|
||||
createWordElement(word) {
|
||||
const wordElement = document.createElement('span');
|
||||
wordElement.className = 'word';
|
||||
wordElement.textContent = word;
|
||||
return wordElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a word animation with the animation queue
|
||||
* @param {HTMLElement} wordElement - Word element to animate
|
||||
* @param {number} delay - Delay before animation starts
|
||||
* @param {number} speed - Animation speed factor
|
||||
*/
|
||||
scheduleWordAnimation(wordElement, delay, speed) {
|
||||
const animationQueue = this.getModule('animation-queue');
|
||||
if (!animationQueue) return;
|
||||
|
||||
const actualDelay = delay * speed;
|
||||
|
||||
animationQueue.schedule(() => {
|
||||
wordElement.style.opacity = '1';
|
||||
wordElement.style.transform = 'translateY(0)';
|
||||
wordElement.style.transition = `opacity 0.2s ease-out, transform 0.3s ease-out`;
|
||||
}, actualDelay);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
@@ -265,3 +270,11 @@ const LayoutRenderer = new LayoutRendererModule();
|
||||
|
||||
// Export the module
|
||||
export { LayoutRenderer };
|
||||
|
||||
// Register with the module registry
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(LayoutRenderer);
|
||||
}
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.LayoutRenderer = LayoutRenderer;
|
||||
|
||||
+38
-13
@@ -24,6 +24,8 @@ const ModuleState = {
|
||||
ERROR: 'ERROR'
|
||||
};
|
||||
|
||||
const MODULE_CACHE_BUSTER = '20260514-new-game-click';
|
||||
|
||||
/**
|
||||
* Module Loader - Manages the loading of all modules
|
||||
*/
|
||||
@@ -61,7 +63,10 @@ const ModuleLoader = (function() {
|
||||
// Load available module scripts
|
||||
loadModuleScripts().then(() => {
|
||||
// Once scripts are loaded, initialize modules
|
||||
initializeModules();
|
||||
initializeModules().catch(error => {
|
||||
console.error('Module Loader: Initialization failed:', error);
|
||||
finalizeLoading();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,17 +103,19 @@ const ModuleLoader = (function() {
|
||||
{ id: 'persistence-manager', script: '/js/persistence-manager-module.js', weight: 12 },
|
||||
{ id: 'localization', script: '/js/localization-module.js', weight: 12 },
|
||||
{ id: 'text-processor', script: '/js/text-processor-module.js', weight: 15 },
|
||||
{ id: 'markup-parser', script: '/js/markup-parser-module.js', weight: 5 },
|
||||
{ id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
|
||||
{ id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 },
|
||||
{ id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module
|
||||
{ id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },
|
||||
|
||||
{ id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS
|
||||
|
||||
// Audio and TTS modules
|
||||
{ id: 'audio-manager', script: '/js/audio-manager-module.js', weight: 12 },
|
||||
{ id: 'kokoro', script: '/js/kokoro-tts-module.js', weight: 50 },
|
||||
{ id: 'browser', script: '/js/browser-tts-module.js', weight: 12 },
|
||||
{ id: 'elevenlabs', script: '/js/elevenlabs-tts-module.js', weight: 12 },
|
||||
{ id: 'openai', script: '/js/openai-tts-module.js', weight: 12 },
|
||||
{ id: 'kokoro-tts', script: '/js/kokoro-tts-module.js', weight: 50 },
|
||||
{ id: 'browser-tts', script: '/js/browser-tts-module.js', weight: 12 },
|
||||
{ id: 'elevenlabs-tts', script: '/js/elevenlabs-tts-module.js', weight: 12 },
|
||||
{ id: 'openai-tts', script: '/js/openai-tts-module.js', weight: 12 },
|
||||
{ id: 'tts-factory', script: '/js/tts-factory-module.js', weight: 13 }, // TTSFactory must be loaded before TTSPlayer
|
||||
|
||||
// UI and interaction modules
|
||||
@@ -310,7 +317,7 @@ const ModuleLoader = (function() {
|
||||
}
|
||||
});
|
||||
|
||||
return result.reverse(); // Reverse to get correct order
|
||||
return result; // Dependencies are pushed before dependents.
|
||||
};
|
||||
|
||||
const optimalOrder = calculateOptimalLoadOrder();
|
||||
@@ -404,7 +411,7 @@ const ModuleLoader = (function() {
|
||||
}
|
||||
});
|
||||
|
||||
return result.reverse(); // Reverse for correct dependency order
|
||||
return result; // Dependencies are pushed before dependents.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,7 +449,8 @@ const ModuleLoader = (function() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.src = src;
|
||||
const separator = src.includes('?') ? '&' : '?';
|
||||
script.src = `${src}${separator}v=${MODULE_CACHE_BUSTER}`;
|
||||
|
||||
// Monitor loading progress using a fake progress indicator (0-10%)
|
||||
if (moduleId && moduleWeights[moduleId]) {
|
||||
@@ -492,7 +500,7 @@ const ModuleLoader = (function() {
|
||||
/**
|
||||
* Initialize all registered modules
|
||||
*/
|
||||
function initializeModules() {
|
||||
async function initializeModules() {
|
||||
const modules = moduleRegistry.getAllModules();
|
||||
|
||||
// Find the game loop module instance
|
||||
@@ -505,8 +513,19 @@ const ModuleLoader = (function() {
|
||||
console.log(`${moduleId} depends on: ${dependencies.length ? dependencies.join(', ') : 'none'}`);
|
||||
});
|
||||
|
||||
// For each registered module, start initialization
|
||||
Object.values(modules).forEach(async (module) => {
|
||||
const moduleList = Object.values(modules);
|
||||
const initializationOrder = sortModulesByDependencies(moduleList);
|
||||
|
||||
console.group('%cActual Module Initialization Order', 'color: green; font-weight: bold');
|
||||
initializationOrder.forEach((module, index) => {
|
||||
console.log(`${index + 1}. ${module.id} (${module.name})`);
|
||||
});
|
||||
console.groupEnd();
|
||||
|
||||
// Initialize in dependency order. This makes the loader guarantee that
|
||||
// by the time the overlay disappears, every module has run its own
|
||||
// initialize() after its declared dependencies are available.
|
||||
for (const module of initializationOrder) {
|
||||
try {
|
||||
// Create a progress callback for this module
|
||||
const progressCallback = (percent, message) => {
|
||||
@@ -539,7 +558,13 @@ const ModuleLoader = (function() {
|
||||
} catch (error) {
|
||||
console.error(`Error initializing module ${module.id}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
if (module.id === 'game-loop') {
|
||||
gameLoopModule = module;
|
||||
}
|
||||
}
|
||||
|
||||
checkAllFinished();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -147,6 +147,7 @@ class LocalizationModule extends BaseModule {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('app', 'locale', normalizedLocale);
|
||||
persistenceManager.updatePreference('tts', 'language', normalizedLocale);
|
||||
}
|
||||
|
||||
// Dispatch locale change event
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Markup Parser Module
|
||||
* Parses author-facing story markup into renderable blocks and timed cue metadata.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
class MarkupParserModule extends BaseModule {
|
||||
constructor() {
|
||||
super('markup-parser', 'Markup Parser');
|
||||
this.dependencies = [];
|
||||
this.assetRoots = {
|
||||
images: '/images/',
|
||||
music: '/music/',
|
||||
sounds: '/sounds/'
|
||||
};
|
||||
|
||||
this.bindMethods([
|
||||
'parse',
|
||||
'parseParagraph',
|
||||
'parseInline',
|
||||
'parseMusicOptions',
|
||||
'markdownToHtml',
|
||||
'smartypants',
|
||||
'escapeHtml',
|
||||
'normalizeParagraph',
|
||||
'buildParagraphBlock',
|
||||
'resolveAssetUrl'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.reportProgress(100, "Markup parser ready");
|
||||
return true;
|
||||
}
|
||||
|
||||
parse(input) {
|
||||
const text = String(input || '').replace(/\r\n?/g, '\n');
|
||||
const blocks = [];
|
||||
let paragraphBuffer = [];
|
||||
let nextParagraphRole = null;
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraphBuffer.length === 0) return;
|
||||
|
||||
const raw = paragraphBuffer.join('\n');
|
||||
paragraphBuffer = [];
|
||||
const paragraph = this.parseParagraph(raw);
|
||||
if (!paragraph.text) return;
|
||||
|
||||
const role = nextParagraphRole || 'body';
|
||||
nextParagraphRole = null;
|
||||
blocks.push(this.buildParagraphBlock(paragraph, role));
|
||||
};
|
||||
|
||||
text.split('\n').forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
return;
|
||||
}
|
||||
|
||||
const chapter = trimmed.match(/^::chapter(?:\[(.*?)\]|\s+(.+))$/i);
|
||||
if (chapter) {
|
||||
flushParagraph();
|
||||
const heading = (chapter[1] || chapter[2] || '').trim();
|
||||
if (heading) {
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
text: this.normalizeParagraph(heading),
|
||||
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)),
|
||||
role: 'chapter-heading'
|
||||
});
|
||||
}
|
||||
nextParagraphRole = 'chapter-first';
|
||||
return;
|
||||
}
|
||||
|
||||
const section = trimmed.match(/^::(?:section|textblock)(?:\[(.*?)\]|\s+(.+))?$/i);
|
||||
if (section) {
|
||||
flushParagraph();
|
||||
const heading = (section[1] || section[2] || '').trim();
|
||||
if (heading) {
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
text: this.normalizeParagraph(heading),
|
||||
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)),
|
||||
role: 'section-heading'
|
||||
});
|
||||
}
|
||||
nextParagraphRole = 'textblock-first';
|
||||
return;
|
||||
}
|
||||
|
||||
const image = trimmed.match(/^::image\[(widescreen|portrait)\]\(([^)]+)\)$/i);
|
||||
if (image) {
|
||||
flushParagraph();
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
size: image[1].toLowerCase(),
|
||||
filename: image[2].trim(),
|
||||
url: this.resolveAssetUrl('images', image[2].trim())
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const music = trimmed.match(/^::music(?:\[([^\]]*)\])?\(([^)]+)\)$/i);
|
||||
if (music) {
|
||||
flushParagraph();
|
||||
const options = this.parseMusicOptions(music[1] || 'crossfade');
|
||||
blocks.push({
|
||||
type: 'music',
|
||||
...options,
|
||||
filename: music[2].trim(),
|
||||
url: this.resolveAssetUrl('music', music[2].trim())
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
paragraphBuffer.push(line);
|
||||
});
|
||||
|
||||
flushParagraph();
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
parseMusicOptions(optionText) {
|
||||
const options = {
|
||||
mode: 'crossfade',
|
||||
loop: true,
|
||||
leadInSeconds: 0
|
||||
};
|
||||
|
||||
String(optionText || '')
|
||||
.split(/[,\s]+/)
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(token => {
|
||||
const lower = token.toLowerCase();
|
||||
const [key, value] = lower.split('=');
|
||||
|
||||
if (['queue', 'crossfade', 'cut'].includes(lower)) {
|
||||
options.mode = lower;
|
||||
} else if (['loop', 'looped', 'repeat'].includes(lower)) {
|
||||
options.loop = true;
|
||||
} else if (['once', 'single', 'no-loop', 'noloop'].includes(lower)) {
|
||||
options.loop = false;
|
||||
} else if (key === 'loop') {
|
||||
options.loop = !['false', '0', 'no', 'once'].includes(value);
|
||||
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro'].includes(key)) {
|
||||
const seconds = Number(value);
|
||||
options.leadInSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
|
||||
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
|
||||
options.leadInSeconds = Number(lower.replace(/s$/, ''));
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
parseParagraph(rawText) {
|
||||
const inline = this.parseInline(this.normalizeParagraph(rawText));
|
||||
return {
|
||||
text: inline.text,
|
||||
layoutText: this.markdownToHtml(inline.text),
|
||||
cueMarkers: inline.cueMarkers
|
||||
};
|
||||
}
|
||||
|
||||
buildParagraphBlock(paragraph, role) {
|
||||
return {
|
||||
type: 'paragraph',
|
||||
text: paragraph.text,
|
||||
layoutText: paragraph.layoutText,
|
||||
cueMarkers: paragraph.cueMarkers,
|
||||
role,
|
||||
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
|
||||
dropCap: role === 'chapter-first',
|
||||
addTopSpace: role === 'textblock-first'
|
||||
};
|
||||
}
|
||||
|
||||
parseInline(text) {
|
||||
const cueMarkers = [];
|
||||
let output = '';
|
||||
let cursor = 0;
|
||||
const markerPattern = /\{\{\s*(sfx|music)\s*:\s*(?:(queue|crossfade|cut)\s*:\s*)?([^}]+?)\s*\}\}/gi;
|
||||
|
||||
for (const match of text.matchAll(markerPattern)) {
|
||||
output += text.slice(cursor, match.index);
|
||||
const charIndex = output.length;
|
||||
const wordIndex = this.countWords(output);
|
||||
const type = match[1].toLowerCase();
|
||||
const mode = type === 'music' ? (match[2] || 'crossfade').toLowerCase() : null;
|
||||
|
||||
cueMarkers.push({
|
||||
type,
|
||||
mode,
|
||||
filename: match[3].trim(),
|
||||
url: this.resolveAssetUrl(type === 'sfx' ? 'sounds' : 'music', match[3].trim()),
|
||||
charIndex,
|
||||
wordIndex
|
||||
});
|
||||
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
|
||||
output += text.slice(cursor);
|
||||
|
||||
return {
|
||||
text: output.replace(/\s{2,}/g, ' ').trim(),
|
||||
cueMarkers
|
||||
};
|
||||
}
|
||||
|
||||
markdownToHtml(text) {
|
||||
const escaped = this.smartypants(this.escapeHtml(text));
|
||||
|
||||
return escaped
|
||||
.replace(/\*\*\*([^*]+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/___([^_]+?)___/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/__([^_]+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*\s][^*]*?)\*/g, '<em>$1</em>')
|
||||
.replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>');
|
||||
}
|
||||
|
||||
smartypants(text) {
|
||||
return String(text)
|
||||
.replace(/---/g, '\u2014')
|
||||
.replace(/--/g, '\u2013')
|
||||
.replace(/\.\.\./g, '\u2026')
|
||||
.replace(/(^|[\s([{\u2014])"([^"]*)"/g, '$1\u201c$2\u201d')
|
||||
.replace(/(^|[\s([{\u2014])'([^']*)'/g, '$1\u2018$2\u2019');
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
normalizeParagraph(text) {
|
||||
return String(text).replace(/\s*\n\s*/g, ' ').trim();
|
||||
}
|
||||
|
||||
countWords(text) {
|
||||
const words = String(text).trim().match(/\S+/g);
|
||||
return words ? words.length : 0;
|
||||
}
|
||||
|
||||
resolveAssetUrl(kind, filename) {
|
||||
const root = this.assetRoots[kind];
|
||||
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
|
||||
if (!root || !safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return root + safeName.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
}
|
||||
|
||||
const MarkupParser = new MarkupParserModule();
|
||||
|
||||
export { MarkupParser };
|
||||
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(MarkupParser);
|
||||
}
|
||||
|
||||
window.MarkupParser = MarkupParser;
|
||||
@@ -7,6 +7,18 @@ import { ApiTTSModuleBase } from './api-tts-module-base.js';
|
||||
export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
constructor() {
|
||||
super('openai-tts', 'OpenAI TTS');
|
||||
|
||||
this.supportedVoices = [
|
||||
{ id: 'alloy', name: 'Alloy', language: 'en' },
|
||||
{ id: 'ash', name: 'Ash', language: 'en' },
|
||||
{ id: 'coral', name: 'Coral', language: 'en' },
|
||||
{ id: 'echo', name: 'Echo', language: 'en' },
|
||||
{ id: 'fable', name: 'Fable', language: 'en' },
|
||||
{ id: 'nova', name: 'Nova', language: 'en' },
|
||||
{ id: 'onyx', name: 'Onyx', language: 'en' },
|
||||
{ id: 'sage', name: 'Sage', language: 'en' },
|
||||
{ id: 'shimmer', name: 'Shimmer', language: 'en' }
|
||||
];
|
||||
|
||||
// Voice options specific to OpenAI
|
||||
this.voiceOptions = {
|
||||
@@ -16,20 +28,8 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
response_format: 'mp3' // OpenAI supports mp3, opus, aac, and flac (not wav)
|
||||
};
|
||||
|
||||
// Predefined voices - OpenAI has a fixed set
|
||||
this.voices = [
|
||||
{ id: 'alloy', name: 'Alloy', language: 'en' },
|
||||
{ id: 'ash', name: 'Ash', language: 'en' },
|
||||
{ id: 'ballad', name: 'Ballad', language: 'en' },
|
||||
{ id: 'coral', name: 'Coral', language: 'en' },
|
||||
{ id: 'echo', name: 'Echo', language: 'en' },
|
||||
{ id: 'fable', name: 'Fable', language: 'en' },
|
||||
{ id: 'onyx', name: 'Onyx', language: 'en' },
|
||||
{ id: 'nova', name: 'Nova', language: 'en' },
|
||||
{ id: 'sage', name: 'Sage', language: 'en' },
|
||||
{ id: 'shimmer', name: 'Shimmer', language: 'en' },
|
||||
{ id: 'verse', name: 'Verse', language: 'en' }
|
||||
];
|
||||
// OpenAI has a documented fixed voice set for this speech endpoint.
|
||||
this.voices = [...this.supportedVoices];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,14 +65,16 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
// API key is already loaded in parent initialize() method
|
||||
// Just check if it's available
|
||||
if (!this.apiKey) {
|
||||
console.error('OpenAI TTS: API key not configured');
|
||||
return false;
|
||||
console.info('OpenAI TTS: API key not configured; provider unavailable until configured');
|
||||
this.isReady = false;
|
||||
this.reportProgress(100, 'OpenAI TTS not configured');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Load preferences
|
||||
const preferredVoice = persistenceManager.getPreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
|
||||
if (preferredVoice) {
|
||||
this.voiceOptions.voice = preferredVoice;
|
||||
this.voiceOptions.voice = this.normalizeVoiceId(preferredVoice);
|
||||
}
|
||||
|
||||
const preferredModel = persistenceManager.getPreference('tts', `${this.id}_model`, this.voiceOptions.model);
|
||||
@@ -80,13 +82,17 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
this.voiceOptions.model = preferredModel;
|
||||
}
|
||||
|
||||
const preferredSpeed = persistenceManager.getPreference('tts', `${this.id}_speed`, this.voiceOptions.speed);
|
||||
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
|
||||
if (typeof preferredSpeed === 'number') {
|
||||
this.voiceOptions.speed = preferredSpeed;
|
||||
this.voiceOptions.speed = this.getApiSpeed(preferredSpeed);
|
||||
}
|
||||
|
||||
// Setup available voices
|
||||
this.voices = this.getAvailableVoices();
|
||||
const apiReachable = await this.loadVoices();
|
||||
if (!apiReachable) {
|
||||
this.isReady = false;
|
||||
this.reportProgress(100, 'OpenAI TTS not ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
this.isReady = true;
|
||||
this.reportProgress(100, 'OpenAI TTS initialized');
|
||||
@@ -103,8 +109,31 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async loadVoices() {
|
||||
// OpenAI has a fixed set of voices, no need to fetch them
|
||||
return true;
|
||||
// OpenAI exposes a documented fixed TTS voice set, not a voice-list
|
||||
// endpoint. Use /models as a lightweight credential/endpoint check.
|
||||
this.voices = this.getAvailableVoices();
|
||||
if (!this.apiKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`OpenAI TTS: API validation failed ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('OpenAI TTS: API validation error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +164,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
* @returns {Array} - Array of voice objects
|
||||
*/
|
||||
getAvailableVoices() {
|
||||
this.voices = [...this.supportedVoices];
|
||||
return this.voices;
|
||||
}
|
||||
|
||||
@@ -156,9 +186,9 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
const payload = {
|
||||
model: this.voiceOptions.model || 'tts-1',
|
||||
input: processedText,
|
||||
voice: this.voiceOptions.voice || 'alloy',
|
||||
voice: this.normalizeVoiceId(this.voiceOptions.voice),
|
||||
response_format: this.voiceOptions.response_format || 'mp3',
|
||||
speed: this.voiceOptions.speed || 1.0
|
||||
speed: this.getApiSpeed(this.voiceOptions.speed)
|
||||
};
|
||||
|
||||
// Make API request
|
||||
@@ -203,24 +233,19 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
setVoiceOptions(options = {}) {
|
||||
// Handle common options
|
||||
if (options.voice) {
|
||||
this.voiceOptions.voice = options.voice;
|
||||
this.voiceOptions.voice = this.normalizeVoiceId(options.voice);
|
||||
|
||||
// Save voice preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_voice`, options.voice);
|
||||
persistenceManager.updatePreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.speed === 'number') {
|
||||
// OpenAI API supports speed values from 0.25 to 4.0 with 1 as default
|
||||
if (options.speed <= 0.5) {
|
||||
// Map [0, 0.5] -> [0.25, 1]
|
||||
this.voiceOptions.speed = 0.25 + (1 - 0.25) * (options.speed / 0.5);
|
||||
} else {
|
||||
// Map [0.5, 1] -> [1, 4]
|
||||
this.voiceOptions.speed = 1 + (4 - 1) * ((options.speed - 0.5) / 0.5);
|
||||
}
|
||||
// OpenAI speech speed uses 1.0 as normal. The app-wide slider also
|
||||
// uses 1.0 as normal, so only clamp at the provider API boundary.
|
||||
this.voiceOptions.speed = this.getApiSpeed(options.speed);
|
||||
}
|
||||
|
||||
// Handle OpenAI-specific options
|
||||
@@ -248,8 +273,34 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getVoiceId(voice) {
|
||||
if (!voice) return '';
|
||||
if (typeof voice === 'string') return voice;
|
||||
return voice.id || voice.name || '';
|
||||
}
|
||||
|
||||
normalizeVoiceId(voice) {
|
||||
const voiceId = this.getVoiceId(voice).toLowerCase();
|
||||
const supported = new Set(this.supportedVoices.map(item => item.id));
|
||||
|
||||
if (supported.has(voiceId)) {
|
||||
return voiceId;
|
||||
}
|
||||
|
||||
if (voiceId) {
|
||||
console.warn(`OpenAI TTS: Unsupported voice "${voiceId}", falling back to alloy`);
|
||||
}
|
||||
|
||||
return 'alloy';
|
||||
}
|
||||
|
||||
getApiSpeed(speed) {
|
||||
const value = Number.isFinite(speed) ? speed : 1.0;
|
||||
return Math.max(0.25, Math.min(4.0, value));
|
||||
}
|
||||
}
|
||||
|
||||
const openAITTSModule = new OpenAITTSModule();
|
||||
|
||||
export { openAITTSModule };
|
||||
export { openAITTSModule };
|
||||
|
||||
+127
-14
@@ -45,7 +45,8 @@ class OptionsUIModule extends BaseModule {
|
||||
'setupInitialState',
|
||||
'dispatchApiChangeEvent',
|
||||
'getPreference',
|
||||
'updatePreference'
|
||||
'updatePreference',
|
||||
'renderProviderStatuses'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -183,20 +184,21 @@ class OptionsUIModule extends BaseModule {
|
||||
const speedValue = document.createElement('span');
|
||||
speedValue.className = 'slider-value';
|
||||
speedValue.textContent = '100%';
|
||||
this.elements.ttsSpeedValue = speedValue;
|
||||
speedContainer.appendChild(speedValue);
|
||||
|
||||
this.elements.ttsSpeed = createUIElement('input', {
|
||||
type: 'range',
|
||||
min: 50,
|
||||
max: 200,
|
||||
max: 150,
|
||||
value: 100,
|
||||
'data-pref-bind': 'app.speed',
|
||||
'data-pref-transform': 'range:0.5,2.0'
|
||||
'data-pref-bind': 'tts.speed',
|
||||
'data-pref-transform': 'centered-speed'
|
||||
}, null, speedContainer);
|
||||
|
||||
// Update displayed value when slider changes
|
||||
this.elements.ttsSpeed.addEventListener('input', () => {
|
||||
speedValue.textContent = `${this.elements.ttsSpeed.value}%`;
|
||||
this.updateSpeedDisplay();
|
||||
});
|
||||
|
||||
appSettingsSection.appendChild(speedContainer);
|
||||
@@ -239,6 +241,11 @@ class OptionsUIModule extends BaseModule {
|
||||
}, null, ttsSystemContainer);
|
||||
|
||||
ttsSection.appendChild(ttsSystemContainer);
|
||||
|
||||
const providerStatusContainer = document.createElement('div');
|
||||
providerStatusContainer.className = 'provider-status-list';
|
||||
this.elements.providerStatus = providerStatusContainer;
|
||||
ttsSection.appendChild(providerStatusContainer);
|
||||
|
||||
// TTS Voice
|
||||
const ttsVoiceContainer = document.createElement('div');
|
||||
@@ -510,7 +517,12 @@ class OptionsUIModule extends BaseModule {
|
||||
if (this.elements.ttsSystem) {
|
||||
this.elements.ttsSystem.addEventListener('change', async (event) => {
|
||||
this.updateApiSettingsVisibility(event.target.value);
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
await ttsFactory.refreshHandlerStatus(event.target.value);
|
||||
}
|
||||
await this.populateVoices();
|
||||
this.renderProviderStatuses();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -595,6 +607,7 @@ class OptionsUIModule extends BaseModule {
|
||||
|
||||
// Update API settings visibility
|
||||
this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
|
||||
this.renderProviderStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -604,8 +617,10 @@ class OptionsUIModule extends BaseModule {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (!ttsFactory || !this.elements.ttsVoice) return;
|
||||
|
||||
// Get voices for current TTS system
|
||||
const voices = await ttsFactory.getVoices() || [];
|
||||
const selectedHandler = this.elements.ttsSystem?.value || this.getPreference('tts', 'preferred_handler', 'none');
|
||||
const voices = typeof ttsFactory.getVoicesForHandler === 'function'
|
||||
? await ttsFactory.getVoicesForHandler(selectedHandler) || []
|
||||
: await ttsFactory.getVoices() || [];
|
||||
console.log('Options UI: TTS voices:', voices);
|
||||
|
||||
// Populate dropdown
|
||||
@@ -614,9 +629,35 @@ class OptionsUIModule extends BaseModule {
|
||||
voices,
|
||||
'id',
|
||||
'name',
|
||||
this.getPreference('tts', 'voice', '')
|
||||
this.getPreference('tts', `${selectedHandler}_voice`, this.getPreference('tts', 'voice', ''))
|
||||
);
|
||||
}
|
||||
|
||||
renderProviderStatuses() {
|
||||
const container = this.elements.providerStatus;
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (!container || !ttsFactory || typeof ttsFactory.getHandlerStatuses !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
const statuses = ttsFactory.getHandlerStatuses();
|
||||
statuses.forEach(status => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'provider-status-row';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.textContent = status.name;
|
||||
row.appendChild(name);
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.className = 'provider-status-value';
|
||||
value.textContent = `${status.ready ? 'ready' : 'not ready'} - ${status.message}`;
|
||||
row.appendChild(value);
|
||||
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the languages dropdown
|
||||
@@ -737,6 +778,7 @@ class OptionsUIModule extends BaseModule {
|
||||
document.addEventListener('tts:voices:updated', () => {
|
||||
console.log('Options UI: Received tts:voices:updated event, updating voice dropdown');
|
||||
this.populateVoices();
|
||||
this.renderProviderStatuses();
|
||||
});
|
||||
|
||||
// Set up language change listener
|
||||
@@ -750,6 +792,36 @@ class OptionsUIModule extends BaseModule {
|
||||
console.log('Options UI: Received TTS engine change event:', event.detail);
|
||||
await this.populateVoices();
|
||||
this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
|
||||
this.renderProviderStatuses();
|
||||
});
|
||||
|
||||
document.addEventListener('tts:status:updated', () => {
|
||||
this.renderProviderStatuses();
|
||||
});
|
||||
|
||||
document.addEventListener('tts:enabled:change', async (event) => {
|
||||
if (!event.detail || typeof event.detail.enabled !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.elements.ttsEnabled) {
|
||||
this.elements.ttsEnabled.checked = event.detail.enabled;
|
||||
}
|
||||
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (!ttsFactory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.detail.enabled) {
|
||||
const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none');
|
||||
if (preferredHandler !== 'none') {
|
||||
await ttsFactory.setActiveHandler(preferredHandler);
|
||||
}
|
||||
} else {
|
||||
await ttsFactory.disableAfterCurrentPlayback();
|
||||
}
|
||||
this.renderProviderStatuses();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -766,6 +838,7 @@ class OptionsUIModule extends BaseModule {
|
||||
// Setup all bindings in the modal
|
||||
this.bindings = persistenceManager.setupBindings('#options-modal');
|
||||
console.log('Options UI: Preference bindings set up', this.bindings.length);
|
||||
this.updateSpeedDisplay();
|
||||
|
||||
// Add event listeners for side effects when preferences change
|
||||
document.addEventListener('preference-updated', (event) => {
|
||||
@@ -793,16 +866,46 @@ class OptionsUIModule extends BaseModule {
|
||||
if (!ttsFactory) return;
|
||||
|
||||
if (key === 'preferred_handler') {
|
||||
this.populateVoices();
|
||||
const enabled = this.getPreference('tts', 'enabled', false);
|
||||
const activation = enabled && value !== 'none'
|
||||
? ttsFactory.setActiveHandler(value)
|
||||
: Promise.resolve(ttsFactory.disableAfterCurrentPlayback());
|
||||
activation.then(() => {
|
||||
this.populateVoices();
|
||||
this.renderProviderStatuses();
|
||||
});
|
||||
this.updateApiSettingsVisibility(value);
|
||||
} else if (key === 'voice') {
|
||||
ttsFactory.configure({ voice: value });
|
||||
} else if (key === 'speed') {
|
||||
ttsFactory.configure({ speed: value });
|
||||
} else if (key === 'language') {
|
||||
ttsFactory.configure({ language: value });
|
||||
} else if (key === 'enabled') {
|
||||
ttsFactory.configure({ enabled: value });
|
||||
}
|
||||
if (!value) {
|
||||
ttsFactory.disableAfterCurrentPlayback();
|
||||
} else {
|
||||
const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none');
|
||||
if (preferredHandler !== 'none') {
|
||||
ttsFactory.setActiveHandler(preferredHandler);
|
||||
}
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('tts:enabled:change', {
|
||||
detail: { enabled: value }
|
||||
}));
|
||||
} else if (key.endsWith('_api_key')) {
|
||||
const provider = key.replace('_api_key', '');
|
||||
this.dispatchApiChangeEvent('api:keyChanged', provider, 'key', value);
|
||||
ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses());
|
||||
} else if (key.endsWith('_api_url')) {
|
||||
const provider = key.replace('_api_url', '');
|
||||
this.dispatchApiChangeEvent('api:urlChanged', provider, 'url', value);
|
||||
ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses());
|
||||
}
|
||||
if (key === 'speed' && this.elements.ttsSpeed) {
|
||||
this.updateSpeedDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle locale changes
|
||||
if (category === 'app' && key === 'locale') {
|
||||
@@ -810,16 +913,26 @@ class OptionsUIModule extends BaseModule {
|
||||
if (localization) {
|
||||
localization.setLocale(value);
|
||||
}
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.configure({ language: value });
|
||||
}
|
||||
this.updatePreference('tts', 'language', value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateSpeedDisplay() {
|
||||
if (!this.elements.ttsSpeed || !this.elements.ttsSpeedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.ttsSpeedValue.textContent = `${this.elements.ttsSpeed.value}%`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const OptionsUI = new OptionsUIModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(OptionsUI);
|
||||
|
||||
// Export the module
|
||||
export { OptionsUI };
|
||||
|
||||
@@ -8,14 +8,12 @@ import { BaseModule } from './base-module.js';
|
||||
class ParagraphLayoutModule extends BaseModule {
|
||||
constructor() {
|
||||
super('paragraph-layout', 'Paragraph Layout');
|
||||
|
||||
// Module dependencies
|
||||
|
||||
this.dependencies = ['text-processor'];
|
||||
|
||||
// Caching canvas context for text measurements
|
||||
this.textMeasureCtx = null;
|
||||
|
||||
// Configuration - use parent's config system
|
||||
|
||||
this.ruler = null;
|
||||
this.rulerStack = [];
|
||||
|
||||
this.updateConfig({
|
||||
maxLineWidth: 600,
|
||||
hyphenationEnabled: true,
|
||||
@@ -24,8 +22,7 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
defaultLineHeight: 1.5,
|
||||
debugMode: false
|
||||
});
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
|
||||
this.bindMethods([
|
||||
'calculateLayout',
|
||||
'measureText',
|
||||
@@ -37,27 +34,20 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
'loadLayoutDependencies'
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(20, "Initializing paragraph layout");
|
||||
|
||||
// Get text processor using parent's getModule method
|
||||
|
||||
const textProcessor = this.getModule('text-processor');
|
||||
|
||||
if (!textProcessor) {
|
||||
console.warn("Paragraph Layout: Text Processor not found, will use fallback processing");
|
||||
console.warn("Paragraph Layout: Text Processor not found, will use unprocessed text");
|
||||
}
|
||||
|
||||
// Load required dependencies
|
||||
|
||||
await this.loadLayoutDependencies();
|
||||
|
||||
// Create off-screen canvas for text measurements
|
||||
this.initializeTextMeasurement();
|
||||
|
||||
// Set up event listeners for config changes
|
||||
this.setupEventListeners();
|
||||
|
||||
|
||||
this.reportProgress(100, "Paragraph layout ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -65,23 +55,15 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load required dependencies for layout calculations
|
||||
*/
|
||||
|
||||
async loadLayoutDependencies() {
|
||||
try {
|
||||
this.reportProgress(30, "Loading layout dependencies");
|
||||
|
||||
// Load LinkedList.js first as it's required by linebreak.js
|
||||
|
||||
await this.loadScript('/js/linked-list.js');
|
||||
|
||||
// Load linebreak.js which is required by knuth-and-plass.js
|
||||
await this.loadScript('/js/linebreak.js');
|
||||
|
||||
// Load knuth-and-plass.js which contains the kap function
|
||||
await this.loadScript('/js/knuth-and-plass.js');
|
||||
|
||||
|
||||
this.reportProgress(50, "Layout dependencies loaded");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -89,148 +71,163 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize text measurement canvas
|
||||
*/
|
||||
|
||||
initializeTextMeasurement() {
|
||||
// Create off-screen canvas for text measurements
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2000;
|
||||
canvas.height = 100;
|
||||
this.textMeasureCtx = canvas.getContext('2d');
|
||||
|
||||
// Set default font
|
||||
let ruler = document.getElementById('ruler');
|
||||
|
||||
if (!ruler) {
|
||||
ruler = document.createElement('span');
|
||||
ruler.id = 'ruler';
|
||||
document.body.appendChild(ruler);
|
||||
}
|
||||
|
||||
Object.assign(ruler.style, {
|
||||
visibility: 'hidden',
|
||||
position: 'absolute',
|
||||
top: '-8000px',
|
||||
left: '-8000px',
|
||||
width: 'auto',
|
||||
display: 'inline',
|
||||
textIndent: '0',
|
||||
textAlign: 'left',
|
||||
hyphens: 'none',
|
||||
marginBlockEnd: '0'
|
||||
});
|
||||
|
||||
this.ruler = ruler;
|
||||
this.rulerStack = [ruler];
|
||||
this.updateFont(this.config.defaultFontSize, this.config.defaultFontFamily);
|
||||
}
|
||||
|
||||
|
||||
setupEventListeners() {
|
||||
// Use parent's addEventListener for automatic cleanup
|
||||
this.addEventListener(document, 'ui:font:change', (event) => {
|
||||
if (event.detail) {
|
||||
const { fontSize, fontFamily } = event.detail;
|
||||
if (fontSize || fontFamily) {
|
||||
this.updateFont(
|
||||
fontSize || this.config.defaultFontSize,
|
||||
fontSize || this.config.defaultFontSize,
|
||||
fontFamily || this.config.defaultFontFamily
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for config changes
|
||||
|
||||
this.addEventListener(document, 'ui:hyphenation:toggle', (event) => {
|
||||
if (event.detail && typeof event.detail.enabled === 'boolean') {
|
||||
this.updateConfig({ hyphenationEnabled: event.detail.enabled });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the font for text measurements
|
||||
* @param {string} fontSize - Font size (with units)
|
||||
* @param {string} fontFamily - Font family
|
||||
*/
|
||||
|
||||
updateFont(fontSize, fontFamily) {
|
||||
if (!this.textMeasureCtx) {
|
||||
console.warn("Text measurement context not initialized");
|
||||
if (!this.ruler) {
|
||||
console.warn("Text measurement ruler not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update config if values are provided
|
||||
|
||||
if (fontSize) this.updateConfig({ defaultFontSize: fontSize });
|
||||
if (fontFamily) this.updateConfig({ defaultFontFamily: fontFamily });
|
||||
|
||||
// Set font on measurement context
|
||||
this.textMeasureCtx.font = `${fontSize} ${fontFamily}`;
|
||||
|
||||
|
||||
this.ruler.style.fontSize = fontSize;
|
||||
this.ruler.style.fontFamily = fontFamily;
|
||||
this.ruler.style.fontFeatureSettings = "'kern' on, 'liga' on, 'onum' on, 'clig' on, 'hlig' on";
|
||||
|
||||
if (this.config.debugMode) {
|
||||
console.log(`Font updated: ${fontSize} ${fontFamily}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure text width using canvas context
|
||||
* @param {string} text - Text to measure
|
||||
* @returns {number} - Text width in pixels
|
||||
*/
|
||||
|
||||
measureText(text) {
|
||||
if (!this.textMeasureCtx) return 0;
|
||||
if (!this.ruler) return 0;
|
||||
if (!text) return 0;
|
||||
|
||||
const metrics = this.textMeasureCtx.measureText(text);
|
||||
return metrics.width;
|
||||
|
||||
if (text.substr(0, 2) === '</') {
|
||||
if (this.rulerStack.length > 1) {
|
||||
const child = this.rulerStack.pop();
|
||||
const parent = this.rulerStack[this.rulerStack.length - 1];
|
||||
if (child.parentElement === parent) {
|
||||
parent.removeChild(child);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (text.substr(0, 1) === '<') {
|
||||
const template = document.createElement('div');
|
||||
template.innerHTML = text;
|
||||
const child = template.firstChild;
|
||||
|
||||
if (child) {
|
||||
const parent = this.rulerStack[this.rulerStack.length - 1];
|
||||
this.rulerStack.push(child);
|
||||
parent.appendChild(child);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (text === '|') return 0;
|
||||
if (text === ' ') text = '\u00A0';
|
||||
|
||||
const parent = this.rulerStack[this.rulerStack.length - 1];
|
||||
const textNode = document.createTextNode(text);
|
||||
parent.appendChild(textNode);
|
||||
const rect = parent.getClientRects()[0] || parent.getBoundingClientRect();
|
||||
const width = rect ? rect.width : 0;
|
||||
parent.removeChild(textNode);
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process text for layout (apply hyphenation and smartypants)
|
||||
* @param {string} text - Text to process
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
|
||||
processTextForLayout(text) {
|
||||
if (!text) return '';
|
||||
|
||||
|
||||
let processedText = text;
|
||||
const textProcessor = this.getModule('text-processor');
|
||||
|
||||
// Apply text processing if available
|
||||
if (textProcessor) {
|
||||
// Apply smartypants (typographic punctuation) if available
|
||||
if (typeof textProcessor.applySmartypants === 'function') {
|
||||
processedText = textProcessor.applySmartypants(processedText);
|
||||
}
|
||||
|
||||
// Apply hyphenation if enabled and available
|
||||
if (this.config.hyphenationEnabled && typeof textProcessor.hyphenateText === 'function') {
|
||||
processedText = textProcessor.hyphenateText(processedText);
|
||||
}
|
||||
|
||||
if (textProcessor && typeof textProcessor.process === 'function') {
|
||||
processedText = textProcessor.process(processedText, {
|
||||
smartypants: true,
|
||||
hyphenate: this.config.hyphenationEnabled,
|
||||
hyphenSelector: '.hyphenatePipe'
|
||||
});
|
||||
} else if (this.config.debugMode) {
|
||||
console.log("Text processor not available, skipping text processing");
|
||||
}
|
||||
|
||||
|
||||
return processedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate layout for a paragraph using Knuth and Plass algorithm
|
||||
* @param {string} text - Text to layout
|
||||
* @param {Object} options - Layout options
|
||||
* @returns {Object} - Layout data with line breaks
|
||||
*/
|
||||
|
||||
calculateLayout(text, options = {}) {
|
||||
if (!text) return null;
|
||||
|
||||
|
||||
try {
|
||||
// Check if the kap function is available
|
||||
if (typeof window.kap !== 'function') {
|
||||
console.error("Paragraph Layout: kap function not available. Make sure knuth-and-plass.js is loaded.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Process text for layout (hyphenation, etc)
|
||||
|
||||
const processedText = this.processTextForLayout(text);
|
||||
|
||||
// Prepare options by merging with defaults
|
||||
const layoutOptions = {
|
||||
width: options.width || this.config.maxLineWidth,
|
||||
fontSize: options.fontSize || this.config.defaultFontSize,
|
||||
fontFamily: options.fontFamily || this.config.defaultFontFamily,
|
||||
lineHeight: options.lineHeight || this.config.defaultLineHeight,
|
||||
tolerance: options.tolerance || 3, // Tolerance for line breaking algorithm
|
||||
lineHeightPx: options.lineHeightPx,
|
||||
tolerance: options.tolerance || 3,
|
||||
demerits: options.demerits || {
|
||||
line: 10, // Demerits for each line break
|
||||
flagged: 100, // Demerits for flagged break points (like hyphens)
|
||||
fitness: 3000 // Demerits for consecutive lines with very different looseness/tightness
|
||||
line: 10,
|
||||
flagged: 100,
|
||||
fitness: 3000
|
||||
}
|
||||
};
|
||||
|
||||
// Update font for measurement
|
||||
|
||||
this.updateFont(layoutOptions.fontSize, layoutOptions.fontFamily);
|
||||
|
||||
// Create measure array - this is crucial for proper line breaking
|
||||
// The first value is the full width, subsequent values can be for indented lines
|
||||
const measure = [layoutOptions.width];
|
||||
|
||||
const numericFontSize = parseFloat(layoutOptions.fontSize) || 16;
|
||||
const lineHeightPx = layoutOptions.lineHeightPx || (numericFontSize * layoutOptions.lineHeight);
|
||||
|
||||
const measure = options.measures || [layoutOptions.width];
|
||||
|
||||
if (this.config.debugMode) {
|
||||
console.log("Paragraph Layout: Calculating layout for text", {
|
||||
text: processedText,
|
||||
@@ -238,8 +235,7 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
options: layoutOptions
|
||||
});
|
||||
}
|
||||
|
||||
// Use the global Knuth and Plass algorithm function with proper parameters
|
||||
|
||||
const layout = window.kap(
|
||||
processedText,
|
||||
this.measureText.bind(this),
|
||||
@@ -248,55 +244,47 @@ class ParagraphLayoutModule extends BaseModule {
|
||||
layoutOptions.tolerance,
|
||||
layoutOptions.demerits
|
||||
);
|
||||
|
||||
// If layout failed, return null
|
||||
|
||||
if (!layout || !layout.breaks || !layout.nodes) {
|
||||
console.warn("Paragraph Layout: Failed to calculate layout for text");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (this.config.debugMode) {
|
||||
console.log("Paragraph Layout: Layout calculated", {
|
||||
breaks: layout.breaks.length,
|
||||
nodes: layout.nodes.length
|
||||
});
|
||||
}
|
||||
|
||||
// Return layout data with original text for reference
|
||||
|
||||
return {
|
||||
...layout,
|
||||
originalText: text,
|
||||
processedText: processedText,
|
||||
processedText,
|
||||
width: layoutOptions.width,
|
||||
lineHeight: layoutOptions.lineHeight
|
||||
lineHeight: layoutOptions.lineHeight,
|
||||
lineHeightPx,
|
||||
fontSize: layoutOptions.fontSize,
|
||||
fontFamily: layoutOptions.fontFamily
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error calculating layout:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set debug mode
|
||||
* @param {boolean} enabled - Whether debug mode should be enabled
|
||||
*/
|
||||
|
||||
setDebugMode(enabled) {
|
||||
// Use parent's updateConfig method
|
||||
this.updateConfig({ debugMode: enabled });
|
||||
console.log(`Paragraph Layout: Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const ParagraphLayout = new ParagraphLayoutModule();
|
||||
|
||||
// Register with the module registry
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(ParagraphLayout);
|
||||
}
|
||||
|
||||
// Export the module
|
||||
export { ParagraphLayout };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.ParagraphLayout = ParagraphLayout;
|
||||
|
||||
Binary file not shown.
@@ -32,6 +32,8 @@ class PersistenceManagerModule extends BaseModule {
|
||||
tts: {
|
||||
enabled: false,
|
||||
preferred_handler: 'none',
|
||||
speed: 1.0,
|
||||
language: 'en-us',
|
||||
voice: '',
|
||||
'elevenlabs-tts_api_key': '',
|
||||
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
|
||||
@@ -260,6 +262,11 @@ class PersistenceManagerModule extends BaseModule {
|
||||
if (!this.preferences[category]) {
|
||||
this.preferences[category] = {};
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(this.preferences[category], setting) &&
|
||||
Object.is(this.preferences[category][setting], value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Store value
|
||||
this.preferences[category][setting] = value;
|
||||
@@ -612,7 +619,17 @@ class PersistenceManagerModule extends BaseModule {
|
||||
if (element.dataset.prefTransform) {
|
||||
try {
|
||||
// Check if it's a range transformer in format 'range:min,max'
|
||||
if (element.dataset.prefTransform.startsWith('range:')) {
|
||||
if (element.dataset.prefTransform === 'centered-speed') {
|
||||
transformer = {
|
||||
toElement: (value) => Math.round(((Number(value) || 1) * 50) + 50),
|
||||
toPreference: (value) => Math.max(0.5, Math.min(2.0, (parseInt(value, 10) - 50) / 50))
|
||||
};
|
||||
} else if (element.dataset.prefTransform === 'multiplier-percent') {
|
||||
transformer = {
|
||||
toElement: (value) => Math.round((Number(value) || 1) * 100),
|
||||
toPreference: (value) => Math.max(0.25, Math.min(4.0, parseInt(value, 10) / 100))
|
||||
};
|
||||
} else if (element.dataset.prefTransform.startsWith('range:')) {
|
||||
const rangeValues = element.dataset.prefTransform.substring(6).split(',');
|
||||
if (rangeValues.length === 2) {
|
||||
const min = parseFloat(rangeValues[0]);
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Playback Coordinator Module
|
||||
* Synchronizes text animation with TTS audio playback to ensure exact timing match
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
class PlaybackCoordinatorModule extends BaseModule {
|
||||
constructor() {
|
||||
super('playback-coordinator', 'Playback Coordinator');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['animation-queue', 'tts-factory'];
|
||||
|
||||
// Current playback state
|
||||
this.isPlaying = false;
|
||||
this.currentSentence = null;
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'play',
|
||||
'calculateWordTimings',
|
||||
'animateWords',
|
||||
'waitForAudioStart',
|
||||
'fastForward',
|
||||
'stop'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(50, "Initializing Playback Coordinator");
|
||||
|
||||
// Verify dependencies
|
||||
const animQueue = this.getModule('animation-queue');
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
|
||||
if (!animQueue || !ttsFactory) {
|
||||
console.error("PlaybackCoordinator: Missing required dependencies");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.reportProgress(100, "Playback Coordinator ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Playback Coordinator:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sentence with synchronized animation + TTS
|
||||
* @param {Object} sentence - Prepared sentence object with layout, TTS, and animation data
|
||||
* @returns {Promise<void>} - Resolves when playback completes
|
||||
*/
|
||||
async play(sentence) {
|
||||
if (this.isPlaying) {
|
||||
console.warn('PlaybackCoordinator: Already playing, canceling previous');
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
this.currentSentence = sentence;
|
||||
|
||||
try {
|
||||
// Start TTS first, then begin text animation when the audio element
|
||||
// confirms playback has started. Sentence preparation/prefetching is
|
||||
// handled by SentenceQueue and can still run while this sentence plays.
|
||||
const ttsPromise = this.playTTS(sentence);
|
||||
await this.waitForAudioStart(sentence, ttsPromise);
|
||||
const animPromise = this.animateWords(sentence);
|
||||
|
||||
// Wait for both to complete
|
||||
await Promise.all([ttsPromise, animPromise]);
|
||||
|
||||
console.log(`PlaybackCoordinator: Completed sentence ${sentence.id}`);
|
||||
} catch (error) {
|
||||
console.error('PlaybackCoordinator: Error during playback:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isPlaying = false;
|
||||
this.currentSentence = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play TTS audio for a sentence
|
||||
* @param {Object} sentence - Sentence object with TTS data
|
||||
* @returns {Promise<void>} - Resolves when TTS completes
|
||||
*/
|
||||
async playTTS(sentence) {
|
||||
if (!sentence.tts || !sentence.tts.enabled) {
|
||||
// TTS disabled, return immediately
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('tts:playback-start', {
|
||||
detail: { sentenceId: sentence.id }
|
||||
}));
|
||||
if (typeof sentence.tts.play === 'function') {
|
||||
await sentence.tts.play();
|
||||
} else {
|
||||
console.warn('PlaybackCoordinator: TTS play function not available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PlaybackCoordinator: TTS playback error:', error);
|
||||
// Don't throw - allow animation to continue
|
||||
} finally {
|
||||
document.dispatchEvent(new CustomEvent('tts:playback-end', {
|
||||
detail: { sentenceId: sentence.id }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async waitForAudioStart(sentence, ttsPromise) {
|
||||
if (!sentence.tts || !sentence.tts.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('tts:audio-started', onStarted);
|
||||
document.removeEventListener('tts:playback-end', onEnded);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
const finish = (reason) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
console.log(`PlaybackCoordinator: Animation start released (${reason}) for ${sentence.id}`);
|
||||
resolve();
|
||||
};
|
||||
const onStarted = () => finish('audio-started');
|
||||
const onEnded = (event) => {
|
||||
if (!event.detail || event.detail.sentenceId === sentence.id) {
|
||||
finish('tts-ended-before-start');
|
||||
}
|
||||
};
|
||||
const timeout = setTimeout(() => finish('audio-start-timeout'), 1500);
|
||||
|
||||
document.addEventListener('tts:audio-started', onStarted, { once: true });
|
||||
document.addEventListener('tts:playback-end', onEnded);
|
||||
|
||||
Promise.resolve(ttsPromise).then(() => finish('tts-promise-resolved')).catch(() => finish('tts-promise-rejected'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate words using calculated timings
|
||||
* @param {Object} sentence - Sentence object with animation data and DOM element
|
||||
* @returns {Promise<void>} - Resolves when animation completes
|
||||
*/
|
||||
async animateWords(sentence) {
|
||||
if (!sentence.element || !sentence.animation || !sentence.animation.wordTimings) {
|
||||
console.error('PlaybackCoordinator: Missing animation data');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const animQueue = this.getModule('animation-queue');
|
||||
if (!animQueue) {
|
||||
console.error('PlaybackCoordinator: Animation queue not available');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const wordElements = sentence.element.querySelectorAll('.word');
|
||||
let wordTimings = sentence.animation.wordTimings;
|
||||
let cueTimings = sentence.animation.cueTimings || [];
|
||||
|
||||
if (wordElements.length !== wordTimings.length) {
|
||||
console.info(`PlaybackCoordinator: Word count mismatch (DOM: ${wordElements.length}, timings: ${wordTimings.length}); recalculating timings from rendered words`);
|
||||
const renderedWords = Array.from(wordElements).map(word => word.textContent || '');
|
||||
const duration = sentence.tts?.duration || sentence.animation.totalDuration || 0;
|
||||
wordTimings = this.calculateWordTimings(renderedWords, duration).wordTimings;
|
||||
cueTimings = cueTimings.map(cue => {
|
||||
const wordIndex = Math.max(0, Math.min(cue.wordIndex || 0, wordTimings.length - 1));
|
||||
return {
|
||||
...cue,
|
||||
delay: (wordTimings[wordIndex] || { delay: duration }).delay
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const totalDuration = wordTimings.length > 0
|
||||
? Math.max(...wordTimings.map(timing => timing.delay + timing.duration))
|
||||
: 0;
|
||||
|
||||
console.log(`PlaybackCoordinator: Animating ${wordTimings.length} words over ${totalDuration}ms`);
|
||||
if (wordTimings.length > 0) {
|
||||
console.log(` First word delay: ${wordTimings[0].delay}ms, Last word delay: ${wordTimings[wordTimings.length-1].delay}ms`);
|
||||
}
|
||||
|
||||
// Schedule each word animation
|
||||
wordTimings.forEach((timing, i) => {
|
||||
if (i < wordElements.length) {
|
||||
animQueue.schedule(() => {
|
||||
const word = wordElements[i];
|
||||
const duration = Math.max(0, timing.duration || 0);
|
||||
const transitionDuration = `${duration}ms`;
|
||||
|
||||
word.style.transition = `opacity ${transitionDuration} linear, transform ${transitionDuration} ease-out`;
|
||||
word.style.visibility = 'visible';
|
||||
word.style.opacity = '1';
|
||||
word.style.transform = 'translateY(0)';
|
||||
}, timing.delay);
|
||||
}
|
||||
});
|
||||
|
||||
cueTimings.forEach(cue => {
|
||||
animQueue.schedule(() => {
|
||||
document.dispatchEvent(new CustomEvent('story:media-cue', {
|
||||
detail: {
|
||||
sentenceId: sentence.id,
|
||||
...cue
|
||||
}
|
||||
}));
|
||||
}, cue.delay || 0);
|
||||
});
|
||||
|
||||
// Schedule completion callback
|
||||
animQueue.schedule(() => {
|
||||
resolve();
|
||||
}, totalDuration + 100); // Small buffer
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate word-level timing to match total TTS duration
|
||||
* This is a utility method that can be called by SentenceQueue during preparation
|
||||
* @param {Array<string>} words - Array of words to animate
|
||||
* @param {number} totalDuration - Total duration in milliseconds
|
||||
* @returns {Object} - Object with wordTimings array and totalDuration
|
||||
*/
|
||||
calculateWordTimings(words, totalDuration) {
|
||||
if (!words || words.length === 0) {
|
||||
return {
|
||||
wordTimings: [],
|
||||
totalDuration: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate characters per word
|
||||
const totalChars = words.reduce((sum, word) => sum + word.length, 0);
|
||||
|
||||
if (totalChars === 0) {
|
||||
// Edge case: all empty words
|
||||
return {
|
||||
wordTimings: words.map(word => ({ word, delay: 0, duration: 0 })),
|
||||
totalDuration: 0
|
||||
};
|
||||
}
|
||||
|
||||
const msPerChar = totalDuration / totalChars;
|
||||
|
||||
let currentDelay = 0;
|
||||
const wordTimings = words.map(word => {
|
||||
const duration = word.length * msPerChar;
|
||||
const timing = {
|
||||
word: word,
|
||||
delay: currentDelay,
|
||||
duration: duration
|
||||
};
|
||||
currentDelay += duration;
|
||||
return timing;
|
||||
});
|
||||
|
||||
return {
|
||||
wordTimings,
|
||||
totalDuration: Math.round(currentDelay)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast forward current playback
|
||||
* Completes all animations immediately and stops TTS
|
||||
*/
|
||||
async fastForward() {
|
||||
if (!this.isPlaying || !this.currentSentence) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('PlaybackCoordinator: Fast forwarding');
|
||||
|
||||
const animQueue = this.getModule('animation-queue');
|
||||
if (animQueue) {
|
||||
animQueue.fastForward();
|
||||
}
|
||||
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory && typeof ttsFactory.fadeOut === 'function') {
|
||||
await ttsFactory.fadeOut(1000);
|
||||
} else if (this.currentSentence.tts && typeof this.currentSentence.tts.stop === 'function') {
|
||||
await new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
this.currentSentence.tts.stop();
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Complete all word animations immediately
|
||||
if (this.currentSentence.element) {
|
||||
const wordElements = this.currentSentence.element.querySelectorAll('.word');
|
||||
wordElements.forEach(word => {
|
||||
word.style.opacity = '1';
|
||||
word.style.transform = 'translateY(0)';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current playback
|
||||
*/
|
||||
async stop() {
|
||||
if (!this.isPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('PlaybackCoordinator: Stopping');
|
||||
|
||||
// Stop TTS
|
||||
if (this.currentSentence && this.currentSentence.tts && typeof this.currentSentence.tts.stop === 'function') {
|
||||
this.currentSentence.tts.stop();
|
||||
}
|
||||
|
||||
// Clear animation queue
|
||||
const animQueue = this.getModule('animation-queue');
|
||||
if (animQueue) {
|
||||
animQueue.clearAll();
|
||||
}
|
||||
|
||||
this.isPlaying = false;
|
||||
this.currentSentence = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const PlaybackCoordinator = new PlaybackCoordinatorModule();
|
||||
|
||||
// Export the module
|
||||
export { PlaybackCoordinator };
|
||||
|
||||
// Register with the module registry
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(PlaybackCoordinator);
|
||||
}
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.PlaybackCoordinator = PlaybackCoordinator;
|
||||
@@ -9,20 +9,29 @@ class SentenceQueueModule extends BaseModule {
|
||||
super('sentence-queue', 'Sentence Queue');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['text-buffer', 'tts-factory', 'tts-player'];
|
||||
|
||||
this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager'];
|
||||
|
||||
// Queue state
|
||||
this.sentenceQueue = [];
|
||||
this.isProcessing = false;
|
||||
this.onSentenceReadyCallback = null;
|
||||
|
||||
|
||||
// Cache for prefetched sentences
|
||||
this.preparedCache = new Map();
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'addSentence',
|
||||
'processNextSentence',
|
||||
'setOnSentenceReady',
|
||||
'completeSentence'
|
||||
'completeSentence',
|
||||
'prepareSentence',
|
||||
'prepareLayout',
|
||||
'extractWords',
|
||||
'getDropCapText',
|
||||
'extractDropCapText',
|
||||
'calculateAnimationTiming'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -69,9 +78,13 @@ class SentenceQueueModule extends BaseModule {
|
||||
* @param {Function} callback - Callback to call when sentence is processed
|
||||
*/
|
||||
addSentence(sentence, callback) {
|
||||
const queueItem = typeof sentence === 'object' && sentence !== null
|
||||
? { ...sentence, callback }
|
||||
: { text: sentence, callback };
|
||||
|
||||
this.sentenceQueue.push({
|
||||
text: sentence,
|
||||
callback: callback
|
||||
...queueItem,
|
||||
text: String(queueItem.text || '').trim()
|
||||
});
|
||||
|
||||
// Process the queue if not already processing
|
||||
@@ -87,36 +100,78 @@ class SentenceQueueModule extends BaseModule {
|
||||
if (this.sentenceQueue.length === 0 || this.isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.isProcessing = true;
|
||||
const item = this.sentenceQueue[0]; // Don't remove yet
|
||||
|
||||
const item = this.sentenceQueue[0];
|
||||
|
||||
try {
|
||||
// Get TTS Factory
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
|
||||
if (!ttsFactory) {
|
||||
console.error("SentenceQueue: TTSFactory dependency not found");
|
||||
this.completeSentence(item, { success: false, reason: 'no_tts_factory' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a speech metadata object
|
||||
const speechMetadata = await this.prepareSpeechMetadata(item.text);
|
||||
|
||||
// If we have a callback for ready sentences, call it with the metadata
|
||||
if (this.onSentenceReadyCallback) {
|
||||
this.onSentenceReadyCallback(item.text, speechMetadata, () => {
|
||||
// Remove from queue and process next
|
||||
this.completeSentence(item, { success: true });
|
||||
});
|
||||
// Check if sentence is already in cache
|
||||
const cacheKey = `${item.id || ''}:${item.text}`;
|
||||
let sentence = this.preparedCache.get(cacheKey);
|
||||
|
||||
if (!sentence) {
|
||||
// Prepare complete sentence object (TTS + layout in parallel)
|
||||
sentence = await this.prepareSentence(item);
|
||||
} else {
|
||||
// No callback, just complete
|
||||
this.completeSentence(item, { success: true });
|
||||
console.log('SentenceQueue: Using cached sentence');
|
||||
this.preparedCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
// Prefetch next sentence while current displays
|
||||
if (this.sentenceQueue.length > 1) {
|
||||
const nextItem = this.sentenceQueue[1];
|
||||
const nextCacheKey = `${nextItem.id || ''}:${nextItem.text}`;
|
||||
if (!this.preparedCache.has(nextCacheKey)) {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-generating', reason: 'prefetch-start', sentenceId: nextItem.id }
|
||||
}));
|
||||
console.log('Process state: playing-generating', { reason: 'prefetch-start', sentenceId: nextItem.id });
|
||||
this.prepareSentence(nextItem)
|
||||
.then(prepared => {
|
||||
this.preparedCache.set(nextCacheKey, prepared);
|
||||
console.log('SentenceQueue: Prefetched next sentence');
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id });
|
||||
})
|
||||
.catch(err => console.warn('SentenceQueue: Prefetch failed:', err));
|
||||
}
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: item.id }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'no-prefetch-needed', sentenceId: item.id });
|
||||
}
|
||||
|
||||
// Notify display handler with complete sentence
|
||||
if (this.onSentenceReadyCallback) {
|
||||
await new Promise(resolve => {
|
||||
sentence.onComplete = resolve;
|
||||
this.onSentenceReadyCallback(sentence, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from queue and continue
|
||||
this.sentenceQueue.shift();
|
||||
if (item.callback) item.callback({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error processing sentence:", error);
|
||||
this.completeSentence(item, { success: false, reason: error.message });
|
||||
console.error("SentenceQueue: Error processing sentence:", error);
|
||||
if (item.callback) item.callback({ success: false, error });
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
if (this.sentenceQueue.length > 0) {
|
||||
this.processNextSentence();
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'ready', reason: 'queue-empty' }
|
||||
}));
|
||||
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
|
||||
detail: { reason: 'sentence-queue-empty' }
|
||||
}));
|
||||
console.log('Process state: ready', { reason: 'queue-empty' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,14 +182,14 @@ class SentenceQueueModule extends BaseModule {
|
||||
*/
|
||||
async prepareSpeechMetadata(text) {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
const ttsPlayer = this.getModule('tts-player');
|
||||
|
||||
if (!ttsFactory || !ttsPlayer) {
|
||||
|
||||
if (!ttsFactory) {
|
||||
throw new Error("TTS dependencies not found");
|
||||
}
|
||||
|
||||
// Check if TTS is enabled
|
||||
const isTtsEnabled = ttsPlayer.isEnabled();
|
||||
|
||||
// Check if TTS is enabled via active handler
|
||||
const activeHandler = ttsFactory.getActiveHandler();
|
||||
const isTtsEnabled = activeHandler !== null;
|
||||
|
||||
// If TTS is disabled, estimate duration based on character count
|
||||
if (!isTtsEnabled) {
|
||||
@@ -175,26 +230,19 @@ class SentenceQueueModule extends BaseModule {
|
||||
* @returns {Object} - Speech metadata object with estimated duration
|
||||
*/
|
||||
estimateSpeechDuration(text) {
|
||||
// Average reading speed is about 14-15 characters per second
|
||||
// We'll use a slightly slower rate for TTS
|
||||
// Average aloud narration is around 12 characters per second at 1x.
|
||||
const charactersPerSecond = 12;
|
||||
const ttsPlayer = this.getModule('tts-player');
|
||||
|
||||
// Get the current speed setting if available
|
||||
|
||||
let speedMultiplier = 1.0;
|
||||
if (ttsPlayer) {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
// Get the current speed setting (typically 0.5-2.0)
|
||||
const speed = ttsFactory.speed || 1.0;
|
||||
speedMultiplier = speed;
|
||||
}
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
speedMultiplier = Number.isFinite(ttsFactory.speed) ? Math.max(0.25, ttsFactory.speed) : 1.0;
|
||||
}
|
||||
|
||||
// Calculate estimated duration in milliseconds
|
||||
const charCount = text.length;
|
||||
const durationSeconds = charCount / (charactersPerSecond * speedMultiplier);
|
||||
const durationMs = Math.max(durationSeconds * 1000, 500); // Minimum 500ms
|
||||
const durationMs = Math.max(durationSeconds * 1000, 800);
|
||||
|
||||
return {
|
||||
text: text,
|
||||
@@ -207,6 +255,264 @@ class SentenceQueueModule extends BaseModule {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a complete sentence object with TTS and layout
|
||||
* @param {string} text - Text to prepare
|
||||
* @returns {Promise<Object>} - Complete sentence object
|
||||
*/
|
||||
async prepareSentence(item) {
|
||||
const text = typeof item === 'string' ? item : item.text;
|
||||
const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const metadata = typeof item === 'object' && item !== null ? item : {};
|
||||
|
||||
try {
|
||||
if (metadata.type && metadata.type !== 'paragraph') {
|
||||
if (metadata.type === 'music') {
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager && typeof audioManager.playMusic === 'function') {
|
||||
audioManager.getAssetUrl('music', metadata.filename);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: metadata.type,
|
||||
text: text || '',
|
||||
status: 'ready',
|
||||
metadata,
|
||||
tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false },
|
||||
animation: { wordTimings: [], cueTimings: [], totalDuration: 0 },
|
||||
element: null,
|
||||
onComplete: null
|
||||
};
|
||||
}
|
||||
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager && typeof audioManager.preloadMediaCues === 'function') {
|
||||
await audioManager.preloadMediaCues(metadata.cueMarkers || []);
|
||||
}
|
||||
|
||||
// Prepare TTS and layout in parallel
|
||||
const [ttsData, layoutData] = await Promise.all([
|
||||
this.prepareSpeechMetadata(text),
|
||||
this.prepareLayout(text, metadata)
|
||||
]);
|
||||
|
||||
// Calculate animation timing based on TTS duration
|
||||
const words = this.extractWords(layoutData.nodes);
|
||||
const animation = this.calculateAnimationTiming(words, ttsData.duration, metadata.cueMarkers || []);
|
||||
|
||||
console.log(`SentenceQueue: Prepared sentence "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms, Words: ${words.length}, Animation total: ${animation.totalDuration}ms, Layout breaks: ${layoutData.breaks.length}`);
|
||||
|
||||
return {
|
||||
id,
|
||||
text,
|
||||
paragraphIndex: metadata.paragraphIndex ?? null,
|
||||
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
|
||||
role: metadata.role || 'body',
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
addTopSpace: Boolean(metadata.addTopSpace),
|
||||
cueMarkers: metadata.cueMarkers || [],
|
||||
status: 'ready',
|
||||
layout: layoutData,
|
||||
tts: {
|
||||
duration: ttsData.duration,
|
||||
provider: ttsData.handler,
|
||||
audioData: ttsData.audioData || null,
|
||||
play: ttsData.play,
|
||||
stop: ttsData.stop,
|
||||
enabled: ttsData.isTtsEnabled
|
||||
},
|
||||
animation: animation,
|
||||
element: null,
|
||||
onComplete: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('SentenceQueue: Error preparing sentence:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare layout for a sentence
|
||||
* @param {string} text - Text to prepare layout for
|
||||
* @returns {Promise<Object>} - Layout data
|
||||
*/
|
||||
async prepareLayout(text, metadata = {}) {
|
||||
const paragraphLayout = this.getModule('paragraph-layout');
|
||||
|
||||
if (!paragraphLayout) {
|
||||
throw new Error("ParagraphLayout module not found");
|
||||
}
|
||||
|
||||
try {
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
await document.fonts.ready;
|
||||
}
|
||||
|
||||
// Calculate layout with Knuth-Plass
|
||||
const storyElement = document.getElementById('story');
|
||||
if (!storyElement) {
|
||||
throw new Error("Story container not found");
|
||||
}
|
||||
|
||||
// Get actual CSS values from the paragraph typography rule, not the
|
||||
// container. The measured font and rendered font must be identical.
|
||||
const containerWidth = storyElement.clientWidth;
|
||||
const probe = document.createElement('p');
|
||||
probe.style.visibility = 'hidden';
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.left = '-8000px';
|
||||
probe.style.top = '-8000px';
|
||||
storyElement.appendChild(probe);
|
||||
const computedStyle = window.getComputedStyle(probe);
|
||||
const fontSize = parseFloat(computedStyle.fontSize);
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight);
|
||||
const fontFamily = computedStyle.fontFamily;
|
||||
probe.remove();
|
||||
|
||||
console.log(`SentenceQueue: Container metrics - width: ${containerWidth}px, fontSize: ${fontSize}px, lineHeight: ${lineHeight}px`);
|
||||
|
||||
// Standard book indentation: no indent on the first chapter paragraph,
|
||||
// first-line indent on following paragraphs.
|
||||
const dropCapLines = metadata.dropCap ? 2 : 0;
|
||||
const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0;
|
||||
const indentWidth = (metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
|
||||
const layoutText = metadata.layoutText || text;
|
||||
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
|
||||
|
||||
// Measures are consumed in line order by the line breaker.
|
||||
const measures = metadata.dropCap
|
||||
? [
|
||||
Math.max(120, containerWidth - dropCapWidth),
|
||||
Math.max(120, containerWidth - dropCapWidth),
|
||||
containerWidth
|
||||
]
|
||||
: [
|
||||
Math.max(120, containerWidth - indentWidth),
|
||||
containerWidth,
|
||||
containerWidth
|
||||
];
|
||||
|
||||
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}]`);
|
||||
|
||||
const layout = paragraphLayout.calculateLayout(layoutPlainText, {
|
||||
measures,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily,
|
||||
lineHeight: lineHeight / fontSize,
|
||||
lineHeightPx: lineHeight
|
||||
});
|
||||
|
||||
if (!layout) {
|
||||
throw new Error('Paragraph layout calculation failed');
|
||||
}
|
||||
|
||||
return {
|
||||
breaks: layout.breaks,
|
||||
nodes: layout.nodes,
|
||||
processedText: layout.processedText || text,
|
||||
sourceLayoutText: layoutText,
|
||||
measures,
|
||||
indentWidth,
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
|
||||
dropCapLines,
|
||||
addTopSpace: Boolean(metadata.addTopSpace),
|
||||
role: metadata.role || 'body',
|
||||
fontSize: layout.fontSize,
|
||||
fontFamily: layout.fontFamily,
|
||||
lineHeight: layout.lineHeight,
|
||||
lineHeightPx: layout.lineHeightPx
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('SentenceQueue: Error preparing layout:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract words from layout nodes
|
||||
* @param {Array} nodes - Layout nodes from Knuth-Plass algorithm
|
||||
* @returns {Array<string>} - Array of words
|
||||
*/
|
||||
extractWords(nodes) {
|
||||
if (!nodes || !Array.isArray(nodes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return nodes
|
||||
.filter(node => node.type === 'box')
|
||||
.map(node => node.value || '');
|
||||
}
|
||||
|
||||
getDropCapText(text) {
|
||||
const plain = String(text || '').replace(/<[^>]+>/g, '');
|
||||
const match = plain.match(/^([“"']?[A-Za-zÀ-ÖØ-öø-ÿ])/u);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
extractDropCapText(text) {
|
||||
const dropCap = this.getDropCapText(text);
|
||||
if (!dropCap) return text;
|
||||
return String(text).replace(dropCap, '').trimStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate animation timing based on TTS duration
|
||||
* @param {Array<string>} words - Array of words to animate
|
||||
* @param {number} totalDuration - Total duration in milliseconds
|
||||
* @returns {Object} - Animation timing data
|
||||
*/
|
||||
calculateAnimationTiming(words, totalDuration, cueMarkers = []) {
|
||||
if (!words || words.length === 0) {
|
||||
return {
|
||||
wordTimings: [],
|
||||
cueTimings: [],
|
||||
totalDuration: 0
|
||||
};
|
||||
}
|
||||
|
||||
const totalChars = words.reduce((sum, word) => sum + word.length, 0);
|
||||
|
||||
if (totalChars === 0) {
|
||||
return {
|
||||
wordTimings: words.map(word => ({ word, delay: 0, duration: 0 })),
|
||||
cueTimings: [],
|
||||
totalDuration: 0
|
||||
};
|
||||
}
|
||||
|
||||
const msPerChar = totalDuration / totalChars;
|
||||
|
||||
let currentDelay = 0;
|
||||
const wordTimings = words.map(word => {
|
||||
const duration = word.length * msPerChar;
|
||||
const timing = {
|
||||
word: word,
|
||||
delay: currentDelay,
|
||||
duration: duration
|
||||
};
|
||||
currentDelay += duration;
|
||||
return timing;
|
||||
});
|
||||
|
||||
const cueTimings = (cueMarkers || []).map(cue => {
|
||||
const wordIndex = Math.max(0, Math.min(cue.wordIndex || 0, wordTimings.length - 1));
|
||||
const timing = wordTimings[wordIndex] || { delay: currentDelay };
|
||||
return {
|
||||
...cue,
|
||||
delay: timing.delay
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
wordTimings,
|
||||
cueTimings,
|
||||
totalDuration: Math.round(currentDelay)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete processing of a sentence
|
||||
* @param {Object} item - Queue item
|
||||
|
||||
@@ -27,6 +27,13 @@ class SocketClientModule extends BaseModule {
|
||||
'disconnect',
|
||||
'send',
|
||||
'sendCommand',
|
||||
'callGameApi',
|
||||
'newGame',
|
||||
'loadGame',
|
||||
'saveGame',
|
||||
'hasSaveGame',
|
||||
'getSaveGames',
|
||||
'isGameRunning',
|
||||
'requestStartGame',
|
||||
'requestSaveGame',
|
||||
'requestLoadGame',
|
||||
@@ -186,6 +193,9 @@ class SocketClientModule extends BaseModule {
|
||||
// Add text to the buffer if available
|
||||
if (this.textBuffer) {
|
||||
console.log(`Socket Client: Processing text fragment: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'waiting-generating', reason: 'server-response-received' }
|
||||
}));
|
||||
this.textBuffer.addText(text);
|
||||
} else {
|
||||
console.error('Socket Client: Text buffer not available');
|
||||
@@ -277,6 +287,9 @@ class SocketClientModule extends BaseModule {
|
||||
|
||||
try {
|
||||
this.socket.emit('playerCommand', { command });
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'command-waiting', reason: 'command-sent', command }
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error sending command:', error);
|
||||
@@ -284,23 +297,57 @@ class SocketClientModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
async callGameApi(method, args = []) {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
const connected = await this.connect();
|
||||
if (!connected || !this.socket) {
|
||||
return { success: false, error: 'not_connected' };
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!this.socket) {
|
||||
resolve({ success: false, error: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit('gameApi', { method, args }, (response) => {
|
||||
resolve(response || { success: false, error: 'empty_response' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
newGame() {
|
||||
return this.callGameApi('newGame', []);
|
||||
}
|
||||
|
||||
loadGame(slot = 1) {
|
||||
return this.callGameApi('loadGame', [slot]);
|
||||
}
|
||||
|
||||
saveGame(slot = 1) {
|
||||
return this.callGameApi('saveGame', [slot]);
|
||||
}
|
||||
|
||||
hasSaveGame(slot = 1) {
|
||||
return this.callGameApi('hasSaveGame', [slot]);
|
||||
}
|
||||
|
||||
getSaveGames() {
|
||||
return this.callGameApi('getSaveGames', []);
|
||||
}
|
||||
|
||||
isGameRunning() {
|
||||
return this.callGameApi('isGameRunning', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to start a new game
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
requestStartGame() {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot start game');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('startGame');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error starting game:', error);
|
||||
return false;
|
||||
}
|
||||
this.newGame();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,18 +355,8 @@ class SocketClientModule extends BaseModule {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
requestSaveGame() {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot save game');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('saveGame');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error saving game:', error);
|
||||
return false;
|
||||
}
|
||||
this.saveGame(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,18 +364,8 @@ class SocketClientModule extends BaseModule {
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
requestLoadGame() {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot load game');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('loadGame');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error loading game:', error);
|
||||
return false;
|
||||
}
|
||||
this.loadGame(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,10 +13,15 @@ class TextBufferModule extends BaseModule {
|
||||
this.processingLock = false;
|
||||
this.processingQueue = [];
|
||||
this.isProcessingActive = false;
|
||||
this.paragraphCounter = 0;
|
||||
this.currentTextBlockId = 0;
|
||||
this.markupParser = null;
|
||||
this.dependencies = ['markup-parser'];
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'addText',
|
||||
'splitIntoParagraphs',
|
||||
'processNextFromQueue',
|
||||
'processSentences',
|
||||
'processNextSentence',
|
||||
@@ -33,6 +38,11 @@ class TextBufferModule extends BaseModule {
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
this.markupParser = this.getModule('markup-parser');
|
||||
if (!this.markupParser) {
|
||||
console.warn("TextBuffer: Markup parser not found, using plain paragraph splitting");
|
||||
}
|
||||
|
||||
// Use parent's addEventListener for automatic cleanup
|
||||
this.addEventListener(document, 'ui:paragraph:complete', (event) => {
|
||||
if (this.processingQueue.length > 0 && !this.isProcessingActive) {
|
||||
@@ -83,9 +93,39 @@ class TextBufferModule extends BaseModule {
|
||||
if (!text) return;
|
||||
|
||||
console.log(`TextBuffer: Adding text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Add to processing queue instead of directly to buffer
|
||||
this.processingQueue.push(text);
|
||||
|
||||
const blocks = this.markupParser && typeof this.markupParser.parse === 'function'
|
||||
? this.markupParser.parse(text)
|
||||
: this.splitIntoParagraphs(text).map(paragraphText => ({
|
||||
type: 'paragraph',
|
||||
text: paragraphText,
|
||||
layoutText: paragraphText,
|
||||
cueMarkers: [],
|
||||
role: 'body',
|
||||
isFirstParagraphInChapter: false
|
||||
}));
|
||||
|
||||
blocks.forEach(block => {
|
||||
if (block.type === 'paragraph') {
|
||||
const paragraphId = `paragraph-${this.paragraphCounter + 1}`;
|
||||
this.processingQueue.push({
|
||||
...block,
|
||||
id: paragraphId,
|
||||
paragraphIndex: this.paragraphCounter,
|
||||
textBlockId: this.currentTextBlockId
|
||||
});
|
||||
this.paragraphCounter += 1;
|
||||
} else {
|
||||
this.processingQueue.push({
|
||||
...block,
|
||||
id: `${block.type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
});
|
||||
|
||||
if (block.type === 'image') {
|
||||
this.currentTextBlockId += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Process the queue if not already processing
|
||||
if (!this.isProcessingActive && this.onSentenceReadyCallback) {
|
||||
@@ -94,6 +134,20 @@ class TextBufferModule extends BaseModule {
|
||||
console.log('TextBuffer: Text queued for processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an incoming narrative fragment into book paragraphs.
|
||||
* Single newlines inside a paragraph are normalized to spaces; blank lines
|
||||
* mark distinct paragraphs.
|
||||
* @param {string} text - Raw text fragment
|
||||
* @returns {Array<string>} - Normalized paragraph strings
|
||||
*/
|
||||
splitIntoParagraphs(text) {
|
||||
return String(text)
|
||||
.split(/\n\s*\n/g)
|
||||
.map(paragraph => paragraph.replace(/\s*\n\s*/g, ' ').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the next text fragment from the queue
|
||||
@@ -104,18 +158,32 @@ class TextBufferModule extends BaseModule {
|
||||
}
|
||||
|
||||
this.isProcessingActive = true;
|
||||
const text = this.processingQueue.shift();
|
||||
const paragraph = this.processingQueue.shift();
|
||||
|
||||
console.log(`TextBuffer: Processing next fragment from queue, remaining: ${this.processingQueue.length}`);
|
||||
|
||||
// Add text to buffer
|
||||
this.buffer += text;
|
||||
|
||||
// If we have a trailing newline as a complete sentence, add a period
|
||||
this.buffer = this.buffer.replace(/\n$/g, '.\n');
|
||||
|
||||
// Process sentences
|
||||
this.processSentences();
|
||||
|
||||
this.buffer = paragraph.text;
|
||||
|
||||
super.dispatchEvent('buffer:sentence', {
|
||||
sentence: paragraph.text,
|
||||
paragraph,
|
||||
remaining: this.processingQueue.length
|
||||
});
|
||||
|
||||
if (this.onSentenceReadyCallback) {
|
||||
this.onSentenceReadyCallback(paragraph, () => {});
|
||||
this.buffer = '';
|
||||
this.isProcessingActive = false;
|
||||
this.processingLock = false;
|
||||
|
||||
if (this.processingQueue.length > 0) {
|
||||
queueMicrotask(() => this.processNextFromQueue());
|
||||
} else {
|
||||
super.dispatchEvent('buffer:empty', {});
|
||||
}
|
||||
} else {
|
||||
this.isProcessingActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,6 +306,8 @@ class TextBufferModule extends BaseModule {
|
||||
this.processingQueue = [];
|
||||
this.isProcessingActive = false;
|
||||
this.processingLock = false;
|
||||
this.paragraphCounter = 0;
|
||||
this.currentTextBlockId = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Handles text formatting and typography enhancements like smart quotes and hyphenation
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import Hyphenopoly from './hyphenopoly.module.js';
|
||||
|
||||
class TextProcessorModule extends BaseModule {
|
||||
constructor() {
|
||||
@@ -25,7 +24,9 @@ class TextProcessorModule extends BaseModule {
|
||||
'isHyphenationAvailable',
|
||||
'hyphenate',
|
||||
'setLocale',
|
||||
'handleLocaleChanged'
|
||||
'handleLocaleChanged',
|
||||
'loadHyphenopolyLoader',
|
||||
'normalizeHyphenationLocale'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -161,104 +162,89 @@ class TextProcessorModule extends BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize hyphenation using Hyphenopoly module
|
||||
* Initialize hyphenation using the browser Hyphenopoly loader used by the prototype.
|
||||
* @returns {Promise<boolean>} - Resolves when hyphenation is initialized
|
||||
*/
|
||||
initializeHyphenation() {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
console.log("Initializing hyphenation with Hyphenopoly module");
|
||||
|
||||
// Configure Hyphenopoly with our requirements
|
||||
const hyphenatorPromise = Hyphenopoly.config({
|
||||
require: [this.locale],
|
||||
hyphen: '\u00AD', // Soft hyphen character
|
||||
minWordLength: 5,
|
||||
leftmin: 2,
|
||||
rightmin: 2,
|
||||
compound: "hyphen",
|
||||
// Define a custom loader for the patterns
|
||||
loader: (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Determine correct pattern file based on locale
|
||||
let patternFile = file;
|
||||
|
||||
// Special handling for 'en' locale - use en-us.wasm if available
|
||||
if (file === 'en.wasm') {
|
||||
patternFile = 'en-us.wasm';
|
||||
}
|
||||
|
||||
const patternPath = `/js/patterns/${patternFile}`;
|
||||
console.log(`Loading hyphenation pattern: ${patternPath}`);
|
||||
|
||||
fetch(patternPath)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${file}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
})
|
||||
.then(arrayBuffer => {
|
||||
resolve(arrayBuffer);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Error loading hyphenation pattern ${file}:`, error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
handleEvent: {
|
||||
error: (e) => {
|
||||
console.warn(`Hyphenopoly error: ${e.msg}`);
|
||||
},
|
||||
engineReady: (e) => {
|
||||
console.log(`Hyphenopoly engine ready for ${e.msg}`);
|
||||
}
|
||||
async initializeHyphenation() {
|
||||
try {
|
||||
console.log("Initializing hyphenation with browser Hyphenopoly loader");
|
||||
|
||||
const locale = this.normalizeHyphenationLocale(this.locale);
|
||||
this.hyphenator = null;
|
||||
this.hyphenatorReady = false;
|
||||
|
||||
await this.loadHyphenopolyLoader();
|
||||
|
||||
window.Hyphenopoly.config({
|
||||
require: {
|
||||
[locale]: "FORCEHYPHENOPOLY"
|
||||
},
|
||||
paths: {
|
||||
maindir: "/js/",
|
||||
patterndir: "/js/patterns/"
|
||||
},
|
||||
setup: {
|
||||
hide: "element",
|
||||
selectors: {
|
||||
".hyphenate": { hyphen: "\u00AD" },
|
||||
".hyphenatePipe": { hyphen: "|" }
|
||||
}
|
||||
});
|
||||
|
||||
// Get the hyphenator for our locale
|
||||
hyphenatorPromise.get(this.locale)
|
||||
.then(hyphenator => {
|
||||
this.hyphenator = hyphenator;
|
||||
this.hyphenatorReady = true;
|
||||
console.log(`Hyphenator ready for ${this.locale}`);
|
||||
|
||||
// Dispatch event that hyphenation is ready
|
||||
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
||||
resolve(true); // Successfully initialized
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Failed to initialize hyphenator for ${this.locale}:`, error);
|
||||
// Try to fall back to en-us if the current locale failed
|
||||
if (this.locale !== 'en-us') {
|
||||
console.log("Falling back to en-us hyphenation");
|
||||
return hyphenatorPromise.get('en-us');
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.then(fallbackHyphenator => {
|
||||
if (fallbackHyphenator) {
|
||||
this.hyphenator = fallbackHyphenator;
|
||||
this.hyphenatorReady = true;
|
||||
console.log("Using fallback en-us hyphenator");
|
||||
|
||||
// Dispatch event that hyphenation is ready
|
||||
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
||||
resolve(true); // Successfully initialized with fallback
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Failed to initialize hyphenation even with fallback:", error);
|
||||
reject(error); // Failed to initialize
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error setting up hyphenation:", error);
|
||||
reject(error); // Failed to initialize
|
||||
},
|
||||
handleEvent: {
|
||||
error: (event) => {
|
||||
console.warn(`Hyphenopoly error: ${event.msg || event.message || event.type}`);
|
||||
},
|
||||
engineReady: (event) => {
|
||||
console.log(`Hyphenopoly engine ready: ${event.msg || locale}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.hyphenator = await window.Hyphenopoly.hyphenators[locale];
|
||||
this.hyphenatorReady = true;
|
||||
this.locale = locale;
|
||||
|
||||
console.log(`Hyphenator ready for ${locale}`);
|
||||
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.hyphenator = null;
|
||||
this.hyphenatorReady = false;
|
||||
console.error("Failed to initialize Hyphenopoly browser hyphenation:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
loadHyphenopolyLoader() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.Hyphenopoly && typeof window.Hyphenopoly.config === 'function') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector('script[src="/js/Hyphenopoly_Loader.js"]');
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', () => resolve(), { once: true });
|
||||
existingScript.addEventListener('error', () => reject(new Error('Failed to load Hyphenopoly loader')), { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/Hyphenopoly_Loader.js';
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load Hyphenopoly loader'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
normalizeHyphenationLocale(locale) {
|
||||
const normalized = String(locale || 'en-us').toLowerCase();
|
||||
if (normalized === 'en') return 'en-us';
|
||||
if (normalized === 'de-de') return 'de';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hyphenation is available
|
||||
* @returns {boolean} - True if hyphenation is available
|
||||
@@ -270,15 +256,17 @@ class TextProcessorModule extends BaseModule {
|
||||
/**
|
||||
* Hyphenate a text using the Hyphenopoly module
|
||||
* @param {string} text - The text to hyphenate
|
||||
* @param {string} selector - Optional selector for Hyphenopoly
|
||||
* @returns {string} - The hyphenated text
|
||||
*/
|
||||
hyphenate(text) {
|
||||
hyphenate(text, selector = null) {
|
||||
if (!this.isHyphenationAvailable()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
return this.hyphenator(text);
|
||||
// If selector provided, pass it to hyphenator
|
||||
return selector ? this.hyphenator(text, selector) : this.hyphenator(text);
|
||||
} catch (error) {
|
||||
console.error("Error hyphenating text:", error);
|
||||
return text;
|
||||
@@ -291,27 +279,31 @@ class TextProcessorModule extends BaseModule {
|
||||
* @param {Object} options - Processing options
|
||||
* @param {boolean} [options.smartypants=true] - Whether to apply SmartyPants processing
|
||||
* @param {boolean} [options.hyphenate=true] - Whether to apply hyphenation
|
||||
* @param {string} [options.hyphenSelector=null] - Selector for hyphen character (e.g., '.hyphenatePipe')
|
||||
* @returns {string} - The processed text
|
||||
*/
|
||||
process(text, options = {}) {
|
||||
const opts = {
|
||||
smartypants: true,
|
||||
hyphenate: true,
|
||||
hyphenSelector: null,
|
||||
...options
|
||||
};
|
||||
|
||||
|
||||
let result = text;
|
||||
|
||||
|
||||
// Apply SmartyPants if available and requested
|
||||
if (opts.smartypants && this.smartyPants) {
|
||||
if (opts.smartypants && this.smartypantsu) {
|
||||
result = this.smartypantsu(result, 1);
|
||||
} else if (opts.smartypants && this.smartyPants) {
|
||||
result = this.smartyPants(result);
|
||||
}
|
||||
|
||||
|
||||
// Apply hyphenation if available and requested
|
||||
if (opts.hyphenate && this.isHyphenationAvailable()) {
|
||||
result = this.hyphenate(result);
|
||||
result = this.hyphenate(result, opts.hyphenSelector);
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+351
-91
@@ -23,7 +23,10 @@ class TTSFactoryModule extends BaseModule {
|
||||
this.initStatus = {};
|
||||
this.activeHandler = null;
|
||||
this.ttsAvailable = false;
|
||||
this.speed = 1; // Default speed
|
||||
this.speed = 1; // Speech speed multiplier. 1.0 is normal speed.
|
||||
this.language = 'en-us';
|
||||
this.voice = '';
|
||||
this.volume = 1.0;
|
||||
|
||||
// IndexedDB Cache Configuration
|
||||
this.db = null; // Will hold the DB connection
|
||||
@@ -48,6 +51,7 @@ class TTSFactoryModule extends BaseModule {
|
||||
'stop',
|
||||
'pause',
|
||||
'resume',
|
||||
'fadeOut',
|
||||
'getVoices',
|
||||
'getPreference',
|
||||
'isSpeaking',
|
||||
@@ -76,22 +80,29 @@ class TTSFactoryModule extends BaseModule {
|
||||
'registerHandlers',
|
||||
'initializeHandlerSystem',
|
||||
'debugLogAllRegisteredModules',
|
||||
'debugTTSHandlers' // Added method
|
||||
'debugTTSHandlers',
|
||||
'emitProcessState',
|
||||
'getEffectiveVoiceId',
|
||||
'disableAfterCurrentPlayback',
|
||||
'getHandlerStatuses',
|
||||
'getVoicesForHandler',
|
||||
'refreshHandlerStatus'
|
||||
]);
|
||||
|
||||
// Listen for kokoro:ready event
|
||||
document.addEventListener('kokoro:ready', (event) => {
|
||||
if (event.detail && typeof event.detail.success === 'boolean') {
|
||||
console.log('TTS Factory: Received kokoro:ready event with success =', event.detail.success);
|
||||
this.initStatus['kokoro'] = event.detail.success;
|
||||
this.initStatus['kokoro-tts'] = event.detail.success;
|
||||
|
||||
// If this is the current active handler or we don't have an active handler yet,
|
||||
// try to activate Kokoro if it's now ready
|
||||
if ((this.activeHandler === 'kokoro' || !this.activeHandler) && event.detail.success) {
|
||||
// Only attempt to set active handler if TTS is enabled
|
||||
if ((this.activeHandler === 'kokoro-tts' || !this.activeHandler) && event.detail.success) {
|
||||
// Only activate Kokoro when it was explicitly selected.
|
||||
const ttsEnabled = this.getPreference('tts', 'enabled', false);
|
||||
if (ttsEnabled) {
|
||||
this.setActiveHandler('kokoro');
|
||||
const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none');
|
||||
if (ttsEnabled && preferredHandler === 'kokoro-tts') {
|
||||
this.setActiveHandler('kokoro-tts');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +110,14 @@ class TTSFactoryModule extends BaseModule {
|
||||
this.updateTTSAvailability();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('tts:speechCompleted', () => {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const enabled = persistenceManager?.getPreference('tts', 'enabled', false);
|
||||
if (!enabled && this.activeHandler) {
|
||||
this.setActiveHandler('none');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for handler availability changes
|
||||
document.addEventListener('tts:handler:availabilityChanged', (event) => {
|
||||
@@ -198,15 +217,29 @@ class TTSFactoryModule extends BaseModule {
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for speed change events from UI
|
||||
document.addEventListener('tts:speed:change', (event) => {
|
||||
if (event.detail && typeof event.detail.speed === 'number') {
|
||||
this.configure({ speed: event.detail.speed });
|
||||
console.log(`TTS Factory: Speed updated to ${this.speed} from UI event`);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('locale-changed', (event) => {
|
||||
if (event.detail?.locale) {
|
||||
this.configure({ language: event.detail.locale });
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for kokoro error events
|
||||
document.addEventListener('kokoro:error', (event) => {
|
||||
console.error('TTS Factory: Received kokoro error event:', event.detail);
|
||||
if (this.handlers['kokoro']) {
|
||||
this.initStatus['kokoro'] = false;
|
||||
if (this.handlers['kokoro-tts']) {
|
||||
this.initStatus['kokoro-tts'] = false;
|
||||
this.updateTTSAvailability();
|
||||
|
||||
|
||||
// If kokoro was our active handler, try fallback
|
||||
if (this.activeHandler === 'kokoro') {
|
||||
if (this.activeHandler === 'kokoro-tts') {
|
||||
console.warn('TTS Factory: Kokoro handler failed, falling back');
|
||||
this.attemptFallbackHandler();
|
||||
}
|
||||
@@ -367,8 +400,8 @@ class TTSFactoryModule extends BaseModule {
|
||||
|
||||
// Default settings for first run
|
||||
const defaults = {
|
||||
'speed': 0.5, // Default speech rate (0-1 range)
|
||||
'preferred_handler': 'kokoro', // Default to Kokoro TTS
|
||||
'speed': 1.0, // Default speech speed multiplier
|
||||
'preferred_handler': 'none', // Development default: TTS disabled
|
||||
'enabled': false, // TTS disabled by default
|
||||
'voice': '', // Empty default - will be selected based on handler
|
||||
'language': 'en-US', // Default language
|
||||
@@ -387,9 +420,10 @@ class TTSFactoryModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
// Load speech rate preference
|
||||
// Load speech rate preference exactly as persisted. Do not migrate or
|
||||
// rewrite this value on load; the UI must reflect the browser state.
|
||||
const savedSpeed = persistenceManager.getPreference('tts', 'speed');
|
||||
if (typeof savedSpeed === 'number') {
|
||||
if (Number.isFinite(savedSpeed)) {
|
||||
this.speed = savedSpeed;
|
||||
console.log(`TTS Factory: Loaded speed preference: ${this.speed}`);
|
||||
} else {
|
||||
@@ -400,6 +434,9 @@ class TTSFactoryModule extends BaseModule {
|
||||
// Load other preferences we need for initialization
|
||||
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler');
|
||||
console.log(`TTS Factory: Loaded preferred handler: ${preferredHandler || 'none'}`);
|
||||
this.language = persistenceManager.getPreference('tts', 'language', defaults.language);
|
||||
this.voice = persistenceManager.getPreference('tts', 'voice', defaults.voice);
|
||||
this.volume = persistenceManager.getPreference('tts', 'volume', defaults.volume);
|
||||
|
||||
// We'll handle the preferred handler in initializeHandlerSystem()
|
||||
|
||||
@@ -469,7 +506,14 @@ class TTSFactoryModule extends BaseModule {
|
||||
|
||||
if (persistenceManager) {
|
||||
preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler');
|
||||
const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false);
|
||||
console.log(`TTS Factory: Preferred handler from settings: ${preferredHandler || 'none'}`);
|
||||
if (!ttsEnabled) {
|
||||
console.log('TTS Factory: TTS toggle is disabled, starting with no active handler');
|
||||
this.activeHandler = null;
|
||||
this.updateTTSAvailability();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Special case for 'none' preference
|
||||
@@ -500,8 +544,11 @@ class TTSFactoryModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a preferred handler or it's not registered, try fallbacks
|
||||
return this.attemptFallbackHandler();
|
||||
// Default to no TTS. Games or users can explicitly select a provider later.
|
||||
console.log('TTS Factory: No preferred TTS handler selected, defaulting to none');
|
||||
this.activeHandler = null;
|
||||
this.updateTTSAvailability();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,8 +556,8 @@ class TTSFactoryModule extends BaseModule {
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async attemptFallbackHandler() {
|
||||
// Fallback order: Kokoro -> Browser -> None
|
||||
const fallbackOrder = ['kokoro', 'browser'];
|
||||
// Providers are opt-in. Keep the baseline as text-only unless explicitly selected.
|
||||
const fallbackOrder = [];
|
||||
|
||||
// Try each fallback in order
|
||||
for (const handlerId of fallbackOrder) {
|
||||
@@ -529,8 +576,8 @@ class TTSFactoryModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
// If all fallbacks failed, update TTS availability
|
||||
console.warn('TTS Factory: All handlers failed to initialize, TTS will be unavailable');
|
||||
// If no explicit provider is selected, update TTS availability and continue.
|
||||
console.log('TTS Factory: No fallback handler selected, TTS will be unavailable');
|
||||
this.activeHandler = null;
|
||||
this.updateTTSAvailability();
|
||||
|
||||
@@ -603,11 +650,16 @@ class TTSFactoryModule extends BaseModule {
|
||||
persistenceManager.updatePreference('tts', 'preferred_handler', 'none');
|
||||
}
|
||||
|
||||
// Dispatch event
|
||||
// Dispatch events
|
||||
document.dispatchEvent(new CustomEvent('tts:handler:changed', {
|
||||
detail: { handler: 'none', available: false }
|
||||
}));
|
||||
|
||||
|
||||
// Also dispatch tts:engine:change for compatibility with Options UI
|
||||
document.dispatchEvent(new CustomEvent('tts:engine:change', {
|
||||
detail: { engine: 'none', handler: 'none', available: false }
|
||||
}));
|
||||
|
||||
this.updateTTSAvailability();
|
||||
return true;
|
||||
}
|
||||
@@ -629,17 +681,17 @@ class TTSFactoryModule extends BaseModule {
|
||||
|
||||
console.log(`TTS Factory: Setting active handler to ${id}`);
|
||||
|
||||
// Check if the handler is ready (just for logging)
|
||||
if (this.handlers[id].isReady !== true) {
|
||||
console.log(`TTS Factory: Initializing handler ${id} before activation`);
|
||||
await this.initializeHandler(id);
|
||||
}
|
||||
|
||||
// Check if the handler is ready after initialization
|
||||
const isReady = this.handlers[id].isReady === true;
|
||||
if (!isReady) {
|
||||
console.warn(`TTS Factory: Handler ${id} is not ready - TTS will be considered disabled until ready`);
|
||||
}
|
||||
|
||||
// Stop any current speech
|
||||
if (this.activeHandler && this.handlers[this.activeHandler]) {
|
||||
this.handlers[this.activeHandler].stop();
|
||||
}
|
||||
|
||||
// Set the new active handler
|
||||
this.activeHandler = id;
|
||||
|
||||
@@ -647,17 +699,34 @@ class TTSFactoryModule extends BaseModule {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'preferred_handler', id);
|
||||
this.voice = persistenceManager.getPreference('tts', 'voice', this.voice || '');
|
||||
this.language = persistenceManager.getPreference('tts', 'language', this.language || 'en-us');
|
||||
this.speed = persistenceManager.getPreference('tts', 'speed', this.speed || 1.0);
|
||||
}
|
||||
|
||||
const handler = this.handlers[id];
|
||||
if (handler && typeof handler.setVoiceOptions === 'function') {
|
||||
handler.setVoiceOptions({
|
||||
voice: this.voice,
|
||||
speed: this.speed,
|
||||
language: this.language
|
||||
});
|
||||
}
|
||||
|
||||
// Dispatch event
|
||||
// Dispatch events
|
||||
const event = new CustomEvent('tts:handler:changed', {
|
||||
detail: { handler: id, available: isReady }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
|
||||
// Also dispatch tts:engine:change for compatibility with Options UI
|
||||
document.dispatchEvent(new CustomEvent('tts:engine:change', {
|
||||
detail: { engine: id, handler: id, available: isReady }
|
||||
}));
|
||||
|
||||
// Update overall TTS availability
|
||||
this.updateTTSAvailability();
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -725,7 +794,7 @@ class TTSFactoryModule extends BaseModule {
|
||||
const preloadData = await handler.preloadSpeech(text);
|
||||
if (preloadData && preloadData.success) {
|
||||
// Cache the speech
|
||||
await this.cacheSpeech(hash, preloadData);
|
||||
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
|
||||
|
||||
// Speak the preloaded speech
|
||||
return handler.speakPreloaded(preloadData, result => {
|
||||
@@ -790,13 +859,13 @@ class TTSFactoryModule extends BaseModule {
|
||||
// If the handler has a preloadSpeech method, use it
|
||||
if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') {
|
||||
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text);
|
||||
|
||||
// Cache the generated speech data
|
||||
if (preloadData) {
|
||||
await this.cacheSpeech(hash, preloadData);
|
||||
|
||||
// Cache the generated speech data (extract audioData from result object)
|
||||
if (preloadData && preloadData.audioData) {
|
||||
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
|
||||
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`);
|
||||
}
|
||||
|
||||
|
||||
return preloadData;
|
||||
} else {
|
||||
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`);
|
||||
@@ -862,6 +931,24 @@ class TTSFactoryModule extends BaseModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
fadeOut(duration = 1000) {
|
||||
const handlers = Object.values(this.handlers);
|
||||
const fades = handlers.map(handler => {
|
||||
if (!handler) return Promise.resolve(false);
|
||||
if (typeof handler.fadeOutCurrentAudio === 'function') {
|
||||
return handler.fadeOutCurrentAudio(duration);
|
||||
} else if (handler.isSpeaking && typeof handler.stop === 'function') {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve(handler.stop());
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
return Promise.all(fades);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get voices from the active handler
|
||||
@@ -957,41 +1044,170 @@ class TTSFactoryModule extends BaseModule {
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
disableAfterCurrentPlayback() {
|
||||
const previousHandler = this.activeHandler;
|
||||
if (previousHandler) {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'last_enabled_handler', previousHandler);
|
||||
}
|
||||
}
|
||||
|
||||
this.activeHandler = null;
|
||||
console.log('TTS Factory: TTS disabled for future generation; current playback may finish');
|
||||
document.dispatchEvent(new CustomEvent('tts:handler:changed', {
|
||||
detail: { handler: 'none', available: false, previousHandler }
|
||||
}));
|
||||
document.dispatchEvent(new CustomEvent('tts:engine:change', {
|
||||
detail: { engine: 'none', handler: 'none', available: false, previousHandler }
|
||||
}));
|
||||
this.updateTTSAvailability();
|
||||
return true;
|
||||
}
|
||||
|
||||
getHandlerStatuses() {
|
||||
const statuses = [{
|
||||
id: 'none',
|
||||
name: 'None',
|
||||
ready: true,
|
||||
active: !this.activeHandler,
|
||||
message: 'Text-only mode'
|
||||
}];
|
||||
|
||||
for (const id in this.handlers) {
|
||||
const handler = this.handlers[id];
|
||||
statuses.push({
|
||||
id,
|
||||
name: typeof handler.getName === 'function' ? handler.getName() : id,
|
||||
ready: handler.isReady === true,
|
||||
active: this.activeHandler === id,
|
||||
message: this.getHandlerStatusMessage(id, handler)
|
||||
});
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
getHandlerStatusMessage(id, handler) {
|
||||
if (!handler) return 'Not registered';
|
||||
if (handler.isReady === true) return 'Ready';
|
||||
if (id === 'kokoro-tts') return handler.state === 'INITIALIZING' ? 'Loading model' : 'Not loaded';
|
||||
if (handler.apiKey === '') return 'API key missing';
|
||||
if (handler.apiKey && handler.isReady !== true) return 'API unavailable or invalid settings';
|
||||
return 'Not ready';
|
||||
}
|
||||
|
||||
async refreshHandlerStatus(id) {
|
||||
if (!id || id === 'none') {
|
||||
this.updateTTSAvailability();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.handlers[id]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const handler = this.handlers[id];
|
||||
if (id === 'kokoro-tts') {
|
||||
this.updateTTSAvailability();
|
||||
return handler.isReady === true;
|
||||
}
|
||||
|
||||
const success = await this.initializeHandler(id);
|
||||
this.updateTTSAvailability();
|
||||
document.dispatchEvent(new CustomEvent('tts:status:updated', {
|
||||
detail: { statuses: this.getHandlerStatuses() }
|
||||
}));
|
||||
return success;
|
||||
}
|
||||
|
||||
async getVoicesForHandler(handlerId) {
|
||||
if (!handlerId || handlerId === 'none' || !this.handlers[handlerId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handler = this.handlers[handlerId];
|
||||
if (handler.isReady !== true && handlerId !== 'kokoro-tts') {
|
||||
await this.initializeHandler(handlerId);
|
||||
}
|
||||
|
||||
if (typeof handler.getVoices === 'function') {
|
||||
return await handler.getVoices() || [];
|
||||
}
|
||||
|
||||
return Array.isArray(handler.voices) ? handler.voices : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure TTS settings for all handlers
|
||||
* @param {Object} options - TTS options
|
||||
* @param {number} [options.speed] - Normalized speech rate (0-1 range)
|
||||
* @param {number} [options.speed] - Speech speed multiplier
|
||||
*/
|
||||
configure(options = {}) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const voiceOptions = {};
|
||||
|
||||
// Handle speed option
|
||||
if (typeof options.speed === 'number') {
|
||||
// Save speed setting
|
||||
this.speed = Math.max(0.1, Math.min(3.0, options.speed));
|
||||
|
||||
// Save to preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
this.speed = Math.max(0.5, Math.min(2.0, options.speed));
|
||||
voiceOptions.speed = this.speed;
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'speed', this.speed);
|
||||
}
|
||||
|
||||
// Update all handlers
|
||||
for (const id in this.handlers) {
|
||||
const handler = this.handlers[id];
|
||||
if (handler && typeof handler.setVoiceOptions === 'function') {
|
||||
handler.setVoiceOptions({ speed: this.speed });
|
||||
}
|
||||
|
||||
if (typeof options.voice === 'string' && options.voice) {
|
||||
this.voice = options.voice;
|
||||
voiceOptions.voice = options.voice;
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'voice', options.voice);
|
||||
if (this.activeHandler) {
|
||||
persistenceManager.updatePreference('tts', `${this.activeHandler}_voice`, options.voice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.language === 'string' && options.language) {
|
||||
this.language = options.language.toLowerCase();
|
||||
voiceOptions.language = this.language;
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'language', this.language);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.volume === 'number') {
|
||||
this.volume = Math.max(0, Math.min(1, options.volume));
|
||||
voiceOptions.volume = this.volume;
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'volume', this.volume);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id in this.handlers) {
|
||||
const handler = this.handlers[id];
|
||||
if (handler && typeof handler.setVoiceOptions === 'function') {
|
||||
handler.setVoiceOptions(voiceOptions);
|
||||
} else if (handler && typeof handler.configure === 'function') {
|
||||
handler.configure(voiceOptions);
|
||||
}
|
||||
if (voiceOptions.language && !voiceOptions.voice && handler && typeof handler.selectVoiceForLocale === 'function') {
|
||||
handler.selectVoiceForLocale(voiceOptions.language);
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI that TTS settings have changed
|
||||
document.dispatchEvent(new CustomEvent('tts:configured', {
|
||||
detail: {
|
||||
options: { speed: this.speed },
|
||||
options: {
|
||||
speed: this.speed,
|
||||
voice: this.voice,
|
||||
language: this.language,
|
||||
volume: this.volume
|
||||
},
|
||||
activeHandler: this.activeHandler
|
||||
}
|
||||
}));
|
||||
@@ -1016,6 +1232,7 @@ class TTSFactoryModule extends BaseModule {
|
||||
const cachedData = await this.getCachedSpeech(hash);
|
||||
if (cachedData) {
|
||||
console.log(`TTS Factory: Using cached speech for hash ${hash} (hits: ${this.cacheHits}, misses: ${this.cacheMisses})`);
|
||||
this.emitProcessState('playing-ready', { reason: 'tts-cache-hit', hash });
|
||||
// Move this item to the end of the Map to mark it as most recently used
|
||||
// this.audioCache.delete(hash);
|
||||
// this.audioCache.set(hash, cachedData);
|
||||
@@ -1025,17 +1242,19 @@ class TTSFactoryModule extends BaseModule {
|
||||
|
||||
// Cache miss - need to generate new speech data
|
||||
this.cacheMisses++;
|
||||
this.emitProcessState('waiting-generating', { reason: 'tts-cache-miss', hash });
|
||||
|
||||
// If the handler has a preloadSpeech method, use it
|
||||
if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') {
|
||||
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text);
|
||||
|
||||
// Cache the generated speech data
|
||||
if (preloadData) {
|
||||
await this.cacheSpeech(hash, preloadData);
|
||||
|
||||
// Cache the generated speech data (extract audioData from result object)
|
||||
if (preloadData && preloadData.audioData) {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
return preloadData;
|
||||
} else {
|
||||
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`);
|
||||
@@ -1053,21 +1272,18 @@ class TTSFactoryModule extends BaseModule {
|
||||
* @returns {Promise<string>} - Hash string
|
||||
*/
|
||||
async generateSpeechHash(text) {
|
||||
// Get the active handler for voice information
|
||||
const handler = this.getActiveHandler();
|
||||
|
||||
// Include handler ID and voice options in the hash to ensure uniqueness across voices
|
||||
let voiceInfo = '';
|
||||
if (handler && handler.voiceOptions && handler.voiceOptions.voice) {
|
||||
// Use the voice ID or name to identify the voice
|
||||
voiceInfo = handler.voiceOptions.voice.id || handler.voiceOptions.voice;
|
||||
}
|
||||
|
||||
// Also include speed setting in the hash
|
||||
const provider = this.activeHandler || 'none';
|
||||
const voiceInfo = this.getEffectiveVoiceId(handler);
|
||||
const speed = this.speed || 1.0;
|
||||
|
||||
// Create a composite key for hashing
|
||||
const key = `${text}|${this.activeHandler}|${voiceInfo}|${speed}`;
|
||||
const language = this.language || 'en-us';
|
||||
const key = JSON.stringify({
|
||||
provider,
|
||||
voice: voiceInfo,
|
||||
speed,
|
||||
language,
|
||||
text
|
||||
});
|
||||
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
@@ -1117,17 +1333,22 @@ class TTSFactoryModule extends BaseModule {
|
||||
console.warn('TTS Factory: Cache not ready, cannot retrieve cached speech');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const item = await this._getDBItem(hash);
|
||||
if (item && item.audioData) {
|
||||
console.log(`TTS Factory: Found cached speech for hash ${hash}`);
|
||||
return item.audioData;
|
||||
// Return in the same format as handlers' preloadSpeech() method
|
||||
return {
|
||||
success: true,
|
||||
audioData: item.audioData,
|
||||
duration: item.duration || 0
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('TTS Factory: Error retrieving cached speech:', error);
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1137,24 +1358,26 @@ class TTSFactoryModule extends BaseModule {
|
||||
* @param {ArrayBuffer} audioData - Audio data to cache
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async cacheSpeech(hash, audioData) {
|
||||
async cacheSpeech(hash, audioData, duration = 0) {
|
||||
if (!this.db || this.cacheStatus !== 'ready') {
|
||||
console.warn('TTS Factory: Cache not ready, cannot cache speech');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (!audioData) {
|
||||
console.error('TTS Factory: No audio data provided to cache');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
console.log(`TTS Factory: cacheSpeech called with audioData:`, audioData ? `${audioData.byteLength} bytes` : 'UNDEFINED', 'duration:', duration);
|
||||
|
||||
try {
|
||||
// Make sure we have room in the cache
|
||||
await this.manageCacheSize(audioData.byteLength);
|
||||
|
||||
// Store the speech data
|
||||
await this._putDBItem(hash, audioData);
|
||||
|
||||
|
||||
// Store the speech data with duration
|
||||
await this._putDBItem(hash, audioData, duration);
|
||||
|
||||
console.log(`TTS Factory: Cached speech for hash ${hash}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -1243,6 +1466,20 @@ class TTSFactoryModule extends BaseModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getEffectiveVoiceId(handler = this.getActiveHandler()) {
|
||||
if (!handler) return this.voice || '';
|
||||
const voice = handler.voiceOptions?.voice || handler.currentVoice || this.voice || '';
|
||||
if (typeof voice === 'string') return voice;
|
||||
return voice.id || voice.name || '';
|
||||
}
|
||||
|
||||
emitProcessState(state, detail = {}) {
|
||||
console.log(`Process state: ${state}`, detail);
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state, ...detail }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens and initializes the IndexedDB database.
|
||||
@@ -1365,9 +1602,10 @@ class TTSFactoryModule extends BaseModule {
|
||||
* Adds or updates an item in the IndexedDB store.
|
||||
* @param {string} hash - The key (hash) of the item to store.
|
||||
* @param {ArrayBuffer} audioData - The audio data to cache.
|
||||
* @param {number} duration - Duration of the audio in milliseconds.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _putDBItem(hash, audioData) {
|
||||
async _putDBItem(hash, audioData, duration = 0) {
|
||||
if (!this.db || this.cacheStatus !== 'ready') {
|
||||
console.warn("IndexedDB not ready, cannot put item.");
|
||||
return Promise.reject(new Error("IndexedDB not ready"));
|
||||
@@ -1380,7 +1618,7 @@ class TTSFactoryModule extends BaseModule {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.put({ hash, audioData, size: audioData.byteLength, lastAccessed: Date.now() });
|
||||
const request = store.put({ hash, audioData, size: audioData.byteLength, duration: duration || 0, lastAccessed: Date.now() });
|
||||
|
||||
request.onerror = (event) => {
|
||||
console.error("Error putting item into IndexedDB:", event.target.error);
|
||||
@@ -1459,8 +1697,27 @@ class TTSFactoryModule extends BaseModule {
|
||||
if (typeof cursor.value.size === 'number') {
|
||||
totalSize += cursor.value.size;
|
||||
} else {
|
||||
console.warn(`Item with hash ${cursor.key} missing or invalid size property.`);
|
||||
// Optionally try to get blob size here, but might be slow
|
||||
// Old cache entry without size - calculate it
|
||||
if (cursor.value.audioData && cursor.value.audioData.byteLength) {
|
||||
const calculatedSize = cursor.value.audioData.byteLength;
|
||||
totalSize += calculatedSize;
|
||||
|
||||
// Update the entry with the size property
|
||||
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const updatedValue = {
|
||||
...cursor.value,
|
||||
size: calculatedSize
|
||||
};
|
||||
store.put(updatedValue);
|
||||
console.log(`Updated cache entry ${cursor.key} with size: ${calculatedSize} bytes`);
|
||||
} else {
|
||||
console.warn(`Item with hash ${cursor.key} missing size and cannot calculate - will be deleted`);
|
||||
// Delete invalid entry
|
||||
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
store.delete(cursor.key);
|
||||
}
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
@@ -1593,8 +1850,11 @@ class TTSFactoryModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't initialize the preferred handler, try fallbacks
|
||||
return this.attemptFallbackHandler();
|
||||
// Default to no TTS. Games or users can explicitly select a provider later.
|
||||
console.log('TTS Factory: No preferred TTS handler selected, defaulting to none');
|
||||
this.activeHandler = null;
|
||||
this.updateTTSAvailability();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1602,8 +1862,8 @@ class TTSFactoryModule extends BaseModule {
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async attemptFallbackHandler() {
|
||||
// Fallback order: Kokoro -> Browser -> None
|
||||
const fallbackOrder = ['kokoro', 'browser'];
|
||||
// Providers are opt-in. Keep the baseline as text-only unless explicitly selected.
|
||||
const fallbackOrder = [];
|
||||
|
||||
// Try each fallback in order
|
||||
for (const handlerId of fallbackOrder) {
|
||||
@@ -1622,8 +1882,8 @@ class TTSFactoryModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
// If all fallbacks failed, update TTS availability
|
||||
console.warn('TTS Factory: All handlers failed to initialize, TTS will be unavailable');
|
||||
// If no explicit provider is selected, update TTS availability and continue.
|
||||
console.log('TTS Factory: No fallback handler selected, TTS will be unavailable');
|
||||
this.activeHandler = null;
|
||||
this.updateTTSAvailability();
|
||||
|
||||
@@ -1737,4 +1997,4 @@ class TTSFactoryModule extends BaseModule {
|
||||
const TTSFactory = new TTSFactoryModule();
|
||||
|
||||
// Export the module
|
||||
export { TTSFactory };
|
||||
export { TTSFactory };
|
||||
|
||||
+312
-157
@@ -7,7 +7,7 @@ class UIControllerModule extends BaseModule {
|
||||
|
||||
// Remove 'tts' from direct dependencies to break circular dependency
|
||||
// UI Controller will access TTS through the Game Loop instead
|
||||
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client'];
|
||||
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator'];
|
||||
|
||||
// References to sub-modules
|
||||
this.displayHandler = null;
|
||||
@@ -43,6 +43,12 @@ class UIControllerModule extends BaseModule {
|
||||
'applyBookSizing',
|
||||
'setupEventListeners',
|
||||
'setupMainUI',
|
||||
'bindTopControls',
|
||||
'syncTopControls',
|
||||
'getStoredTtsPreference',
|
||||
'setStoredTtsPreference',
|
||||
'sliderValueFromSpeed',
|
||||
'speedFromSliderValue',
|
||||
'initializeTextBuffer',
|
||||
'showUI',
|
||||
'hideUI',
|
||||
@@ -103,15 +109,25 @@ class UIControllerModule extends BaseModule {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.reportProgress(50, 'Setting up event listeners');
|
||||
|
||||
// Set up event listeners between components
|
||||
this.setupEventListeners();
|
||||
|
||||
this.reportProgress(70, 'Setting up main UI');
|
||||
this.reportProgress(50, 'Setting up main UI');
|
||||
|
||||
// Initialize main UI container
|
||||
await this.setupMainUI();
|
||||
|
||||
this.reportProgress(70, 'Setting up event listeners');
|
||||
|
||||
// Set up event listeners after the display handler has created controls
|
||||
this.setupEventListeners();
|
||||
this.bindTopControls();
|
||||
this.syncTopControls();
|
||||
requestAnimationFrame(() => {
|
||||
this.bindTopControls();
|
||||
this.syncTopControls();
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.bindTopControls();
|
||||
this.syncTopControls();
|
||||
}, 250);
|
||||
|
||||
this.reportProgress(80, 'Initializing text buffer');
|
||||
|
||||
@@ -146,22 +162,45 @@ class UIControllerModule extends BaseModule {
|
||||
this.applyBookSizing();
|
||||
|
||||
// Set up window resize handler
|
||||
window.addEventListener('resize', () => this.applyBookSizing());
|
||||
const handleViewportResize = () => this.applyBookSizing();
|
||||
window.addEventListener('resize', handleViewportResize);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', handleViewportResize);
|
||||
}
|
||||
if (window.ResizeObserver && document.body) {
|
||||
this.bodyResizeObserver = new ResizeObserver(handleViewportResize);
|
||||
this.bodyResizeObserver.observe(document.body);
|
||||
}
|
||||
}
|
||||
|
||||
applyBookSizing() {
|
||||
// Apply book sizing based on viewport dimensions
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const aspectRatio = viewportWidth / viewportHeight;
|
||||
const viewportAspectRatio = viewportWidth / viewportHeight;
|
||||
const bookWidth = 2727;
|
||||
const bookHeight = 1691;
|
||||
const bookScale = Math.min(viewportWidth / bookWidth, viewportHeight / bookHeight);
|
||||
|
||||
document.documentElement.style.setProperty('--viewport-aspect-ratio', aspectRatio);
|
||||
|
||||
const maxBookHeight = viewportHeight * 0.9;
|
||||
document.documentElement.style.setProperty('--book-height', `${maxBookHeight}px`);
|
||||
|
||||
const bookWidth = maxBookHeight * Math.min(aspectRatio, 1.613);
|
||||
document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
|
||||
document.documentElement.style.setProperty('--book-height', `${bookHeight}px`);
|
||||
document.documentElement.style.setProperty('--book-scale', bookScale);
|
||||
document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio);
|
||||
document.documentElement.style.setProperty(
|
||||
'--viewport-dimension',
|
||||
viewportWidth / viewportHeight > bookWidth / bookHeight ? 'vw' : 'vh'
|
||||
);
|
||||
|
||||
document.dispatchEvent(new CustomEvent('book:scaled', {
|
||||
detail: {
|
||||
bookWidth,
|
||||
bookHeight,
|
||||
bookScale,
|
||||
displayWidth: bookWidth * bookScale,
|
||||
displayHeight: bookHeight * bookScale,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
@@ -169,62 +208,46 @@ class UIControllerModule extends BaseModule {
|
||||
const saveButton = document.getElementById('save');
|
||||
const loadButton = document.getElementById('reload');
|
||||
const restartButton = document.getElementById('rewind');
|
||||
const speechToggle = document.getElementById('speech');
|
||||
const optionsButton = document.getElementById('options');
|
||||
|
||||
// Get persistence manager module
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
|
||||
// Set up save button
|
||||
if (saveButton) {
|
||||
saveButton.addEventListener('click', () => {
|
||||
saveButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
if (saveButton.getAttribute('disabled') === 'disabled') return;
|
||||
document.dispatchEvent(new CustomEvent('ui:game:save'));
|
||||
});
|
||||
}
|
||||
|
||||
// Set up load button
|
||||
if (loadButton) {
|
||||
loadButton.addEventListener('click', () => {
|
||||
loadButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
if (loadButton.getAttribute('disabled') === 'disabled') return;
|
||||
document.dispatchEvent(new CustomEvent('ui:game:load'));
|
||||
});
|
||||
}
|
||||
|
||||
// Set up restart button
|
||||
if (restartButton) {
|
||||
restartButton.addEventListener('click', () => {
|
||||
restartButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.__newGameHandled = true;
|
||||
document.dispatchEvent(new CustomEvent('ui:game:restart'));
|
||||
});
|
||||
}
|
||||
|
||||
// Set up speech toggle button
|
||||
if (speechToggle) {
|
||||
// Initialize ttsEnabled from persistence manager
|
||||
if (persistenceManager) {
|
||||
const prefs = persistenceManager.getAllPreferences();
|
||||
this.ttsEnabled = prefs.tts?.enabled ?? false;
|
||||
|
||||
// Update button state
|
||||
this.updateButtonStates();
|
||||
|
||||
this.addEventListener(document, 'click', (event) => {
|
||||
if (event.target && event.target.closest && event.target.closest('#rewind')) {
|
||||
event.preventDefault();
|
||||
if (event.__newGameHandled) return;
|
||||
document.dispatchEvent(new CustomEvent('ui:game:restart'));
|
||||
}
|
||||
|
||||
speechToggle.addEventListener('click', () => {
|
||||
// Toggle TTS state
|
||||
this.ttsEnabled = !this.ttsEnabled;
|
||||
|
||||
// Update UI
|
||||
this.updateButtonStates();
|
||||
|
||||
// Save preference
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
|
||||
}
|
||||
|
||||
// Notify other components
|
||||
document.dispatchEvent(new CustomEvent('ui:tts:toggle', {
|
||||
detail: { enabled: this.ttsEnabled }
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Set up options button
|
||||
if (optionsButton) {
|
||||
@@ -232,9 +255,31 @@ class UIControllerModule extends BaseModule {
|
||||
document.dispatchEvent(new CustomEvent('ui:options:toggle'));
|
||||
});
|
||||
}
|
||||
|
||||
this.addEventListener(document, 'ui:command', (event) => {
|
||||
if (!event.detail || event.detail.moduleId === this.id) return;
|
||||
this.handleCommand(event.detail);
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'click', (event) => {
|
||||
if (event.target && event.target.closest && event.target.closest('#options-modal, #controls, #player_input, #command_input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||
if (playbackCoordinator && playbackCoordinator.isPlaying) {
|
||||
this.handleCommand({ type: 'continue', source: 'book-click' });
|
||||
}
|
||||
|
||||
if (this.inputHandler && typeof this.inputHandler.focusInput === 'function') {
|
||||
this.inputHandler.focusInput();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for book events
|
||||
document.addEventListener('book:ready', () => {
|
||||
this.bindTopControls();
|
||||
this.syncTopControls();
|
||||
this.updateButtonStates({
|
||||
canSave: true,
|
||||
canLoad: true,
|
||||
@@ -275,8 +320,9 @@ class UIControllerModule extends BaseModule {
|
||||
this.updateButtonStates();
|
||||
|
||||
// Ensure persistence is updated
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
|
||||
const currentPersistenceManager = this.getModule('persistence-manager');
|
||||
if (currentPersistenceManager) {
|
||||
currentPersistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -294,64 +340,26 @@ class UIControllerModule extends BaseModule {
|
||||
this.updateButtonStates();
|
||||
|
||||
// Ensure persistence is updated
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
|
||||
const currentPersistenceManager = this.getModule('persistence-manager');
|
||||
if (currentPersistenceManager) {
|
||||
currentPersistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set up speed slider in main UI
|
||||
const speedSlider = document.getElementById('speed');
|
||||
const speedReset = document.getElementById('speed_reset');
|
||||
|
||||
if (speedSlider) {
|
||||
// Initialize speed from persistence manager
|
||||
if (persistenceManager) {
|
||||
const prefs = persistenceManager.getAllPreferences();
|
||||
// Get the unified speed value (0-1 range)
|
||||
const speed = prefs.tts?.speed ?? 0.5;
|
||||
// Convert to slider range (0-100)
|
||||
speedSlider.value = Math.round(speed * 100);
|
||||
|
||||
document.addEventListener('preference-updated', (event) => {
|
||||
const { category, key, value } = event.detail || {};
|
||||
if (category !== 'tts') {
|
||||
return;
|
||||
}
|
||||
|
||||
speedSlider.addEventListener('input', (e) => {
|
||||
// Convert slider value (0-100) to normalized speed (0-1)
|
||||
const speed = parseInt(e.target.value) / 100;
|
||||
|
||||
// Scale for different TTS engines
|
||||
// This value is used for real-time preview only
|
||||
const rate = this.ttsEnabled ? speed * 2 : 1;
|
||||
|
||||
// Update animation speed
|
||||
document.dispatchEvent(new CustomEvent('animation:speed:change', {
|
||||
detail: { speed: rate }
|
||||
}));
|
||||
|
||||
// Update TTS speed
|
||||
document.dispatchEvent(new CustomEvent('tts:speed:change', {
|
||||
detail: { speed: speed }
|
||||
}));
|
||||
|
||||
// Save preference
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'speed', speed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (speedReset) {
|
||||
speedReset.addEventListener('click', () => {
|
||||
// Reset to default speed (0.5)
|
||||
if (speedSlider) {
|
||||
// Default value is 0.5 in normalized form (0-1),
|
||||
// which is 50 in slider range (0-100)
|
||||
speedSlider.value = 50;
|
||||
|
||||
// Trigger the input event to update all components
|
||||
speedSlider.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (key === 'enabled') {
|
||||
this.ttsEnabled = value === true;
|
||||
this.syncTopControls();
|
||||
} else if (key === 'speed') {
|
||||
this.syncTopControls();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for speed change events from other components
|
||||
document.addEventListener('tts:speed:change', (event) => {
|
||||
@@ -359,17 +367,157 @@ class UIControllerModule extends BaseModule {
|
||||
// Update the main UI speed slider
|
||||
const speedSlider = document.getElementById('speed');
|
||||
if (speedSlider) {
|
||||
// Convert normalized speed (0-1) to slider range (0-100)
|
||||
speedSlider.value = Math.round(event.detail.speed * 100);
|
||||
speedSlider.value = this.sliderValueFromSpeed(event.detail.speed);
|
||||
}
|
||||
|
||||
// Save to persistence manager
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'speed', event.detail.speed);
|
||||
const currentPersistenceManager = this.getModule('persistence-manager');
|
||||
if (currentPersistenceManager) {
|
||||
currentPersistenceManager.updatePreference('tts', 'speed', event.detail.speed);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sliderValueFromSpeed(speed) {
|
||||
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1;
|
||||
return Math.round((Math.max(0.5, Math.min(2.0, value)) * 50) + 50);
|
||||
}
|
||||
|
||||
speedFromSliderValue(value) {
|
||||
const sliderValue = Number.isFinite(Number(value)) ? Number(value) : 50;
|
||||
return Math.max(0.5, Math.min(2.0, (sliderValue - 50) / 50));
|
||||
}
|
||||
|
||||
bindTopControls() {
|
||||
const speechToggle = document.getElementById('speech');
|
||||
const speedSlider = document.getElementById('speed');
|
||||
const speedReset = document.getElementById('speed_reset');
|
||||
|
||||
if (speechToggle && speechToggle.dataset.uiControllerBound !== 'true') {
|
||||
speechToggle.dataset.uiControllerBound = 'true';
|
||||
speechToggle.removeAttribute('disabled');
|
||||
speechToggle.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
const currentEnabled = this.getStoredTtsPreference('enabled', this.ttsEnabled);
|
||||
const nextEnabled = !currentEnabled;
|
||||
this.ttsEnabled = nextEnabled;
|
||||
console.log(`UIController: Top speech toggle set to ${nextEnabled ? 'enabled' : 'disabled'}`);
|
||||
|
||||
this.setStoredTtsPreference('enabled', nextEnabled);
|
||||
|
||||
if (ttsFactory) {
|
||||
if (nextEnabled) {
|
||||
const preferredHandler = persistenceManager?.getPreference('tts', 'preferred_handler', 'none') || 'none';
|
||||
if (preferredHandler !== 'none') {
|
||||
await ttsFactory.setActiveHandler(preferredHandler);
|
||||
}
|
||||
} else {
|
||||
await ttsFactory.disableAfterCurrentPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
this.syncTopControls();
|
||||
document.dispatchEvent(new CustomEvent('tts:enabled:change', {
|
||||
detail: { enabled: nextEnabled, source: 'topbar' }
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') {
|
||||
speedSlider.dataset.uiControllerBound = 'true';
|
||||
speedSlider.min = speedSlider.min || '50';
|
||||
speedSlider.max = speedSlider.max || '150';
|
||||
speedSlider.addEventListener('input', (event) => {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const speed = this.speedFromSliderValue(event.target.value);
|
||||
|
||||
document.dispatchEvent(new CustomEvent('animation:speed:change', {
|
||||
detail: { speed: 1 }
|
||||
}));
|
||||
|
||||
document.dispatchEvent(new CustomEvent('tts:speed:change', {
|
||||
detail: { speed }
|
||||
}));
|
||||
|
||||
this.setStoredTtsPreference('speed', speed);
|
||||
});
|
||||
}
|
||||
|
||||
if (speedReset && speedReset.dataset.uiControllerBound !== 'true') {
|
||||
speedReset.dataset.uiControllerBound = 'true';
|
||||
speedReset.addEventListener('click', () => {
|
||||
const slider = document.getElementById('speed');
|
||||
if (slider) {
|
||||
slider.value = this.sliderValueFromSpeed(1);
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
syncTopControls() {
|
||||
this.bindTopControls();
|
||||
|
||||
this.ttsEnabled = this.getStoredTtsPreference('enabled', this.ttsEnabled) === true;
|
||||
|
||||
const speedSlider = document.getElementById('speed');
|
||||
if (speedSlider) {
|
||||
const speed = this.getStoredTtsPreference('speed', 1);
|
||||
const value = String(this.sliderValueFromSpeed(speed));
|
||||
if (speedSlider.value !== value) {
|
||||
speedSlider.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
getStoredTtsPreference(key, defaultValue) {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
|
||||
const value = persistenceManager.getPreference('tts', key, undefined);
|
||||
if (typeof value !== 'undefined' && value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem('ai-interactive-fiction-preferences');
|
||||
if (raw) {
|
||||
const prefs = JSON.parse(raw);
|
||||
if (prefs && prefs.tts && Object.prototype.hasOwnProperty.call(prefs.tts, key)) {
|
||||
return prefs.tts[key];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('UIController: Failed to read TTS preference fallback:', error);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
setStoredTtsPreference(key, value) {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager && typeof persistenceManager.updatePreference === 'function') {
|
||||
persistenceManager.updatePreference('tts', key, value);
|
||||
}
|
||||
|
||||
try {
|
||||
const storageKey = 'ai-interactive-fiction-preferences';
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const prefs = raw ? JSON.parse(raw) : {};
|
||||
prefs.tts = prefs.tts || {};
|
||||
prefs.tts[key] = value;
|
||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
||||
} catch (error) {
|
||||
console.warn('UIController: Failed to write TTS preference fallback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async setupMainUI() {
|
||||
// Ensure all UI components exist
|
||||
@@ -383,36 +531,45 @@ class UIControllerModule extends BaseModule {
|
||||
this.rightPage = document.getElementById('page_right');
|
||||
this.storyElement = document.getElementById('story');
|
||||
}
|
||||
|
||||
if (this.inputHandler && typeof this.inputHandler.focusInput === 'function') {
|
||||
requestAnimationFrame(() => this.inputHandler.focusInput());
|
||||
}
|
||||
}
|
||||
|
||||
initializeTextBuffer() {
|
||||
// Initialize text buffer handling
|
||||
if (this.textBuffer) {
|
||||
console.log('UIController: Setting up text buffer callback');
|
||||
this.textBuffer.setOnSentenceReady((text, callback) => {
|
||||
console.log('UIController: Received sentence from text buffer, displaying');
|
||||
|
||||
// Use the display handler to show text with proper formatting and TTS
|
||||
this.displayText(text)
|
||||
.then(() => {
|
||||
console.log('UIController: Display of sentence completed, continuing...');
|
||||
|
||||
// Signal that we're ready to process the next sentence
|
||||
if (typeof callback === 'function') {
|
||||
// Use a small timeout to prevent potential stack overflow with many sentences
|
||||
setTimeout(() => callback(), 10);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('UIController: Error displaying text:', error);
|
||||
// Continue anyway to prevent blocking
|
||||
if (typeof callback === 'function') callback();
|
||||
});
|
||||
});
|
||||
console.log('UIController: Text buffer callback set up');
|
||||
} else {
|
||||
console.warn('UIController: Text buffer module not found');
|
||||
// Connect SentenceQueue to UIDisplayHandler
|
||||
const sentenceQueue = this.getModule('sentence-queue');
|
||||
const displayHandler = this.getModule('ui-display-handler');
|
||||
|
||||
if (!sentenceQueue || !displayHandler) {
|
||||
console.error('UIController: Required modules not found (sentence-queue or ui-display-handler)');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('UIController: Setting up SentenceQueue → UIDisplayHandler pipeline');
|
||||
|
||||
// Set up callback for when sentences are ready to display
|
||||
sentenceQueue.setOnSentenceReady(async (sentence, callback) => {
|
||||
try {
|
||||
console.log(`UIController: Rendering sentence ${sentence.id}`);
|
||||
await displayHandler.renderSentence(sentence);
|
||||
console.log(`UIController: Sentence ${sentence.id} rendered successfully`);
|
||||
|
||||
// Signal completion to process next sentence
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('UIController: Error rendering sentence:', error);
|
||||
// Still proceed to prevent blocking
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('UIController: SentenceQueue pipeline configured');
|
||||
}
|
||||
|
||||
handleCommand(command) {
|
||||
@@ -425,8 +582,13 @@ class UIControllerModule extends BaseModule {
|
||||
this.effects.processCommand(command);
|
||||
break;
|
||||
case 'continue':
|
||||
if (this.animationQueue) {
|
||||
this.animationQueue.fastForward();
|
||||
{
|
||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||
if (playbackCoordinator && playbackCoordinator.isPlaying) {
|
||||
playbackCoordinator.fastForward();
|
||||
} else if (this.animationQueue) {
|
||||
this.animationQueue.fastForward();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'input':
|
||||
@@ -473,7 +635,7 @@ class UIControllerModule extends BaseModule {
|
||||
const speechToggle = document.getElementById('speech');
|
||||
|
||||
// Update save button state
|
||||
if (saveButton) {
|
||||
if (saveButton && typeof canSave === 'boolean') {
|
||||
if (canSave) {
|
||||
saveButton.removeAttribute('disabled');
|
||||
} else {
|
||||
@@ -482,7 +644,7 @@ class UIControllerModule extends BaseModule {
|
||||
}
|
||||
|
||||
// Update load button state
|
||||
if (loadButton) {
|
||||
if (loadButton && typeof canLoad === 'boolean') {
|
||||
if (canLoad) {
|
||||
loadButton.removeAttribute('disabled');
|
||||
} else {
|
||||
@@ -491,35 +653,28 @@ class UIControllerModule extends BaseModule {
|
||||
}
|
||||
|
||||
// Update restart button state
|
||||
if (restartButton) {
|
||||
if (restartButton && typeof canRestart === 'boolean') {
|
||||
if (canRestart) {
|
||||
restartButton.removeAttribute('disabled');
|
||||
} else {
|
||||
restartButton.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
|
||||
|
||||
// Update speech toggle button state
|
||||
if (speechToggle) {
|
||||
// Update the button appearance based on TTS state using existing styles
|
||||
if (!this.ttsAvailable) {
|
||||
// TTS is not available, disable the button
|
||||
speechToggle.setAttribute('disabled', 'disabled');
|
||||
speechToggle.title = 'Text-to-speech is not available';
|
||||
speechToggle.removeAttribute('disabled');
|
||||
if (this.ttsEnabled) {
|
||||
speechToggle.style.fontWeight = 'bold';
|
||||
speechToggle.style.color = '#000';
|
||||
speechToggle.title = this.ttsAvailable ? 'Disable speech' : 'Speech enabled, selected provider is not ready';
|
||||
} else {
|
||||
// TTS is available, remove disabled attribute
|
||||
speechToggle.removeAttribute('disabled');
|
||||
|
||||
// Update based on whether TTS is enabled
|
||||
if (this.ttsEnabled) {
|
||||
speechToggle.style.fontWeight = 'bold';
|
||||
speechToggle.style.color = '#000';
|
||||
speechToggle.title = 'Disable speech';
|
||||
} else {
|
||||
speechToggle.style.fontWeight = 'normal';
|
||||
speechToggle.style.color = '#999';
|
||||
speechToggle.title = 'Enable speech';
|
||||
}
|
||||
speechToggle.style.fontWeight = 'normal';
|
||||
speechToggle.style.color = '#999';
|
||||
speechToggle.title = 'Enable speech';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,17 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
super('ui-display-handler', 'UI Display Handler');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['paragraph-layout', 'layout-renderer', 'animation-queue'];
|
||||
this.dependencies = ['layout-renderer', 'playback-coordinator'];
|
||||
|
||||
// DOM elements
|
||||
this.container = null;
|
||||
this.pageLeft = null;
|
||||
this.pageRight = null;
|
||||
this.paragraphContainer = null;
|
||||
this.renderedItems = [];
|
||||
this.resizeTimer = null;
|
||||
this.storyResizeObserver = null;
|
||||
this.lastStoryMetrics = null;
|
||||
|
||||
// Resources to preload
|
||||
this.cssPath = '/css/style.css';
|
||||
@@ -28,6 +32,12 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.bindMethods([
|
||||
'initializeContainers',
|
||||
'displayText',
|
||||
'renderSentence',
|
||||
'renderHeading',
|
||||
'handleDeferredMediaBlock',
|
||||
'rerenderStory',
|
||||
'clear',
|
||||
'scheduleRerender',
|
||||
'measureText',
|
||||
'loadCSS',
|
||||
'showChoices',
|
||||
@@ -49,9 +59,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.reportProgress(30, "Getting module dependencies");
|
||||
|
||||
// Get references to required modules using parent's getModule method
|
||||
this.paragraphLayout = this.getModule('paragraph-layout');
|
||||
this.layoutRenderer = this.getModule('layout-renderer');
|
||||
this.animationQueue = this.getModule('animation-queue');
|
||||
this.playbackCoordinator = this.getModule('playback-coordinator');
|
||||
|
||||
this.reportProgress(50, "Initializing display containers");
|
||||
|
||||
@@ -61,6 +70,40 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.reportProgress(70, "Setting up typography");
|
||||
|
||||
this.reportProgress(90, "Setting up event listeners");
|
||||
this.addEventListener(document, 'book:resized', () => {
|
||||
this.scheduleRerender();
|
||||
});
|
||||
|
||||
if (window.ResizeObserver && this.paragraphContainer) {
|
||||
this.storyResizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const computedStyle = window.getComputedStyle(this.paragraphContainer);
|
||||
const metrics = {
|
||||
width: Math.round(entry.contentRect.width),
|
||||
fontSize: computedStyle.fontSize,
|
||||
lineHeight: computedStyle.lineHeight
|
||||
};
|
||||
|
||||
if (!this.lastStoryMetrics) {
|
||||
this.lastStoryMetrics = metrics;
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = metrics.width !== this.lastStoryMetrics.width ||
|
||||
metrics.fontSize !== this.lastStoryMetrics.fontSize ||
|
||||
metrics.lineHeight !== this.lastStoryMetrics.lineHeight;
|
||||
|
||||
this.lastStoryMetrics = metrics;
|
||||
if (changed) {
|
||||
this.scheduleRerender();
|
||||
}
|
||||
});
|
||||
this.storyResizeObserver.observe(this.paragraphContainer);
|
||||
}
|
||||
|
||||
this.reportProgress(100, "UI Display Handler ready");
|
||||
return true;
|
||||
@@ -70,6 +113,11 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRerender() {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = setTimeout(() => this.rerenderStory(), 80);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load CSS file asynchronously and wait for it to be applied
|
||||
@@ -160,9 +208,9 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
controls.id = 'controls';
|
||||
controls.className = 'buttons';
|
||||
controls.innerHTML = `
|
||||
<a class="l10n-speech" id="speech" title="Toggle text to speech" disabled="disabled">speech</a>
|
||||
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="0" max="100" value="50" id="speed" name="speed" /></span>
|
||||
<a class="l10n-restart" id="rewind" title="Restart story from beginning" disabled="disabled">restart</a>
|
||||
<a class="l10n-speech" id="speech" title="Toggle text to speech">speech</a>
|
||||
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
|
||||
<a class="l10n-restart" id="rewind" title="Start a new game">new game</a>
|
||||
<a class="l10n-save" id="save" title="Save progress">save</a>
|
||||
<a class="l10n-load" id="reload" title="Reload from save point" disabled="disabled">load</a>
|
||||
<a class="l10n-options" id="options" title="Options">options</a>
|
||||
@@ -220,6 +268,13 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.container.className = 'container';
|
||||
this.pageRight.appendChild(this.container);
|
||||
}
|
||||
|
||||
if (!document.getElementById('start_prompt')) {
|
||||
const startPrompt = document.createElement('div');
|
||||
startPrompt.id = 'start_prompt';
|
||||
startPrompt.textContent = 'Klick on new game or load to start the game';
|
||||
this.pageRight.appendChild(startPrompt);
|
||||
}
|
||||
|
||||
// Create paragraph container inside story container
|
||||
this.paragraphContainer = document.getElementById('paragraphs');
|
||||
@@ -272,33 +327,194 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
|
||||
/**
|
||||
* Display text in the UI
|
||||
* Display text in the UI (backward compatibility)
|
||||
* Note: Text should flow through SentenceQueue instead
|
||||
* @param {string} text - Text to display
|
||||
* @param {Object} options - Display options
|
||||
* @returns {Promise<HTMLElement>} - Promise resolving to the displayed paragraph element
|
||||
*/
|
||||
displayText(text, options = {}) {
|
||||
if (!text) return Promise.resolve(null);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Generate a unique ID for this paragraph
|
||||
const paragraphId = `p-${Date.now()}-${this.currentParagraphId++}`;
|
||||
|
||||
// Add to pending paragraphs queue
|
||||
this.pendingParagraphs.push({
|
||||
id: paragraphId,
|
||||
text,
|
||||
options,
|
||||
resolve
|
||||
|
||||
// For backward compatibility, delegate to sentence queue
|
||||
console.warn('UIDisplayHandler.displayText called directly, text should flow through SentenceQueue');
|
||||
|
||||
const sentenceQueue = this.getModule('sentence-queue');
|
||||
if (sentenceQueue) {
|
||||
return new Promise(resolve => {
|
||||
sentenceQueue.addSentence(text, () => resolve(null));
|
||||
});
|
||||
|
||||
// If this is the only paragraph, process it immediately
|
||||
if (this.pendingParagraphs.length === 1) {
|
||||
this.processNextParagraph();
|
||||
} else {
|
||||
console.log(`UIDisplayHandler: Queued paragraph (${this.pendingParagraphs.length} total)`);
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a prepared sentence to the display
|
||||
* @param {Object} sentence - Prepared sentence object from SentenceQueue
|
||||
* @returns {Promise<HTMLElement>} - Promise resolving to the paragraph element
|
||||
*/
|
||||
async renderSentence(sentence) {
|
||||
if (!sentence || !sentence.layout) {
|
||||
if (sentence && sentence.kind === 'heading') {
|
||||
return this.renderHeading(sentence);
|
||||
}
|
||||
if (sentence && (sentence.kind === 'image' || sentence.kind === 'music')) {
|
||||
return this.handleDeferredMediaBlock(sentence);
|
||||
}
|
||||
console.error('UIDisplayHandler: Invalid sentence object');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Render DOM from layout data
|
||||
const paragraphElement = this.layoutRenderer.renderParagraph(
|
||||
sentence.layout,
|
||||
{ id: sentence.id }
|
||||
);
|
||||
|
||||
// Append to container
|
||||
if (this.paragraphContainer) {
|
||||
this.paragraphContainer.appendChild(paragraphElement);
|
||||
if (typeof this.layoutRenderer.adjustJustification === 'function') {
|
||||
this.layoutRenderer.adjustJustification(paragraphElement);
|
||||
}
|
||||
} else {
|
||||
console.error('UIDisplayHandler: Paragraph container not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Store element reference in sentence
|
||||
sentence.element = paragraphElement;
|
||||
this.renderedItems.push({
|
||||
type: 'paragraph',
|
||||
id: sentence.id,
|
||||
text: sentence.text,
|
||||
metadata: {
|
||||
layoutText: sentence.layout?.sourceLayoutText || sentence.text,
|
||||
cueMarkers: sentence.cueMarkers || [],
|
||||
role: sentence.role || 'body',
|
||||
isFirstParagraphInChapter: sentence.isFirstParagraphInChapter,
|
||||
dropCap: sentence.dropCap,
|
||||
addTopSpace: sentence.addTopSpace,
|
||||
paragraphIndex: sentence.paragraphIndex
|
||||
}
|
||||
});
|
||||
|
||||
// Start coordinated playback (animation + TTS)
|
||||
await this.playbackCoordinator.play(sentence);
|
||||
|
||||
// Scroll to bottom
|
||||
if (this.pageRight) {
|
||||
this.pageRight.scrollTop = this.pageRight.scrollHeight;
|
||||
}
|
||||
|
||||
// Call completion callback
|
||||
if (sentence.onComplete) {
|
||||
sentence.onComplete();
|
||||
}
|
||||
|
||||
return paragraphElement;
|
||||
|
||||
} catch (error) {
|
||||
console.error('UIDisplayHandler: Error rendering sentence:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async renderHeading(sentence) {
|
||||
const heading = document.createElement('p');
|
||||
heading.id = sentence.id;
|
||||
heading.className = 'story-chapter-heading';
|
||||
heading.innerHTML = sentence.metadata?.layoutText || sentence.text;
|
||||
this.renderedItems.push({
|
||||
type: 'heading',
|
||||
id: sentence.id,
|
||||
text: sentence.text,
|
||||
layoutText: sentence.metadata?.layoutText || sentence.text
|
||||
});
|
||||
|
||||
if (this.paragraphContainer) {
|
||||
this.paragraphContainer.appendChild(heading);
|
||||
}
|
||||
|
||||
if (sentence.onComplete) {
|
||||
sentence.onComplete();
|
||||
}
|
||||
|
||||
return heading;
|
||||
}
|
||||
|
||||
async rerenderStory() {
|
||||
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
||||
|
||||
const sentenceQueue = this.getModule('sentence-queue');
|
||||
if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') return;
|
||||
|
||||
console.log('UIDisplayHandler: Re-typesetting story after page resize');
|
||||
const scrollTop = this.pageRight ? this.pageRight.scrollTop : 0;
|
||||
this.paragraphContainer.innerHTML = '';
|
||||
|
||||
for (const item of this.renderedItems) {
|
||||
if (item.type === 'heading') {
|
||||
const heading = document.createElement('p');
|
||||
heading.id = item.id;
|
||||
heading.className = 'story-chapter-heading';
|
||||
heading.innerHTML = item.layoutText || item.text;
|
||||
this.paragraphContainer.appendChild(heading);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type !== 'paragraph') continue;
|
||||
|
||||
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
|
||||
const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id });
|
||||
paragraph.querySelectorAll('.word').forEach(word => {
|
||||
word.style.transition = 'none';
|
||||
word.style.visibility = 'visible';
|
||||
word.style.opacity = '1';
|
||||
word.style.transform = 'translateY(0)';
|
||||
});
|
||||
this.paragraphContainer.appendChild(paragraph);
|
||||
}
|
||||
|
||||
if (this.pageRight) {
|
||||
this.pageRight.scrollTop = scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeferredMediaBlock(sentence) {
|
||||
document.dispatchEvent(new CustomEvent('story:media-block', {
|
||||
detail: {
|
||||
id: sentence.id,
|
||||
type: sentence.kind,
|
||||
...(sentence.metadata || {})
|
||||
}
|
||||
}));
|
||||
|
||||
if (sentence.kind === 'music') {
|
||||
const leadInSeconds = Number(sentence.metadata?.leadInSeconds || sentence.metadata?.leadIn || 0);
|
||||
if (leadInSeconds > 0) {
|
||||
console.log(`UIDisplayHandler: Waiting ${leadInSeconds}s before continuing after music block`);
|
||||
await new Promise(resolve => setTimeout(resolve, leadInSeconds * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
if (sentence.onComplete) {
|
||||
sentence.onComplete();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
this.paragraphContainer = document.createElement('div');
|
||||
this.paragraphContainer.id = 'paragraphs';
|
||||
this.container.appendChild(this.paragraphContainer);
|
||||
}
|
||||
this.renderedItems = [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,3 +592,11 @@ const uiDisplayHandler = new UIDisplayHandlerModule();
|
||||
|
||||
// Export the module
|
||||
export { uiDisplayHandler as UIDisplayHandler };
|
||||
|
||||
// Register with the module registry
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(uiDisplayHandler);
|
||||
}
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.UIDisplayHandler = uiDisplayHandler;
|
||||
|
||||
@@ -5,7 +5,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
super('ui-input-handler', 'UI Input Handler');
|
||||
|
||||
// Explicitly declare ui-display-handler as a dependency
|
||||
this.dependencies = ['ui-display-handler'];
|
||||
this.dependencies = ['ui-display-handler', 'markup-parser', 'playback-coordinator'];
|
||||
|
||||
// Input elements
|
||||
this.inputArea = null;
|
||||
@@ -28,7 +28,10 @@ class UIInputHandlerModule extends BaseModule {
|
||||
'handleKeyboardInput',
|
||||
'submitCommand',
|
||||
'addToHistory',
|
||||
'resetCursorPosition'
|
||||
'formatCommandHistory',
|
||||
'resetCursorPosition',
|
||||
'focusInput',
|
||||
'setProcessState'
|
||||
]);
|
||||
|
||||
console.log('UIInputHandler: Constructor initialized');
|
||||
@@ -53,6 +56,9 @@ class UIInputHandlerModule extends BaseModule {
|
||||
this.reportProgress(60, 'Setting up input elements');
|
||||
|
||||
this.setupInputElements();
|
||||
this.addEventListener(document, 'story:process-state', (event) => {
|
||||
this.setProcessState(event.detail?.state || 'ready', event.detail || {});
|
||||
});
|
||||
|
||||
this.reportProgress(100, 'UI Input Handler ready');
|
||||
return true;
|
||||
@@ -67,14 +73,47 @@ class UIInputHandlerModule extends BaseModule {
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
handleKeyboardInput(event) {
|
||||
// Handle global keyboard shortcuts here
|
||||
// This is different from the input field's specific key handling
|
||||
|
||||
// For example: Escape key to blur the input
|
||||
if (!this.playerInput) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (document.activeElement === this.playerInput) {
|
||||
this.playerInput.blur();
|
||||
this.playerInput.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsModal = document.getElementById('options-modal');
|
||||
if (optionsModal && optionsModal.style.display !== 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === ' ' && this.isPlaybackActive()) {
|
||||
document.dispatchEvent(new CustomEvent('ui:command', {
|
||||
detail: { type: 'continue', source: 'spacebar' }
|
||||
}));
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (document.body.dataset.gameRunning !== 'true') {
|
||||
return;
|
||||
}
|
||||
this.submitCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.length === 1 && document.activeElement !== this.playerInput) {
|
||||
if (document.body.dataset.gameRunning !== 'true') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.focusInput();
|
||||
const start = this.playerInput.selectionStart ?? this.playerInput.value.length;
|
||||
const end = this.playerInput.selectionEnd ?? start;
|
||||
this.playerInput.setRangeText(event.key, start, end, 'end');
|
||||
this.playerInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +183,8 @@ class UIInputHandlerModule extends BaseModule {
|
||||
playerInput.style.whiteSpace = 'pre-wrap';
|
||||
|
||||
inputWrapper.appendChild(playerInput);
|
||||
this.playerInput = playerInput;
|
||||
}
|
||||
this.playerInput = playerInput;
|
||||
|
||||
// Create the cursor if needed
|
||||
let cursor = document.getElementById('cursor');
|
||||
@@ -153,8 +192,8 @@ class UIInputHandlerModule extends BaseModule {
|
||||
cursor = document.createElement('span');
|
||||
cursor.id = 'cursor';
|
||||
inputWrapper.appendChild(cursor);
|
||||
this.cursor = cursor;
|
||||
}
|
||||
this.cursor = cursor;
|
||||
|
||||
// Set up input event handlers
|
||||
if (playerInput) {
|
||||
@@ -171,16 +210,94 @@ class UIInputHandlerModule extends BaseModule {
|
||||
// Position the cursor
|
||||
if (playerInput && cursor) {
|
||||
this.positionCursor(playerInput, cursor);
|
||||
|
||||
// Focus the input to let user start typing immediately
|
||||
setTimeout(() => {
|
||||
playerInput.focus();
|
||||
}, 100);
|
||||
this.setProcessState('ready', { reason: 'input-initialized' });
|
||||
this.focusInput();
|
||||
requestAnimationFrame(() => this.focusInput());
|
||||
setTimeout(() => this.focusInput(), 250);
|
||||
}
|
||||
|
||||
console.log('UIInputHandler: Input elements setup complete');
|
||||
}
|
||||
|
||||
focusInput() {
|
||||
if (document.body.dataset.gameRunning !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.playerInput) {
|
||||
this.playerInput = document.getElementById('player_input');
|
||||
}
|
||||
|
||||
if (this.playerInput && !this.playerInput.disabled) {
|
||||
this.playerInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setProcessState(state, detail = {}) {
|
||||
const knownStates = [
|
||||
'ready',
|
||||
'command-waiting',
|
||||
'waiting-generating',
|
||||
'playing-generating',
|
||||
'playing-ready'
|
||||
];
|
||||
const nextState = knownStates.includes(state) ? state : 'ready';
|
||||
|
||||
this.applyMouseCursor(nextState);
|
||||
|
||||
if (this.cursor) {
|
||||
knownStates.forEach(value => this.cursor.classList.remove(`cursor-${value}`));
|
||||
this.cursor.classList.add(`cursor-${nextState}`);
|
||||
this.cursor.dataset.processState = nextState;
|
||||
this.cursor.setAttribute('aria-label', 'text input cursor');
|
||||
this.cursor.innerHTML = '';
|
||||
}
|
||||
|
||||
console.log(`Cursor process state: ${nextState}`, detail);
|
||||
}
|
||||
|
||||
applyMouseCursor(state) {
|
||||
const root = document.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.dataset.processState = state;
|
||||
const cursor = this.getMouseCursor(state);
|
||||
if (cursor) {
|
||||
root.style.setProperty('--process-cursor', cursor);
|
||||
} else {
|
||||
root.style.removeProperty('--process-cursor');
|
||||
}
|
||||
}
|
||||
|
||||
getMouseCursor(state) {
|
||||
if (state === 'ready') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const svg = this.getMouseCursorSvg(state);
|
||||
const fallback = state === 'command-waiting' ? 'wait' : 'progress';
|
||||
return `url("${this.toCursorDataUrl(svg)}") 12 12, ${fallback}`;
|
||||
}
|
||||
|
||||
getMouseCursorSvg(state) {
|
||||
const stroke = '#222222';
|
||||
const common = `xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`;
|
||||
const icons = {
|
||||
'command-waiting': `<svg ${common}><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>`,
|
||||
'waiting-generating': `<svg ${common}><path d="M12 2v4"/><path d="M12 18v4"/><path d="m4.93 4.93 2.83 2.83"/><path d="m16.24 16.24 2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="m4.93 19.07 2.83-2.83"/><path d="m16.24 7.76 2.83-2.83"/></svg>`,
|
||||
'playing-generating': `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M12 2v3"/><path d="M12 19v3"/></svg>`,
|
||||
'playing-ready': `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`
|
||||
};
|
||||
|
||||
return icons[state] || icons['waiting-generating'];
|
||||
}
|
||||
|
||||
toCursorDataUrl(svg) {
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle player input changes
|
||||
* @param {Event} e - Input event
|
||||
@@ -271,7 +388,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
if (this.commandHistoryElement && this.commandHistoryElement.appendChild) {
|
||||
const historyItem = document.createElement('div');
|
||||
historyItem.className = 'history-item';
|
||||
historyItem.textContent = `> ${command}`;
|
||||
historyItem.innerHTML = `> ${this.formatCommandHistory(command)}`;
|
||||
this.commandHistoryElement.appendChild(historyItem);
|
||||
|
||||
// Limit visible history items
|
||||
@@ -284,6 +401,23 @@ class UIInputHandlerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
formatCommandHistory(command) {
|
||||
const parser = this.getModule('markup-parser') || window.MarkupParser;
|
||||
if (parser && typeof parser.markdownToHtml === 'function') {
|
||||
return parser.markdownToHtml(command);
|
||||
}
|
||||
|
||||
return String(command)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
isPlaybackActive() {
|
||||
const playbackCoordinator = this.getModule('playback-coordinator') || window.PlaybackCoordinator;
|
||||
return Boolean(playbackCoordinator && playbackCoordinator.isPlaying);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the cursor position to the start.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user