diff --git a/background.js b/background.js index f5ca4f4..2ff9363 100644 --- a/background.js +++ b/background.js @@ -4,9 +4,42 @@ try { "js/defaults.js", "js/messaging.js", "js/content-handlers.js", - "js/events.js" + "js/events.js", + "js/download.js" ) -} -catch (err) { +} catch (err) { console.error(err) } + +registerMessageListener("background", { + async downloadSelectedText(text) { + console.log("BG: synthesizing (no player)", text); + + const settings = await getSettings(["voiceName", "rate", "pitch", "voices"]); + const voice = (settings.voices || [])[0]; // fallback + + const options = { + voice, + lang: voice.lang, + rate: settings.rate, + pitch: settings.pitch + }; + + // Create speech instance WITHOUT player + const speech = new Speech([text], options); + + // Use engine directly + const engine = + speech.engine || + speech._engine || + speech.__engine; + + const url = await engine.getAudioUrl(text, options.voice, options.pitch); + + const blob = await fetch(url).then(r => r.blob()); + + return await blobToBase64(blob); + } +}); + + diff --git a/js/content.js b/js/content.js index c474317..8b12211 100644 --- a/js/content.js +++ b/js/content.js @@ -1,10 +1,10 @@ -(function() { +(function () { registerMessageListener("contentScript", { getRequireJs: getRequireJs, getDocumentInfo: getInfo, getCurrentIndex: getCurrentIndex, - getTexts: getTexts + getTexts: getTexts, }) function getInfo() { @@ -17,7 +17,7 @@ function getLang() { var lang = document.documentElement.lang || $("html").attr("xml:lang"); - if (lang) lang = lang.split(",",1)[0].replace(/_/g, '-'); + if (lang) lang = lang.split(",", 1)[0].replace(/_/g, '-'); return lang; } @@ -47,9 +47,9 @@ || $("embed[type='application/pdf']").length || $("iframe[src*='.pdf']").length) return ["js/content/pdf-doc.js"]; else if (/^\d+\.\d+\.\d+\.\d+$/.test(location.hostname) - && location.port === "1122" - && location.protocol === "http:" - && location.pathname === "/bookshelf/index.html") return ["js/content/yd-app-web.js"]; + && location.port === "1122" + && location.protocol === "http:" + && location.pathname === "/bookshelf/index.html") return ["js/content/yd-app-web.js"]; else return ["js/content/html-doc.js"]; } @@ -65,7 +65,7 @@ } else { return Promise.resolve(readAloudDoc.getTexts(index, quietly)) - .then(function(texts) { + .then(function (texts) { if (texts && Array.isArray(texts)) { if (!quietly) console.log(texts.join("\n\n")); } @@ -90,7 +90,7 @@ if (!audioCanPlay()) return; const silenceTrack = getSilenceTrack() try { - const should = await sendToPlayer({method: "shouldPlaySilence", args: [providerId]}) + const should = await sendToPlayer({ method: "shouldPlaySilence", args: [providerId] }) if (should) silenceTrack.start() else silenceTrack.stop() } @@ -128,7 +128,7 @@ function isNotEmpty(text) { function fixParagraphs(texts) { var out = []; var para = ""; - for (var i=0; i 0) return tryGetTexts(getTexts, millis-500); + .then(function (texts) { + if (texts && !texts.length && millis - 500 > 0) return tryGetTexts(getTexts, millis - 500); else return texts; }) } @@ -181,20 +181,20 @@ function simulateMouseEvent(element, eventName, coordX, coordY) { function simulateClick(elementToClick) { var box = elementToClick.getBoundingClientRect(), - coordX = box.left + (box.right - box.left) / 2, - coordY = box.top + (box.bottom - box.top) / 2; - simulateMouseEvent (elementToClick, "mousedown", coordX, coordY); - simulateMouseEvent (elementToClick, "mouseup", coordX, coordY); - simulateMouseEvent (elementToClick, "click", coordX, coordY); + coordX = box.left + (box.right - box.left) / 2, + coordY = box.top + (box.bottom - box.top) / 2; + simulateMouseEvent(elementToClick, "mousedown", coordX, coordY); + simulateMouseEvent(elementToClick, "mouseup", coordX, coordY); + simulateMouseEvent(elementToClick, "click", coordX, coordY); } -const getMath = (function() { +const getMath = (function () { let promise = Promise.resolve(null) return () => promise = promise.then(math => math || makeMath()) })(); async function makeMath() { - const getXmlFromMathEl = function(mathEl) { + const getXmlFromMathEl = function (mathEl) { const clone = mathEl.cloneNode(true) $("annotation, annotation-xml", clone).remove() removeAllAttrs(clone, true) @@ -210,11 +210,11 @@ async function makeMath() { return mathEl ? getXmlFromMathEl(mathEl) : el.getAttribute("data-mathml") }, }) - .when(() => document.querySelector("math"), { - selector: "math", - getXML: getXmlFromMathEl, - }) - .else(null) + .when(() => document.querySelector("math"), { + selector: "math", + getXML: getXmlFromMathEl, + }) + .else(null) if (!math) return null const elems = $(math.selector).get() @@ -229,8 +229,8 @@ async function makeMath() { catch (err) { console.error(err) return { - show() {}, - hide() {} + show() { }, + hide() { } } } diff --git a/js/events.js b/js/events.js index d8849f9..98fe2a0 100644 --- a/js/events.js +++ b/js/events.js @@ -1,5 +1,5 @@ -brapi.runtime.onInstalled.addListener(function() { +brapi.runtime.onInstalled.addListener(function () { installContentScripts() installContextMenus() }) @@ -22,11 +22,75 @@ var handlers = { reportIssue: reportIssue, authWavenet: authWavenet, managePiperVoices, + getSelectedText: getSelectedText, + downloadSelectedText: downloadSelectedText + } registerMessageListener("serviceWorker", handlers) +async function generateAudioBlob(text) { + // Get TTS settings + const settings = await getSettings(); + const rate = await getSetting("rate" + (settings.voiceName || "")); + + // Choose language + let lang = settings.lang || "en-US"; + + // Create a Speech instance (same used for Read Aloud player) + const speech = new Speech([text], { + rate: rate || defaults.rate, + pitch: settings.pitch || defaults.pitch, + volume: settings.volume || defaults.volume, + lang: lang + }); + + // Generate & return the audio blob + return await speech.createAudioBlob(); +} + + +function blobToBase64(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +async function getSelectedText() { + try { + const tabs = await brapi.tabs.query({ active: true, currentWindow: true }); + if (!tabs.length) return ""; + + const tabId = tabs[0].id; + + const result = await brapi.scripting.executeScript({ + target: { tabId }, + func: () => window.getSelection().toString().trim(), + }); + + return result[0].result || ""; + } catch (e) { + console.error("getSelectedText ERROR:", e); + return ""; + } +}; + + +async function downloadSelectedText(text) { + console.log("BG → sending to player:", text); + return sendToPlayer({ + dest: "player", + method: "synthesizeDownloadAudio", + args: [text] + }); +} + + + /** * Installers */ @@ -40,7 +104,7 @@ async function installContentScripts() { world: "MAIN" }, ] - const registeredIds = await brapi.scripting.getRegisteredContentScripts({ids: scripts.map(x => x.id)}) + const registeredIds = await brapi.scripting.getRegisteredContentScripts({ ids: scripts.map(x => x.id) }) .then(scripts => scripts.map(x => x.id)) .catch(err => { console.error(err) @@ -59,15 +123,15 @@ async function installContentScripts() { function installContextMenus() { if (brapi.contextMenus) - brapi.contextMenus.create({ - id: "read-selection", - title: brapi.i18n.getMessage("context_read_selection"), - contexts: ["selection"] - }, - function() { - if (brapi.runtime.lastError) console.error(brapi.runtime.lastError) - else console.info("Installed context menus") - }) + brapi.contextMenus.create({ + id: "read-selection", + title: brapi.i18n.getMessage("context_read_selection"), + contexts: ["selection"] + }, + function () { + if (brapi.runtime.lastError) console.error(brapi.runtime.lastError) + else console.info("Installed context menus") + }) } @@ -75,49 +139,49 @@ function installContextMenus() { * Context menu handlers */ if (brapi.contextMenus) -brapi.contextMenus.onClicked.addListener(function(info, tab) { - if (info.menuItemId == "read-selection") - Promise.resolve() - .then(function() { - if (tab && tab.id != -1) return detectTabLanguage(tab.id) - else return undefined - }) - .then(function(lang) { - return playText(info.selectionText, {lang: lang}) - }) - .catch(handleHeadlessError) -}) + brapi.contextMenus.onClicked.addListener(function (info, tab) { + if (info.menuItemId == "read-selection") + Promise.resolve() + .then(function () { + if (tab && tab.id != -1) return detectTabLanguage(tab.id) + else return undefined + }) + .then(function (lang) { + return playText(info.selectionText, { lang: lang }) + }) + .catch(handleHeadlessError) + }) /** * Shortcut keys handlers */ if (brapi.commands) -brapi.commands.onCommand.addListener(function(command) { - if (command == "play") { - getPlaybackState() - .then(function(stateInfo) { - switch (stateInfo.state) { - case "PLAYING": return pause() - case "PAUSED": return resume() - case "STOPPED": return playTab() - } - }) - .catch(handleHeadlessError) - } - else if (command == "stop") { - stop() - .catch(handleHeadlessError) - } - else if (command == "forward") { - forward() - .catch(handleHeadlessError) - } - else if (command == "rewind") { - rewind() - .catch(handleHeadlessError) - } -}) + brapi.commands.onCommand.addListener(function (command) { + if (command == "play") { + getPlaybackState() + .then(function (stateInfo) { + switch (stateInfo.state) { + case "PLAYING": return pause() + case "PAUSED": return resume() + case "STOPPED": return playTab() + } + }) + .catch(handleHeadlessError) + } + else if (command == "stop") { + stop() + .catch(handleHeadlessError) + } + else if (command == "forward") { + forward() + .catch(handleHeadlessError) + } + else if (command == "rewind") { + rewind() + .catch(handleHeadlessError) + } + }) /** @@ -180,24 +244,24 @@ var currentTask = { async function playText(text, opts) { const hasPlayer = await stop().then(res => res == true, err => false) if (!hasPlayer) await injectPlayer(await getActiveTab()) - await sendToPlayer({method: "playText", args: [text, opts]}) + await sendToPlayer({ method: "playText", args: [text, opts] }) } async function playTab(tabId) { const tab = tabId ? await getTab(tabId) : await getActiveTab() - if (!tab) throw new Error(JSON.stringify({code: "error_page_unreadable"})) + if (!tab) throw new Error(JSON.stringify({ code: "error_page_unreadable" })) const task = currentTask.begin() try { const handler = contentHandlers.find(h => h.match(tab.url || "", tab.title)) if (handler.validate) await handler.validate(tab) if (handler.getSourceUri) { - await brapi.storage.local.set({"sourceUri": handler.getSourceUri(tab)}) + await brapi.storage.local.set({ "sourceUri": handler.getSourceUri(tab) }) } else { const frameId = handler.getFrameId && await getAllFrames(tab.id).then(frames => handler.getFrameId(frames)) if (!await contentScriptAlreadyInjected(tab, frameId)) await injectContentScript(tab, frameId, handler.extraScripts) - await brapi.storage.local.set({"sourceUri": "contentscript:" + tab.id}) + await brapi.storage.local.set({ "sourceUri": "contentscript:" + tab.id }) } } finally { @@ -206,7 +270,7 @@ async function playTab(tabId) { const hasPlayer = await stop().then(res => res == true, err => false) if (!hasPlayer) await injectPlayer(tab) - await sendToPlayer({method: "playTab"}) + await sendToPlayer({ method: "playTab" }) } async function reloadAndPlayTab(tabId) { @@ -235,37 +299,37 @@ async function reloadAndPlayTab(tabId) { function stop() { currentTask.cancel() - return sendToPlayer({method: "stop"}) + return sendToPlayer({ method: "stop" }) } function pause() { - return sendToPlayer({method: "pause"}) + return sendToPlayer({ method: "pause" }) } function resume() { - return sendToPlayer({method: "resume"}) + return sendToPlayer({ method: "resume" }) } async function getPlaybackState() { - if (currentTask.isActive()) return {state: "LOADING"} + if (currentTask.isActive()) return { state: "LOADING" } try { - return await sendToPlayer({method: "getPlaybackState"}) || {state: "STOPPED"} + return await sendToPlayer({ method: "getPlaybackState" }) || { state: "STOPPED" } } catch (err) { - return {state: "STOPPED"} + return { state: "STOPPED" } } } function forward() { - return sendToPlayer({method: "forward"}) + return sendToPlayer({ method: "forward" }) } function rewind() { - return sendToPlayer({method: "rewind"}) + return sendToPlayer({ method: "rewind" }) } function seek(n) { - return sendToPlayer({method: "seek", args: [n]}) + return sendToPlayer({ method: "seek", args: [n] }) } @@ -278,7 +342,7 @@ function handleHeadlessError(err) { function reportIssue(url, comment) { var manifest = brapi.runtime.getManifest(); return getSettings() - .then(function(settings) { + .then(function (settings) { if (url) settings.url = url; settings.version = manifest.version; settings.userAgent = navigator.userAgent; @@ -291,7 +355,7 @@ function reportIssue(url, comment) { function authWavenet() { createTab("https://cloud.google.com/text-to-speech/#put-text-to-speech-into-action", true) - .then(function(tab) { + .then(function (tab) { addRequestListener(); brapi.tabs.onRemoved.addListener(onTabRemoved); return showInstructions(); @@ -312,14 +376,14 @@ function authWavenet() { var parser = new URL(details.url); var qs = parser.search ? parseQueryString(parser.search) : {}; if (qs.token) { - updateSettings({gcpToken: qs.token}); + updateSettings({ gcpToken: qs.token }); showSuccess(); } } function showInstructions() { return brapi.scripting.executeScript({ - target: {tabId: tab.id}, - func: function() { + target: { tabId: tab.id }, + func: function () { var elem = document.createElement('DIV') elem.id = 'ra-notice' elem.style.position = 'fixed' @@ -338,8 +402,8 @@ function authWavenet() { } function showSuccess() { return brapi.scripting.executeScript({ - target: {tabId: tab.id}, - func: function() { + target: { tabId: tab.id }, + func: function () { var elem = document.getElementById('ra-notice') elem.style.backgroundColor = '#0d0' elem.innerHTML = 'Successful, you can now use Google Wavenet voices. You may close this tab.' @@ -354,18 +418,18 @@ async function openPdfViewer(tabId, pdfUrl) { origins: ["http://*/", "https://*/"] } if (!await brapi.permissions.contains(perms)) { - throw new Error(JSON.stringify({code: "error_add_permissions", perms: perms})) + throw new Error(JSON.stringify({ code: "error_add_permissions", perms: perms })) } await setTabUrl(tabId, brapi.runtime.getURL("pdf-viewer.html?url=" + encodeURIComponent(pdfUrl))) await new Promise(f => handlers.pdfViewerCheckIn = f) } async function managePiperVoices() { - const result = await sendToPlayer({method: "managePiperVoices"}).catch(err => false) + const result = await sendToPlayer({ method: "managePiperVoices" }).catch(err => false) if (result != "OK") { - if (result == "POPOUT") await sendToPlayer({method: "close"}) + if (result == "POPOUT") await sendToPlayer({ method: "close" }) await injectPlayer() - await sendToPlayer({method: "managePiperVoices"}) + await sendToPlayer({ method: "managePiperVoices" }) } } @@ -377,7 +441,7 @@ async function contentScriptAlreadyInjected(tab, frameId) { tabId: tab.id, frameIds: frameId ? [frameId] : undefined, }, - func: function() { + func: function () { return typeof brapi != "undefined" } }) @@ -398,7 +462,7 @@ async function injectContentScript(tab, frameId, extraScripts) { "js/content.js", ] }) - const files = extraScripts || await brapi.tabs.sendMessage(tab.id, {dest: "contentScript", method: "getRequireJs"}) + const files = extraScripts || await brapi.tabs.sendMessage(tab.id, { dest: "contentScript", method: "getRequireJs" }) await brapi.scripting.executeScript({ target: { tabId: tab.id, @@ -419,7 +483,7 @@ async function injectPlayer(tab) { throw new Error("Incognito tab") } await brapi.scripting.executeScript({ - target: {tabId: tab.id}, + target: { tabId: tab.id }, func: createPlayerFrame }) } @@ -450,7 +514,7 @@ async function createPlayerTab() { index: 0, active: false, }) - await brapi.tabs.update(tab.id, {pinned: true}) + await brapi.tabs.update(tab.id, { pinned: true }) } diff --git a/js/player.js b/js/player.js index 16be0cc..fb8bef0 100644 --- a/js/player.js +++ b/js/player.js @@ -8,23 +8,23 @@ var lastUrlPromise = Promise.resolve(null) const piperSubject = new rxjs.Subject() const piperObservable = rxjs.defer(() => { - createPiperFrame() - return piperSubject - }) + createPiperFrame() + return piperSubject +}) .pipe( - rxjs.shareReplay({bufferSize: 1, refCount: false}) + rxjs.shareReplay({ bufferSize: 1, refCount: false }) ) const piperCallbacks = new rxjs.Subject() const piperDispatcher = makeDispatcher("piper-host", { - advertiseVoices({voices}, sender) { - updateSettings({piperVoices: voices}) + advertiseVoices({ voices }, sender) { + updateSettings({ piperVoices: voices }) piperSubject.next(sender) }, - onStart: args => piperCallbacks.next({type: "start", ...args}), - onSentence: args => piperCallbacks.next({type: "sentence", ...args}), - onParagraph: args => piperCallbacks.next({type: "paragraph", ...args}), - onEnd: args => piperCallbacks.next({type: "end", ...args}), - onError: args => piperCallbacks.next({type: "error", ...args}), + onStart: args => piperCallbacks.next({ type: "start", ...args }), + onSentence: args => piperCallbacks.next({ type: "sentence", ...args }), + onParagraph: args => piperCallbacks.next({ type: "paragraph", ...args }), + onEnd: args => piperCallbacks.next({ type: "end", ...args }), + onError: args => piperCallbacks.next({ type: "error", ...args }), audioPlay: args => audioPlayer.play(args.src, args.rate, args.volume), audioPause: () => audioPlayer.pause(), audioResume: () => audioPlayer.resume(), @@ -40,7 +40,7 @@ const audioPlayer = immediate(() => { return new Promise((fulfill, reject) => { current = { playbackState$, - playback: playAudio(Promise.resolve(url), {rate, volume}, playbackState$).subscribe({ + playback: playAudio(Promise.resolve(url), { rate, volume }, playbackState$).subscribe({ complete: fulfill, error: reject }) @@ -59,12 +59,12 @@ const audioPlayer = immediate(() => { const fasttextSubject = new rxjs.Subject() const fasttextObservable = rxjs.defer(() => { - createFasttextFrame() - return fasttextSubject - }) + createFasttextFrame() + return fasttextSubject +}) .pipe( rxjs.startWith(null), - rxjs.shareReplay({bufferSize: 1, refCount: false}) + rxjs.shareReplay({ bufferSize: 1, refCount: false }) ) const fasttextDispatcher = makeDispatcher("fasttext-host", { onServiceReady(args, sender) { @@ -74,12 +74,12 @@ const fasttextDispatcher = makeDispatcher("fasttext-host", { window.addEventListener("message", event => { - const send = message => event.source.postMessage(message, {targetOrigin: event.origin}) + const send = message => event.source.postMessage(message, { targetOrigin: event.origin }) piperDispatcher.dispatch(event.data, { sendRequest(method, args) { const id = String(Math.random()) - send({from: "piper-host", to: "piper-service", type: "request", id, method, args}) + send({ from: "piper-host", to: "piper-service", type: "request", id, method, args }) return piperDispatcher.waitForResponse(id) } }, send) @@ -87,7 +87,7 @@ window.addEventListener("message", event => { fasttextDispatcher.dispatch(event.data, { sendRequest(method, args) { const id = String(Math.random()) - send({from: "fasttext-host", to: "fasttext-service", type: "request", id, method, args}) + send({ from: "fasttext-host", to: "fasttext-service", type: "request", id, method, args }) return fasttextDispatcher.waitForResponse(id) } }, send) @@ -101,7 +101,7 @@ if (queryString.has("autoclose")) rxjs.combineLatest(idleSubject, piperSubject.pipe(rxjs.startWith(null))) .pipe( rxjs.switchMap(([isIdle, piper]) => { - if (isIdle) return rxjs.timer(queryString.get("autoclose") == "long" || piper ? 15*60*1000 : 5*60*1000) + if (isIdle) return rxjs.timer(queryString.get("autoclose") == "long" || piper ? 15 * 60 * 1000 : 5 * 60 * 1000) else return rxjs.EMPTY }) ) @@ -125,12 +125,13 @@ var messageHandlers = { isPaired: () => phoneTtsEngine.isPaired(), managePiperVoices, getLastUrl: () => lastUrlPromise, + synthesizeDownloadAudio: synthesizeDownloadAudio, } registerMessageListener("player", messageHandlers) if (queryString.has("opener")) { - brapi.runtime.sendMessage({dest: queryString.get("opener"), method: "playerCheckIn"}) + brapi.runtime.sendMessage({ dest: queryString.get("opener"), method: "playerCheckIn" }) .catch(console.error) } else { bgPageInvoke("playerCheckIn") @@ -139,22 +140,153 @@ if (queryString.has("opener")) { document.addEventListener("DOMContentLoaded", initialize) +async function synthesizeDownloadAudio(text) { + + // SETTINGS + const settings = await getSettings(["rate", "pitch"]); + const lang = "en"; // best voice + + // SPLIT TEXT INTO SMALL CHUNKS (MAX 180 characters) + const chunks = []; + let remaining = text.trim(); + + while (remaining.length > 0) { + chunks.push(remaining.slice(0, 180)); + remaining = remaining.slice(180); + } + + const audioBlobs = []; + + // STEP 3 — GET AUDIO URL FOR EACH CHUNK + for (let i = 0; i < chunks.length; i++) { + const part = chunks[i]; + + let url; + try { + url = await googleTranslateSynthesizeSpeech(part, lang); + } catch (err) { + console.error("Chunk synthesis failed:", err); + throw new Error("GoogleTranslate TTS failed for chunk " + (i + 1)); + } + + // FETCH BLOB + const blob = await fetch(url).then(r => r.blob()); + audioBlobs.push(blob); + } + + // MERGE ALL BLOBS INTO ONE FINAL MP3 + const merged = new Blob(audioBlobs, { type: "audio/mp3" }); + + // STEP 5 — Convert to Base64 + return await new Promise(resolve => { + const reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result); + }; + reader.readAsDataURL(merged); + }); +} + + + +async function getAudioUrlForDownload(engine, text, options) { + + // Case 1: Engine already has getAudioUrl() + if (engine.getAudioUrl) { + return await engine.getAudioUrl(text, options.voice, options.pitch); + } + + // Case 2: Google Translate engine + if (engine.constructor.name === "GoogleTranslateTtsEngine") { + throw new Error("GoogleTranslate engine cannot be used for download"); + } + // Case 3: OpenAI engine + if (engine.constructor.name === "OpenaiTtsEngine") { + return await engine._getAudioUrl(text, options.voice, options.pitch); + } + + // Case 4: If engine starts playback using playAudio(url) + if (engine.speak) { + // Patch speak() to capture the URL + return new Promise(async (resolve, reject) => { + const playback = engine.speak(text, options, new rxjs.BehaviorSubject("resumed")); + playback.subscribe({ + next(event) { + if (event.type === "start" && event.src) { + resolve(event.src); // capture URL + } + }, + error: reject, + complete: () => reject("No URL found") + }); + }); + } + + throw new Error("Engine does not support audio download"); +} + + +async function synthesizeBlob(text) { + const opts = await getSettings(["voiceName", "rate", "pitch"]) + .then(x => ({ + voice: { voiceName: x.voiceName }, + rate: x.rate, + pitch: x.pitch, + })); + + // Create temporary Speech instance + const speech = new Speech([text], opts); + + // Instead of createAudioBlob(), use the engine directly: + const engine = speech.engine; // internal engine chosen by pickEngine() + + if (!engine.getAudioUrl) { + throw new Error("Selected voice engine does not support audio download."); + } + + // Get raw URL + const url = await engine.getAudioUrl(text, opts.voice, opts.pitch); + + // Convert URL to Blob + const res = await fetch(url); + const blob = await res.blob(); + + return blob; +} + + +function blobToBase64(blob) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); +} + +async function synthesizeSpeech(text, options) { + // Use the same engine the player normally uses + const speech = new Speech([text], options); + + // call internal engine to produce audio Blob + const audio = await speech.createAudioBlob(); + return audio; +} async function initialize() { setI18nText() $("#hidethistab-link") .toggle(canUseEmbeddedPlayer() && !(await getSettings()).useEmbeddedPlayer) - .click(function() { + .click(function () { $("#dialog-backdrop, #hidethistab-dialog").show() }) $("#hidethistab-dialog .btn, #hidethistab-dialog .close") - .click(function(event) { + .click(function (event) { $("#dialog-backdrop, #hidethistab-dialog").hide() if ($(event.target).is(".btn-ok")) { - updateSettings({useEmbeddedPlayer: true}) + updateSettings({ useEmbeddedPlayer: true }) .then(() => window.close()) .catch(console.error) } @@ -165,13 +297,13 @@ function playText(text, opts) { opts = opts || {} playbackError = null if (!activeDoc) { - openDoc(new SimpleSource(text.split(/(?:\r?\n){2,}/), {lang: opts.lang}), function(err) { + openDoc(new SimpleSource(text.split(/(?:\r?\n){2,}/), { lang: opts.lang }), function (err) { if (err) playbackError = err }) } const doc = activeDoc return activeDoc.play() - .catch(function(err) { + .catch(function (err) { if (doc == activeDoc) { handleError(err); closeDoc(); @@ -183,13 +315,13 @@ function playText(text, opts) { function playTab() { playbackError = null if (!activeDoc) { - openDoc(new TabSource(), function(err) { + openDoc(new TabSource(), function (err) { if (err) playbackError = err }) } const doc = activeDoc return activeDoc.play() - .catch(function(err) { + .catch(function (err) { if (doc == activeDoc) { handleError(err); closeDoc(); @@ -219,7 +351,7 @@ function resume() { function getPlaybackState() { if (activeDoc) { return Promise.all([activeDoc.getState(), activeDoc.getActiveSpeech()]) - .then(function(results) { + .then(function (results) { return { state: results[0], speechInfo: results[1] && results[1].getInfo(), @@ -239,7 +371,7 @@ function getPlaybackState() { } function openDoc(source, onEnd) { - activeDoc = new Doc(source, function(err) { + activeDoc = new Doc(source, function (err) { handleError(err); closeDoc(); if (typeof onEnd == "function") onEnd(err); @@ -304,14 +436,14 @@ function playAudio(urlPromise, options, playbackState$) { } } -var requestAudioPlaybackPermission = lazy(async function() { +var requestAudioPlaybackPermission = lazy(async function () { const thisTab = await brapi.tabs.getCurrent() - const prevTab = await brapi.tabs.query({windowId: thisTab.windowId, active: true}).then(tabs => tabs[0]) - await brapi.tabs.update(thisTab.id, {active: true}) + const prevTab = await brapi.tabs.query({ windowId: thisTab.windowId, active: true }).then(tabs => tabs[0]) + await brapi.tabs.update(thisTab.id, { active: true }) $("#dialog-backdrop, #audio-playback-permission-dialog").show() await new Audio(brapi.runtime.getURL("sound/silence.mp3")).play() $("#dialog-backdrop, #audio-playback-permission-dialog").hide() - await brapi.tabs.update(prevTab.id, {active: true}) + await brapi.tabs.update(prevTab.id, { active: true }) }) async function createOffscreen() { @@ -334,10 +466,10 @@ function playAudioOffscreen(urlPromise, options, playbackState$) { if (state == "resumed") { return rxjs.defer(async () => { if (!playback$) { - const result = await sendToOffscreen({method: "play", args: [url, options]}) + const result = await sendToOffscreen({ method: "play", args: [url, options] }) if (result != true) throw "Offscreen doc not present" } else { - const result = await sendToOffscreen({method: "resume"}) + const result = await sendToOffscreen({ method: "resume" }) if (result != true) throw "Offscreen doc gone" } }).pipe( @@ -345,7 +477,7 @@ function playAudioOffscreen(urlPromise, options, playbackState$) { console.debug(err) return rxjs.defer(createOffscreen).pipe( rxjs.exhaustMap(async () => { - const result = await sendToOffscreen({method: "play", args: [url, options]}) + const result = await sendToOffscreen({ method: "play", args: [url, options] }) if (result != true) throw new Error("Offscreen doc inaccessible") }) ) @@ -353,7 +485,7 @@ function playAudioOffscreen(urlPromise, options, playbackState$) { rxjs.exhaustMap(() => rxjs.NEVER.pipe( rxjs.finalize(() => { - sendToOffscreen({method: "pause"}) + sendToOffscreen({ method: "pause" }) .catch(console.error) }) ) @@ -368,7 +500,7 @@ function playAudioOffscreen(urlPromise, options, playbackState$) { ), rxjs.mergeWith( new rxjs.Observable(observer => { - messageHandlers.offscreenPlaybackEvent = function(event) { + messageHandlers.offscreenPlaybackEvent = function (event) { if (event.type == "error") observer.error(event.error) else observer.next(event) } @@ -418,8 +550,8 @@ function managePiperVoices() { .catch(console.error) brapi.tabs.getCurrent() .then(tab => Promise.all([ - brapi.windows.update(tab.windowId, {focused: true}), - brapi.tabs.update(tab.id, {active: true}) + brapi.windows.update(tab.windowId, { focused: true }), + brapi.tabs.update(tab.id, { active: true }) ])) .catch(console.error) return "OK" @@ -433,9 +565,9 @@ function createPiperFrame() { f.allow = "cross-origin-isolated" f.style.position = "absolute" f.style.left = - f.style.top = "0" + f.style.top = "0" f.style.width = - f.style.height = "100%" + f.style.height = "100%" f.style.borderWidth = "0" document.body.appendChild(f) } diff --git a/js/popup.js b/js/popup.js index d0c91cf..f682189 100644 --- a/js/popup.js +++ b/js/popup.js @@ -18,9 +18,9 @@ piperInitializingSubject else $("#status").hide() }) -$(function() { +$(function () { if (queryString.isPopup) $("body").addClass("is-popup") - else getCurrentTab().then(function(currentTab) {return updateSettings({readAloudTab: currentTab.id})}) + else getCurrentTab().then(function (currentTab) { return updateSettings({ readAloudTab: currentTab.id }) }) }) @@ -38,8 +38,8 @@ async function popout(tabId) { const url = brapi.runtime.getURL("popup.html?tab=" + activeTab.id) try { if (!tabId) throw "Create" - const tab = await updateTab(tabId, {url, active: true}) - await updateWindow(tab.windowId, {focused: true}).catch(console.error) + const tab = await updateTab(tabId, { url, active: true }) + await updateWindow(tab.windowId, { focused: true }).catch(console.error) window.close() } catch (err) { @@ -68,16 +68,14 @@ async function init() { $("#decrease-window-size").click(changeWindowSize.bind(null, -1)); $("#increase-window-size").click(changeWindowSize.bind(null, +1)); $("#toggle-dark-mode").click(toggleDarkMode); - + $("#downloadBtn").click(onDownload); refreshSize(); checkAnnouncements(); - const {state} = await bgPageInvoke("getPlaybackState") + const { state } = await bgPageInvoke("getPlaybackState") if (state == "PAUSED" || state == "STOPPED") onPlay() } - - function handleError(err) { if (!err) return; if (err.name == "CancellationException") return; @@ -86,14 +84,14 @@ function handleError(err) { var errInfo = JSON.parse(err.message); $("#status").html(formatError(errInfo)).show(); - $("#status a").click(function() { + $("#status a").click(function () { switch ($(this).attr("href")) { case "#open-extension-settings": - brapi.tabs.create({url: "chrome://extensions/?id=" + brapi.runtime.id}); + brapi.tabs.create({ url: "chrome://extensions/?id=" + brapi.runtime.id }); break; case "#request-permissions": brapi.permissions.request(errInfo.perms) - .then(function(granted) { + .then(function (granted) { if (granted) { if (errInfo.reload) return reloadAndPlay() else $("#btnPlay").click() @@ -101,22 +99,22 @@ function handleError(err) { }) break; case "#sign-in": - getAuthToken({interactive: true}) - .then(function(token) { + getAuthToken({ interactive: true }) + .then(function (token) { if (token) $("#btnPlay").click(); }) - .catch(function(err) { + .catch(function (err) { $("#status").text(err.message).show(); }) break; case "#auth-wavenet": brapi.permissions.request(config.wavenetPerms) - .then(function(granted) { + .then(function (granted) { if (granted) bgPageInvoke("authWavenet"); }) break; case "#open-pdf-viewer": - brapi.tabs.create({url: config.pdfViewerUrl}) + brapi.tabs.create({ url: config.pdfViewerUrl }) break case "#connect-phone": location.href = "connect-phone.html" @@ -126,7 +124,7 @@ function handleError(err) { } else if (config.browserId == "opera" && /locked fullscreen/.test(err.message)) { $("#status").html("Click here to start read aloud.").show() - $("#status a").click(async function() { + $("#status a").click(async function () { try { playerCheckIn$.pipe(rxjs.take(1)).subscribe(() => $("#btnPlay").click()) const tab = await brapi.tabs.create({ @@ -134,7 +132,7 @@ function handleError(err) { index: 0, active: false, }) - brapi.tabs.update(tab.id, {pinned: true}) + brapi.tabs.update(tab.id, { pinned: true }) .catch(console.error) } catch (err) { handleError(err) @@ -183,12 +181,12 @@ function updateHighlighting(speech) { var elem = $("#highlight"); if (!elem.data("texts") || elem.data("texts").length != speech.texts.length - || elem.data("texts").some((text,i) => text != speech.texts[i]) + || elem.data("texts").some((text, i) => text != speech.texts[i]) ) { elem.css("direction", speech.isRTL ? "rtl" : "") - .data({texts: speech.texts, position: null}) + .data({ texts: speech.texts, position: null }) .empty() - for (let i=0; i= scrollParent.height()) - scrollParent.animate({scrollTop: scrollParent[0].scrollTop + childTop - 10}) + scrollParent.animate({ scrollTop: scrollParent[0].scrollTop + childTop - 10 }) } @@ -260,7 +258,7 @@ function onPlay() { $("#status").hide(); const requestId = currentPlayRequestId = Math.random() bgPageInvoke("getPlaybackState") - .then(function(stateInfo) { + .then(function (stateInfo) { if (stateInfo.state == "PAUSED") return bgPageInvoke("resume") else return bgPageInvoke("playTab", queryString.tab ? [Number(queryString.tab)] : []) }) @@ -313,25 +311,59 @@ function onSeek(n) { function changeFontSize(delta) { getSettings(["highlightFontSize"]) - .then(function(settings) { + .then(function (settings) { var newSize = (settings.highlightFontSize || defaults.highlightFontSize) + delta; - if (newSize >= 1 && newSize <= 8) return updateSettings({highlightFontSize: newSize}).then(refreshSize); + if (newSize >= 1 && newSize <= 8) return updateSettings({ highlightFontSize: newSize }).then(refreshSize); }) .catch(handleError) } +async function onDownload() { + try { + // Ask BACKGROUND for selected text + const selectedText = await bgPageInvoke("getSelectedText"); + + if (!selectedText) { + alert("Please select some text first."); + return; + } + + // Ask BACKGROUND to synthesize audio + const base64Audio = await bgPageInvoke("downloadSelectedText", [selectedText]); + + // Trigger file download + const a = document.createElement("a"); + a.href = base64Audio; + a.download = makeSafeFilename(selectedText) + ".mp3"; + a.click(); + + } catch (err) { + console.error("DOWNLOAD ERROR → FULL ERROR:", JSON.stringify(err, null, 2)); + alert("Unable to download file. Check console."); + } +} + +function makeSafeFilename(text) { + return text + .trim() + .slice(0, 40) // limit length + .replace(/[\/\\:*?"<>|]/g, "") // remove illegal characters + .replace(/\s+/g, "_") // replace spaces + .toLowerCase(); +} + function changeWindowSize(delta) { getSettings(["highlightWindowSize"]) - .then(function(settings) { + .then(function (settings) { var newSize = (settings.highlightWindowSize || defaults.highlightWindowSize) + delta; - if (newSize >= 1 && newSize <= 3) return updateSettings({highlightWindowSize: newSize}).then(refreshSize); + if (newSize >= 1 && newSize <= 3) return updateSettings({ highlightWindowSize: newSize }).then(refreshSize); }) .catch(handleError) } function refreshSize() { return getSettings(["highlightFontSize", "highlightWindowSize"]) - .then(function(settings) { + .then(function (settings) { var fontSize = getFontSize(settings); var windowSize = getWindowSize(settings); $("#highlight").css({ @@ -366,29 +398,29 @@ function refreshSize() { function checkAnnouncements() { var now = new Date().getTime(); getSettings(["announcement"]) - .then(function(settings) { + .then(function (settings) { var ann = settings.announcement; if (ann && ann.expire > now) return ann; else return ajaxGet(config.serviceUrl + "/read-aloud/announcement") .then(JSON.parse) - .then(function(result) { - result.expire = now + 6*3600*1000; + .then(function (result) { + result.expire = now + 6 * 3600 * 1000; if (ann && result.id == ann.id) { result.lastShown = ann.lastShown; result.disabled = ann.disabled; } - updateSettings({announcement: result}); + updateSettings({ announcement: result }); return result; }) }) - .then(function(ann) { + .then(function (ann) { if (ann.text && !ann.disabled) { - if (!ann.lastShown || now-ann.lastShown > ann.period*60*1000) { + if (!ann.lastShown || now - ann.lastShown > ann.period * 60 * 1000) { showAnnouncement(ann); ann.lastShown = now; - updateSettings({announcement: ann}); + updateSettings({ announcement: ann }); } } }) @@ -398,13 +430,13 @@ function showAnnouncement(ann) { var html = escapeHtml(ann.text).replace(/\[(.*?)\]/g, "$1").replace(/\n/g, "
"); $("#footer").html(html).addClass("announcement"); if (ann.disableIfClick) - $("#footer a").click(function() { + $("#footer a").click(function () { ann.disabled = true; - updateSettings({announcement: ann}); + updateSettings({ announcement: ann }); }) } function toggleDarkMode() { const darkMode = document.body.classList.toggle("dark-mode") - updateSettings({darkMode}) + updateSettings({ darkMode }) } diff --git a/js/speech.js b/js/speech.js index 34477e0..0bf8aeb 100644 --- a/js/speech.js +++ b/js/speech.js @@ -7,6 +7,7 @@ function Speech(texts, options) { var self = this; const engine = pickEngine() + this.engine = engine; let piperState this.options = options; diff --git a/popup.html b/popup.html index 5adbf61..c9fb8f7 100644 --- a/popup.html +++ b/popup.html @@ -26,6 +26,7 @@ stop settings + Download