Multiple custom audio sources (#1303)
* Fix label * Fix icon size being flexible * Add schema * Add customSourceType option * Update settings * Pass customSourceType to the audio downloader * Implement custom audio JSON mode
This commit is contained in:
parent
ef577b8875
commit
ebfef0c748
33
ext/bg/data/custom-audio-list-schema.json
Normal file
33
ext/bg/data/custom-audio-list-schema.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -348,6 +348,7 @@
|
|||||||
"volume",
|
"volume",
|
||||||
"autoPlay",
|
"autoPlay",
|
||||||
"customSourceUrl",
|
"customSourceUrl",
|
||||||
|
"customSourceType",
|
||||||
"textToSpeechVoice"
|
"textToSpeechVoice"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -387,6 +388,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
|
"customSourceType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["audio", "json"],
|
||||||
|
"default": "audio"
|
||||||
|
},
|
||||||
"textToSpeechVoice": {
|
"textToSpeechVoice": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": ""
|
"default": ""
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* global
|
/* global
|
||||||
|
* JsonSchemaValidator
|
||||||
* NativeSimpleDOMParser
|
* NativeSimpleDOMParser
|
||||||
* SimpleDOMParser
|
* SimpleDOMParser
|
||||||
*/
|
*/
|
||||||
@ -24,6 +25,8 @@ class AudioDownloader {
|
|||||||
constructor({japaneseUtil, requestBuilder}) {
|
constructor({japaneseUtil, requestBuilder}) {
|
||||||
this._japaneseUtil = japaneseUtil;
|
this._japaneseUtil = japaneseUtil;
|
||||||
this._requestBuilder = requestBuilder;
|
this._requestBuilder = requestBuilder;
|
||||||
|
this._customAudioListSchema = null;
|
||||||
|
this._schemaValidator = null;
|
||||||
this._getInfoHandlers = new Map([
|
this._getInfoHandlers = new Map([
|
||||||
['jpod101', this._getInfoJpod101.bind(this)],
|
['jpod101', this._getInfoJpod101.bind(this)],
|
||||||
['jpod101-alternate', this._getInfoJpod101Alternate.bind(this)],
|
['jpod101-alternate', this._getInfoJpod101Alternate.bind(this)],
|
||||||
@ -183,14 +186,51 @@ class AudioDownloader {
|
|||||||
return [{type: 'tts', text: reading || expression, voice: textToSpeechVoice}];
|
return [{type: 'tts', text: reading || expression, voice: textToSpeechVoice}];
|
||||||
}
|
}
|
||||||
|
|
||||||
async _getInfoCustom(expression, reading, {customSourceUrl}) {
|
async _getInfoCustom(expression, reading, {customSourceUrl, customSourceType}) {
|
||||||
if (typeof customSourceUrl !== 'string') {
|
if (typeof customSourceUrl !== 'string') {
|
||||||
throw new Error('No custom URL defined');
|
throw new Error('No custom URL defined');
|
||||||
}
|
}
|
||||||
const data = {expression, reading};
|
const data = {expression, reading};
|
||||||
const url = customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[m1]}` : m0));
|
const url = customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[m1]}` : m0));
|
||||||
|
|
||||||
|
switch (customSourceType) {
|
||||||
|
case 'json':
|
||||||
|
return await this._getInfoCustomJson(url);
|
||||||
|
default:
|
||||||
return [{type: 'url', url}];
|
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) {
|
async _downloadAudioFromUrl(url, source) {
|
||||||
const response = await this._requestBuilder.fetchAnonymous(url, {
|
const response = await this._requestBuilder.fetchAnonymous(url, {
|
||||||
@ -254,4 +294,22 @@ class AudioDownloader {
|
|||||||
throw new Error('DOM parsing not supported');
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1581,7 +1581,7 @@ class Backend {
|
|||||||
throw new Error('Invalid reading and expression');
|
throw new Error('Invalid reading and expression');
|
||||||
}
|
}
|
||||||
|
|
||||||
const {sources, customSourceUrl} = details;
|
const {sources, customSourceUrl, customSourceType} = details;
|
||||||
const data = await this._downloadDefinitionAudio(
|
const data = await this._downloadDefinitionAudio(
|
||||||
sources,
|
sources,
|
||||||
expression,
|
expression,
|
||||||
@ -1589,6 +1589,7 @@ class Backend {
|
|||||||
{
|
{
|
||||||
textToSpeechVoice: null,
|
textToSpeechVoice: null,
|
||||||
customSourceUrl,
|
customSourceUrl,
|
||||||
|
customSourceType,
|
||||||
binary: true,
|
binary: true,
|
||||||
disableCache: true
|
disableCache: true
|
||||||
}
|
}
|
||||||
|
@ -726,6 +726,7 @@ class OptionsUtil {
|
|||||||
windowType: 'popup',
|
windowType: 'popup',
|
||||||
windowState: 'normal'
|
windowState: 'normal'
|
||||||
};
|
};
|
||||||
|
profile.options.audio.customSourceType = 'audio';
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
@ -2205,15 +2205,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-item-right">
|
<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>
|
</div>
|
||||||
<div class="settings-item-children more" hidden>
|
<div class="settings-item-children more" hidden>
|
||||||
<p>
|
<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
|
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>
|
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>
|
||||||
<p>
|
<p>
|
||||||
<a class="more-toggle" data-parent-distance="3">Less…</a>
|
<a class="more-toggle" data-parent-distance="3">Less…</a>
|
||||||
|
@ -970,6 +970,7 @@ button.popup-menu-item:disabled {
|
|||||||
height: calc(16em / 14);
|
height: calc(16em / 14);
|
||||||
background-color: var(--text-color);
|
background-color: var(--text-color);
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.popup-menu-item-icon:not([hidden]) {
|
.popup-menu-item-icon:not([hidden]) {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -112,7 +112,7 @@ class DisplayAudio {
|
|||||||
|
|
||||||
const {expression, reading} = expressionReading;
|
const {expression, reading} = expressionReading;
|
||||||
const audioOptions = this._getAudioOptions();
|
const audioOptions = this._getAudioOptions();
|
||||||
const {textToSpeechVoice, customSourceUrl, volume} = audioOptions;
|
const {textToSpeechVoice, customSourceUrl, customSourceType, volume} = audioOptions;
|
||||||
if (!Array.isArray(sources)) {
|
if (!Array.isArray(sources)) {
|
||||||
({sources} = audioOptions);
|
({sources} = audioOptions);
|
||||||
}
|
}
|
||||||
@ -126,7 +126,7 @@ class DisplayAudio {
|
|||||||
// Create audio
|
// Create audio
|
||||||
let audio;
|
let audio;
|
||||||
let title;
|
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) {
|
if (info !== null) {
|
||||||
let source;
|
let source;
|
||||||
({audio, source} = info);
|
({audio, source} = info);
|
||||||
@ -520,13 +520,13 @@ class DisplayAudio {
|
|||||||
// Entry info
|
// Entry info
|
||||||
entry.index = i;
|
entry.index = i;
|
||||||
|
|
||||||
const {audio, audioResolved, title} = infoList[i];
|
const {audio, audioResolved, info: {name}} = infoList[i];
|
||||||
if (audioResolved) { entry.valid = (audio !== null); }
|
if (audioResolved) { entry.valid = (audio !== null); }
|
||||||
|
|
||||||
const labelNode = entry.node.querySelector('.popup-menu-item-label');
|
const labelNode = entry.node.querySelector('.popup-menu-item-label');
|
||||||
let label = defaultLabel;
|
let label = defaultLabel;
|
||||||
if (ii > 1) { label = `${label} ${i + 1}`; }
|
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;
|
labelNode.textContent = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1432,13 +1432,13 @@ class Display extends EventDispatcher {
|
|||||||
async _injectAnkiNoteMedia(definition, mode, options, fields) {
|
async _injectAnkiNoteMedia(definition, mode, options, fields) {
|
||||||
const {
|
const {
|
||||||
anki: {screenshot: {format, quality}},
|
anki: {screenshot: {format, quality}},
|
||||||
audio: {sources, customSourceUrl}
|
audio: {sources, customSourceUrl, customSourceType}
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const ownerFrameId = this._ownerFrameId;
|
const ownerFrameId = this._ownerFrameId;
|
||||||
const definitionDetails = this._getDefinitionDetailsForNote(definition);
|
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 screenshotDetails = (this._ankiNoteBuilder.containsMarker(fields, 'screenshot') ? {ownerFrameId, format, quality} : null);
|
||||||
const clipboardDetails = {
|
const clipboardDetails = {
|
||||||
image: this._ankiNoteBuilder.containsMarker(fields, 'clipboard-image'),
|
image: this._ankiNoteBuilder.containsMarker(fields, 'clipboard-image'),
|
||||||
|
@ -304,6 +304,7 @@ function createProfileOptionsUpdatedTestData1() {
|
|||||||
volume: 100,
|
volume: 100,
|
||||||
autoPlay: false,
|
autoPlay: false,
|
||||||
customSourceUrl: '',
|
customSourceUrl: '',
|
||||||
|
customSourceType: 'audio',
|
||||||
textToSpeechVoice: ''
|
textToSpeechVoice: ''
|
||||||
},
|
},
|
||||||
scanning: {
|
scanning: {
|
||||||
|
Loading…
Reference in New Issue
Block a user