summaryrefslogtreecommitdiff
path: root/ext/js/media
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/media')
-rw-r--r--ext/js/media/audio-downloader.js126
-rw-r--r--ext/js/media/audio-system.js41
-rw-r--r--ext/js/media/media-util.js2
-rw-r--r--ext/js/media/text-to-speech-audio.js16
4 files changed, 165 insertions, 20 deletions
diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js
index 1720a5d9..e041cc67 100644
--- a/ext/js/media/audio-downloader.js
+++ b/ext/js/media/audio-downloader.js
@@ -17,17 +17,25 @@
*/
import {RequestBuilder} from '../background/request-builder.js';
+import {ExtensionError} from '../core/extension-error.js';
import {JsonSchema} from '../data/json-schema.js';
import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
import {NativeSimpleDOMParser} from '../dom/native-simple-dom-parser.js';
import {SimpleDOMParser} from '../dom/simple-dom-parser.js';
export class AudioDownloader {
+ /**
+ * @param {{japaneseUtil: import('../language/sandbox/japanese-util.js').JapaneseUtil, requestBuilder: RequestBuilder}} details
+ */
constructor({japaneseUtil, requestBuilder}) {
+ /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */
this._japaneseUtil = japaneseUtil;
+ /** @type {RequestBuilder} */
this._requestBuilder = requestBuilder;
+ /** @type {?JsonSchema} */
this._customAudioListSchema = null;
- this._getInfoHandlers = new Map([
+ /** @type {Map<import('settings').AudioSourceType, import('audio-downloader').GetInfoHandler>} */
+ this._getInfoHandlers = new Map(/** @type {[name: import('settings').AudioSourceType, handler: import('audio-downloader').GetInfoHandler][]} */ ([
['jpod101', this._getInfoJpod101.bind(this)],
['jpod101-alternate', this._getInfoJpod101Alternate.bind(this)],
['jisho', this._getInfoJisho.bind(this)],
@@ -35,9 +43,15 @@ export class AudioDownloader {
['text-to-speech-reading', this._getInfoTextToSpeechReading.bind(this)],
['custom', this._getInfoCustom.bind(this)],
['custom-json', this._getInfoCustomJson.bind(this)]
- ]);
+ ]));
}
+ /**
+ * @param {import('audio').AudioSourceInfo} source
+ * @param {string} term
+ * @param {string} reading
+ * @returns {Promise<import('audio-downloader').Info[]>}
+ */
async getTermAudioInfoList(source, term, reading) {
const handler = this._getInfoHandlers.get(source.type);
if (typeof handler === 'function') {
@@ -50,6 +64,14 @@ export class AudioDownloader {
return [];
}
+ /**
+ * @param {import('audio').AudioSourceInfo[]} sources
+ * @param {?number} preferredAudioIndex
+ * @param {string} term
+ * @param {string} reading
+ * @param {?number} idleTimeout
+ * @returns {Promise<import('audio-downloader').AudioBinaryBase64>}
+ */
async downloadTermAudio(sources, preferredAudioIndex, term, reading, idleTimeout) {
const errors = [];
for (const source of sources) {
@@ -70,28 +92,34 @@ export class AudioDownloader {
}
}
- const error = new Error('Could not download audio');
+ const error = new ExtensionError('Could not download audio');
error.data = {errors};
throw error;
}
// Private
+ /**
+ * @param {string} url
+ * @param {string} base
+ * @returns {string}
+ */
_normalizeUrl(url, base) {
return new URL(url, base).href;
}
+ /** @type {import('audio-downloader').GetInfoHandler} */
async _getInfoJpod101(term, reading) {
if (reading === term && this._japaneseUtil.isStringEntirelyKana(term)) {
reading = term;
- term = null;
+ term = '';
}
const params = new URLSearchParams();
- if (term) {
+ if (term.length > 0) {
params.set('kanji', term);
}
- if (reading) {
+ if (reading.length > 0) {
params.set('kana', reading);
}
@@ -99,6 +127,7 @@ export class AudioDownloader {
return [{type: 'url', url}];
}
+ /** @type {import('audio-downloader').GetInfoHandler} */
async _getInfoJpod101Alternate(term, reading) {
const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post';
const data = new URLSearchParams({
@@ -149,6 +178,7 @@ export class AudioDownloader {
throw new Error('Failed to find audio URL');
}
+ /** @type {import('audio-downloader').GetInfoHandler} */
async _getInfoJisho(term, reading) {
const fetchUrl = `https://jisho.org/search/${term}`;
const response = await this._requestBuilder.fetchAnonymous(fetchUrl, {
@@ -181,26 +211,52 @@ export class AudioDownloader {
throw new Error('Failed to find audio URL');
}
- async _getInfoTextToSpeech(term, reading, {voice}) {
- if (!voice) {
- throw new Error('No voice');
+ /** @type {import('audio-downloader').GetInfoHandler} */
+ async _getInfoTextToSpeech(term, reading, details) {
+ if (typeof details !== 'object' || details === null) {
+ throw new Error('Invalid arguments');
+ }
+ const {voice} = details;
+ if (typeof voice !== 'string') {
+ throw new Error('Invalid voice');
}
return [{type: 'tts', text: term, voice: voice}];
}
- async _getInfoTextToSpeechReading(term, reading, {voice}) {
- if (!voice) {
- throw new Error('No voice');
+ /** @type {import('audio-downloader').GetInfoHandler} */
+ async _getInfoTextToSpeechReading(term, reading, details) {
+ if (typeof details !== 'object' || details === null) {
+ throw new Error('Invalid arguments');
+ }
+ const {voice} = details;
+ if (typeof voice !== 'string') {
+ throw new Error('Invalid voice');
}
return [{type: 'tts', text: reading, voice: voice}];
}
- async _getInfoCustom(term, reading, {url}) {
+ /** @type {import('audio-downloader').GetInfoHandler} */
+ async _getInfoCustom(term, reading, details) {
+ if (typeof details !== 'object' || details === null) {
+ throw new Error('Invalid arguments');
+ }
+ let {url} = details;
+ if (typeof url !== 'string') {
+ throw new Error('Invalid url');
+ }
url = this._getCustomUrl(term, reading, url);
return [{type: 'url', url}];
}
- async _getInfoCustomJson(term, reading, {url}) {
+ /** @type {import('audio-downloader').GetInfoHandler} */
+ async _getInfoCustomJson(term, reading, details) {
+ if (typeof details !== 'object' || details === null) {
+ throw new Error('Invalid arguments');
+ }
+ let {url} = details;
+ if (typeof url !== 'string') {
+ throw new Error('Invalid url');
+ }
url = this._getCustomUrl(term, reading, url);
const response = await this._requestBuilder.fetchAnonymous(url, {
@@ -220,12 +276,14 @@ export class AudioDownloader {
if (this._customAudioListSchema === null) {
const schema = await this._getCustomAudioListSchema();
- this._customAudioListSchema = new JsonSchema(schema);
+ this._customAudioListSchema = new JsonSchema(/** @type {import('json-schema').Schema} */ (schema));
}
this._customAudioListSchema.validate(responseJson);
+ /** @type {import('audio-downloader').Info[]} */
const results = [];
for (const {url: url2, name} of responseJson.audioSources) {
+ /** @type {import('audio-downloader').Info1} */
const info = {type: 'url', url: url2};
if (typeof name === 'string') { info.name = name; }
results.push(info);
@@ -233,17 +291,32 @@ export class AudioDownloader {
return results;
}
+ /**
+ * @param {string} term
+ * @param {string} reading
+ * @param {string} url
+ * @returns {string}
+ * @throws {Error}
+ */
_getCustomUrl(term, reading, url) {
if (typeof url !== 'string') {
throw new Error('No custom URL defined');
}
const data = {term, reading};
- return url.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[m1]}` : m0));
+ return url.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[/** @type {'term'|'reading'} */ (m1)]}` : m0));
}
+ /**
+ * @param {string} url
+ * @param {import('settings').AudioSourceType} sourceType
+ * @param {?number} idleTimeout
+ * @returns {Promise<import('audio-downloader').AudioBinaryBase64>}
+ */
async _downloadAudioFromUrl(url, sourceType, idleTimeout) {
let signal;
+ /** @type {?import('request-builder.js').ProgressCallback} */
let onProgress = null;
+ /** @type {?import('core').Timeout} */
let idleTimer = null;
if (typeof idleTimeout === 'number') {
const abortController = new AbortController();
@@ -252,7 +325,9 @@ export class AudioDownloader {
abortController.abort('Idle timeout');
};
onProgress = (done) => {
- clearTimeout(idleTimer);
+ if (idleTimer !== null) {
+ clearTimeout(idleTimer);
+ }
idleTimer = done ? null : setTimeout(onIdleTimeout, idleTimeout);
};
idleTimer = setTimeout(onIdleTimeout, idleTimeout);
@@ -287,6 +362,11 @@ export class AudioDownloader {
return {data, contentType};
}
+ /**
+ * @param {ArrayBuffer} arrayBuffer
+ * @param {import('settings').AudioSourceType} sourceType
+ * @returns {Promise<boolean>}
+ */
async _isAudioBinaryValid(arrayBuffer, sourceType) {
switch (sourceType) {
case 'jpod101':
@@ -304,6 +384,10 @@ export class AudioDownloader {
}
}
+ /**
+ * @param {ArrayBuffer} arrayBuffer
+ * @returns {Promise<string>}
+ */
async _arrayBufferDigest(arrayBuffer) {
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer)));
let digest = '';
@@ -313,6 +397,11 @@ export class AudioDownloader {
return digest;
}
+ /**
+ * @param {string} content
+ * @returns {import('simple-dom-parser').ISimpleDomParser}
+ * @throws {Error}
+ */
_createSimpleDOMParser(content) {
if (typeof NativeSimpleDOMParser !== 'undefined' && NativeSimpleDOMParser.isSupported()) {
return new NativeSimpleDOMParser(content);
@@ -323,6 +412,9 @@ export class AudioDownloader {
}
}
+ /**
+ * @returns {Promise<unknown>}
+ */
async _getCustomAudioListSchema() {
const url = chrome.runtime.getURL('/data/schemas/custom-audio-list-schema.json');
const response = await fetch(url, {
diff --git a/ext/js/media/audio-system.js b/ext/js/media/audio-system.js
index 55812bec..1e8f1be2 100644
--- a/ext/js/media/audio-system.js
+++ b/ext/js/media/audio-system.js
@@ -19,12 +19,19 @@
import {EventDispatcher} from '../core.js';
import {TextToSpeechAudio} from './text-to-speech-audio.js';
+/**
+ * @augments EventDispatcher<import('audio-system').EventType>
+ */
export class AudioSystem extends EventDispatcher {
constructor() {
super();
+ /** @type {?HTMLAudioElement} */
this._fallbackAudio = null;
}
+ /**
+ * @returns {void}
+ */
prepare() {
// speechSynthesis.getVoices() will not be populated unless some API call is made.
if (
@@ -35,6 +42,9 @@ export class AudioSystem extends EventDispatcher {
}
}
+ /**
+ * @returns {HTMLAudioElement}
+ */
getFallbackAudio() {
if (this._fallbackAudio === null) {
this._fallbackAudio = new Audio('/data/audio/button.mp3');
@@ -42,6 +52,11 @@ export class AudioSystem extends EventDispatcher {
return this._fallbackAudio;
}
+ /**
+ * @param {string} url
+ * @param {import('settings').AudioSourceType} sourceType
+ * @returns {Promise<HTMLAudioElement>}
+ */
async createAudio(url, sourceType) {
const audio = new Audio(url);
await this._waitForData(audio);
@@ -51,6 +66,12 @@ export class AudioSystem extends EventDispatcher {
return audio;
}
+ /**
+ * @param {string} text
+ * @param {string} voiceUri
+ * @returns {TextToSpeechAudio}
+ * @throws {Error}
+ */
createTextToSpeechAudio(text, voiceUri) {
const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri);
if (voice === null) {
@@ -61,10 +82,17 @@ export class AudioSystem extends EventDispatcher {
// Private
- _onVoicesChanged(e) {
- this.trigger('voiceschanged', e);
+ /**
+ * @param {Event} event
+ */
+ _onVoicesChanged(event) {
+ this.trigger('voiceschanged', event);
}
+ /**
+ * @param {HTMLAudioElement} audio
+ * @returns {Promise<void>}
+ */
_waitForData(audio) {
return new Promise((resolve, reject) => {
audio.addEventListener('loadeddata', () => resolve());
@@ -72,6 +100,11 @@ export class AudioSystem extends EventDispatcher {
});
}
+ /**
+ * @param {HTMLAudioElement} audio
+ * @param {import('settings').AudioSourceType} sourceType
+ * @returns {boolean}
+ */
_isAudioValid(audio, sourceType) {
switch (sourceType) {
case 'jpod101':
@@ -87,6 +120,10 @@ export class AudioSystem extends EventDispatcher {
}
}
+ /**
+ * @param {string} voiceUri
+ * @returns {?SpeechSynthesisVoice}
+ */
_getTextToSpeechVoiceFromVoiceUri(voiceUri) {
try {
for (const voice of speechSynthesis.getVoices()) {
diff --git a/ext/js/media/media-util.js b/ext/js/media/media-util.js
index 843dc11c..1d70acd3 100644
--- a/ext/js/media/media-util.js
+++ b/ext/js/media/media-util.js
@@ -103,7 +103,7 @@ export class MediaUtil {
/**
* Gets the file extension for a corresponding media type.
* @param {string} mediaType The media type to use.
- * @returns {string} A file extension including the dot for the media type,
+ * @returns {?string} A file extension including the dot for the media type,
* otherwise `null`.
*/
static getFileExtensionFromAudioMediaType(mediaType) {
diff --git a/ext/js/media/text-to-speech-audio.js b/ext/js/media/text-to-speech-audio.js
index ae717519..cd1205e5 100644
--- a/ext/js/media/text-to-speech-audio.js
+++ b/ext/js/media/text-to-speech-audio.js
@@ -17,13 +17,22 @@
*/
export class TextToSpeechAudio {
+ /**
+ * @param {string} text
+ * @param {SpeechSynthesisVoice} voice
+ */
constructor(text, voice) {
+ /** @type {string} */
this._text = text;
+ /** @type {SpeechSynthesisVoice} */
this._voice = voice;
+ /** @type {?SpeechSynthesisUtterance} */
this._utterance = null;
+ /** @type {number} */
this._volume = 1;
}
+ /** @type {number} */
get currentTime() {
return 0;
}
@@ -32,6 +41,7 @@ export class TextToSpeechAudio {
// NOP
}
+ /** @type {number} */
get volume() {
return this._volume;
}
@@ -43,6 +53,9 @@ export class TextToSpeechAudio {
}
}
+ /**
+ * @returns {Promise<void>}
+ */
async play() {
try {
if (this._utterance === null) {
@@ -59,6 +72,9 @@ export class TextToSpeechAudio {
}
}
+ /**
+ * @returns {void}
+ */
pause() {
try {
speechSynthesis.cancel();