diff options
-rw-r--r-- | ext/bg/data/custom-audio-list-schema.json | 33 | ||||
-rw-r--r-- | ext/bg/data/options-schema.json | 6 | ||||
-rw-r--r-- | ext/bg/js/audio-downloader.js | 62 | ||||
-rw-r--r-- | ext/bg/js/backend.js | 3 | ||||
-rw-r--r-- | ext/bg/js/options.js | 1 | ||||
-rw-r--r-- | ext/bg/settings2.html | 32 | ||||
-rw-r--r-- | ext/mixed/css/material.css | 1 | ||||
-rw-r--r-- | ext/mixed/js/display-audio.js | 8 | ||||
-rw-r--r-- | ext/mixed/js/display.js | 4 | ||||
-rw-r--r-- | test/test-options-util.js | 1 |
10 files changed, 139 insertions, 12 deletions
diff --git a/ext/bg/data/custom-audio-list-schema.json b/ext/bg/data/custom-audio-list-schema.json new file mode 100644 index 00000000..2cb3ca78 --- /dev/null +++ b/ext/bg/data/custom-audio-list-schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "type", + "audioSources" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "audioSourceList" + }, + "audioSources": { + "type": "array", + "items": { + "type": "object", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } +} diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json index 189c8621..dfc553ed 100644 --- a/ext/bg/data/options-schema.json +++ b/ext/bg/data/options-schema.json @@ -348,6 +348,7 @@ "volume", "autoPlay", "customSourceUrl", + "customSourceType", "textToSpeechVoice" ], "properties": { @@ -387,6 +388,11 @@ "type": "string", "default": "" }, + "customSourceType": { + "type": "string", + "enum": ["audio", "json"], + "default": "audio" + }, "textToSpeechVoice": { "type": "string", "default": "" diff --git a/ext/bg/js/audio-downloader.js b/ext/bg/js/audio-downloader.js index 495b6399..62abda8f 100644 --- a/ext/bg/js/audio-downloader.js +++ b/ext/bg/js/audio-downloader.js @@ -16,6 +16,7 @@ */ /* global + * JsonSchemaValidator * NativeSimpleDOMParser * SimpleDOMParser */ @@ -24,6 +25,8 @@ class AudioDownloader { constructor({japaneseUtil, requestBuilder}) { this._japaneseUtil = japaneseUtil; this._requestBuilder = requestBuilder; + this._customAudioListSchema = null; + this._schemaValidator = null; this._getInfoHandlers = new Map([ ['jpod101', this._getInfoJpod101.bind(this)], ['jpod101-alternate', this._getInfoJpod101Alternate.bind(this)], @@ -183,13 +186,50 @@ class AudioDownloader { return [{type: 'tts', text: reading || expression, voice: textToSpeechVoice}]; } - async _getInfoCustom(expression, reading, {customSourceUrl}) { + async _getInfoCustom(expression, reading, {customSourceUrl, customSourceType}) { if (typeof customSourceUrl !== 'string') { throw new Error('No custom URL defined'); } const data = {expression, reading}; const url = customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[m1]}` : m0)); - return [{type: 'url', url}]; + + switch (customSourceType) { + case 'json': + return await this._getInfoCustomJson(url); + default: + return [{type: 'url', url}]; + } + } + + async _getInfoCustomJson(url) { + const response = await this._requestBuilder.fetchAnonymous(url, { + method: 'GET', + mode: 'cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' + }); + + if (!response.ok) { + throw new Error(`Invalid response: ${response.status}`); + } + + const responseJson = await response.json(); + + const schema = await this._getCustomAudioListSchema(); + if (this._schemaValidator === null) { + this._schemaValidator = new JsonSchemaValidator(); + } + this._schemaValidator.validate(responseJson, schema); + + const results = []; + for (const {url: url2, name} of responseJson.audioSources) { + const info = {type: 'url', url: url2}; + if (typeof name === 'string') { info.name = name; } + results.push(info); + } + return results; } async _downloadAudioFromUrl(url, source) { @@ -254,4 +294,22 @@ class AudioDownloader { throw new Error('DOM parsing not supported'); } } + + async _getCustomAudioListSchema() { + let schema = this._customAudioListSchema; + if (schema === null) { + const url = chrome.runtime.getURL('/bg/data/custom-audio-list-schema.json'); + const response = await fetch(url, { + method: 'GET', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' + }); + schema = await response.json(); + this._customAudioListSchema = schema; + } + return schema; + } } diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 2949cbed..60739aab 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -1581,7 +1581,7 @@ class Backend { throw new Error('Invalid reading and expression'); } - const {sources, customSourceUrl} = details; + const {sources, customSourceUrl, customSourceType} = details; const data = await this._downloadDefinitionAudio( sources, expression, @@ -1589,6 +1589,7 @@ class Backend { { textToSpeechVoice: null, customSourceUrl, + customSourceType, binary: true, disableCache: true } diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index 1690efb0..10919ae3 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -726,6 +726,7 @@ class OptionsUtil { windowType: 'popup', windowState: 'normal' }; + profile.options.audio.customSourceType = 'audio'; } return options; } diff --git a/ext/bg/settings2.html b/ext/bg/settings2.html index 62598352..448cc67d 100644 --- a/ext/bg/settings2.html +++ b/ext/bg/settings2.html @@ -2205,15 +2205,41 @@ </div> </div> <div class="settings-item-right"> - <input type="text" spellcheck="false" autocomplete="off" data-setting="audio.customSourceUrl" placeholder="None"> + <div class="settings-item-group"> + <div class="settings-item-group-item"> + <div class="settings-item-group-item-label">Type</div> + <select class="short-width short-height" data-setting="audio.customSourceType"> + <option value="audio">Audio</option> + <option value="json">JSON</option> + </select> + </div> + <div class="settings-item-group-item"> + <div class="settings-item-group-item-label">URL</div> + <input class="short-height" type="text" spellcheck="false" autocomplete="off" data-setting="audio.customSourceUrl" placeholder="None"> + </div> + </div> </div> </div> <div class="settings-item-children more" hidden> <p> - URL format used for fetching audio clips in <em>Custom</em> mode. + The <em>URL</em> property specifies the URL format used for fetching audio clips in <em>Custom</em> mode. The replacement tags <code data-select-on-click="">{expression}</code> and <code data-select-on-click="">{reading}</code> can be used to specify which expression and reading is being looked up.<br> - Example: <a data-select-on-click="">http://localhost/audio.mp3?expression={expression}&reading={reading}</a> + </p> + <p> + The <em>Type</em> property specifies how the URL is handled when looking up audio: + </p> + <ul> + <li> + <strong>Audio</strong> - The link is treated as a direct link to an audio file that the browser can play. + </li> + <li> + <strong>JSON</strong> - The link is interpreted as a link to a JSON file, which is downloaded and parsed for audio URLs. + The format of the JSON file is specified in <a href="/bg/data/custom-audio-list-schema.json" target="_blank" rel="noopener noreferrer">this schema file</a>. + </li> + </ul> + <p> + Example URL: <a data-select-on-click="">http://localhost/audio.mp3?expression={expression}&reading={reading}</a> </p> <p> <a class="more-toggle" data-parent-distance="3">Less…</a> diff --git a/ext/mixed/css/material.css b/ext/mixed/css/material.css index ec55120b..d144937b 100644 --- a/ext/mixed/css/material.css +++ b/ext/mixed/css/material.css @@ -970,6 +970,7 @@ button.popup-menu-item:disabled { height: calc(16em / 14); background-color: var(--text-color); margin-right: 0.5em; + flex: 0 0 auto; } .popup-menu-item-icon:not([hidden]) { display: block; diff --git a/ext/mixed/js/display-audio.js b/ext/mixed/js/display-audio.js index c60831b1..f624d85b 100644 --- a/ext/mixed/js/display-audio.js +++ b/ext/mixed/js/display-audio.js @@ -112,7 +112,7 @@ class DisplayAudio { const {expression, reading} = expressionReading; const audioOptions = this._getAudioOptions(); - const {textToSpeechVoice, customSourceUrl, volume} = audioOptions; + const {textToSpeechVoice, customSourceUrl, customSourceType, volume} = audioOptions; if (!Array.isArray(sources)) { ({sources} = audioOptions); } @@ -126,7 +126,7 @@ class DisplayAudio { // Create audio let audio; let title; - const info = await this._createExpressionAudio(sources, sourceDetailsMap, expression, reading, {textToSpeechVoice, customSourceUrl}); + const info = await this._createExpressionAudio(sources, sourceDetailsMap, expression, reading, {textToSpeechVoice, customSourceUrl, customSourceType}); if (info !== null) { let source; ({audio, source} = info); @@ -520,13 +520,13 @@ class DisplayAudio { // Entry info entry.index = i; - const {audio, audioResolved, title} = infoList[i]; + const {audio, audioResolved, info: {name}} = infoList[i]; if (audioResolved) { entry.valid = (audio !== null); } const labelNode = entry.node.querySelector('.popup-menu-item-label'); let label = defaultLabel; if (ii > 1) { label = `${label} ${i + 1}`; } - if (typeof title === 'string' && title.length > 0) { label += `: ${title}`; } + if (typeof name === 'string' && name.length > 0) { label += `: ${name}`; } labelNode.textContent = label; } diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index eb8b2900..6c97cb84 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -1432,13 +1432,13 @@ class Display extends EventDispatcher { async _injectAnkiNoteMedia(definition, mode, options, fields) { const { anki: {screenshot: {format, quality}}, - audio: {sources, customSourceUrl} + audio: {sources, customSourceUrl, customSourceType} } = options; const timestamp = Date.now(); const ownerFrameId = this._ownerFrameId; const definitionDetails = this._getDefinitionDetailsForNote(definition); - const audioDetails = (mode !== 'kanji' && this._ankiNoteBuilder.containsMarker(fields, 'audio') ? {sources, customSourceUrl} : null); + const audioDetails = (mode !== 'kanji' && this._ankiNoteBuilder.containsMarker(fields, 'audio') ? {sources, customSourceUrl, customSourceType} : null); const screenshotDetails = (this._ankiNoteBuilder.containsMarker(fields, 'screenshot') ? {ownerFrameId, format, quality} : null); const clipboardDetails = { image: this._ankiNoteBuilder.containsMarker(fields, 'clipboard-image'), diff --git a/test/test-options-util.js b/test/test-options-util.js index 5597dbba..0ec40a9b 100644 --- a/test/test-options-util.js +++ b/test/test-options-util.js @@ -304,6 +304,7 @@ function createProfileOptionsUpdatedTestData1() { volume: 100, autoPlay: false, customSourceUrl: '', + customSourceType: 'audio', textToSpeechVoice: '' }, scanning: { |