diff --git a/ext/css/settings.css b/ext/css/settings.css index 1bc2d1a7..86a6cdb3 100644 --- a/ext/css/settings.css +++ b/ext/css/settings.css @@ -1312,6 +1312,45 @@ body.preview-sidebar-visible .fab-container-item.fab-container-item-popup-previe #audio-source-list-empty { display: none; } +.audio-source { + display: flex; + flex-flow: row nowrap; + align-items: center; + align-content: flex-start; + justify-content: flex-start; +} +.audio-source-inner { + margin: 0 0.375em; + flex: 1 1 auto; + display: flex; + flex-flow: row wrap; + align-items: center; + align-content: flex-start; + justify-content: flex-start; +} +.audio-source-type-select { + flex: 1 0 auto; + width: calc(var(--input-width-large) + 2em); + margin: 0.125em 0; +} +.audio-source-parameter-container { + margin: 0.125em 0; + flex: 1e8 1 auto; + flex-flow: row nowrap; + align-items: center; + align-content: flex-start; + justify-content: flex-start; +} +.audio-source-parameter-container:not([hidden]) { + display: flex; +} +.audio-source-parameter-label { + flex: 0 0 auto; + margin: 0 0.375em; +} +.audio-source-parameter { + flex: 1 1 auto; +} .profile-add-button-container { display: flex; diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json index 9afad1e3..4b97342c 100644 --- a/ext/data/schemas/options-schema.json +++ b/ext/data/schemas/options-schema.json @@ -343,8 +343,6 @@ "enabled", "volume", "autoPlay", - "customSourceUrl", - "textToSpeechVoice", "sources" ], "properties": { @@ -362,31 +360,46 @@ "type": "boolean", "default": false }, - "customSourceUrl": { - "type": "string", - "default": "" - }, - "textToSpeechVoice": { - "type": "string", - "default": "" - }, "sources": { "type": "array", "items": { - "type": "string", - "enum": [ - "jpod101", - "jpod101-alternate", - "jisho", - "text-to-speech", - "text-to-speech-reading", - "custom", - "custom-json" + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url", + "voice" ], - "default": "jpod101" + "properties": { + "type": { + "type": "string", + "enum": [ + "jpod101", + "jpod101-alternate", + "jisho", + "text-to-speech", + "text-to-speech-reading", + "custom", + "custom-json" + ], + "default": "jpod101" + }, + "url": { + "type": "string", + "default": "" + }, + "voice": { + "type": "string", + "default": "" + } + } }, "default": [ - "jpod101" + { + "type": "jpod101", + "url": "", + "voice": "" + } ] } } diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index 89d50903..eb29dae4 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -826,16 +826,21 @@ class OptionsUtil { sentenceParsing.terminationCharacterMode = sentenceParsing.enableTerminationCharacters ? 'custom' : 'newlines'; delete sentenceParsing.enableTerminationCharacters; - const {sources, customSourceType} = audio; + const {sources, customSourceUrl, customSourceType, textToSpeechVoice} = audio; audio.sources = sources.map((type) => { switch (type) { + case 'text-to-speech': + case 'text-to-speech-reading': + return {type, url: '', voice: textToSpeechVoice}; case 'custom': - return (customSourceType === 'json' ? 'custom-json' : 'custom'); + return {type: (customSourceType === 'json' ? 'custom-json' : 'custom'), url: customSourceUrl, voice: ''}; default: - return type; + return {type, url: '', voice: ''}; } }); delete audio.customSourceType; + delete audio.customSourceUrl; + delete audio.textToSpeechVoice; } return options; } diff --git a/ext/js/display/display-audio.js b/ext/js/display/display-audio.js index b7ec6ba1..6d2504e4 100644 --- a/ext/js/display/display-audio.js +++ b/ext/js/display/display-audio.js @@ -146,7 +146,7 @@ class DisplayAudio { _onOptionsUpdated({options}) { if (options === null) { return; } - const {enabled, autoPlay, textToSpeechVoice, customSourceUrl, volume, sources} = options.audio; + const {enabled, autoPlay, volume, sources} = options.audio; this._autoPlay = enabled && autoPlay; this._playbackVolume = Number.isFinite(volume) ? Math.max(0.0, Math.min(1.0, volume / 100.0)) : 1.0; @@ -155,13 +155,14 @@ class DisplayAudio { 'jpod101-alternate', 'jisho' ]); + const nameMap = new Map(); this._audioSources.length = 0; - for (const type of sources) { - this._addAudioSourceInfo(type, customSourceUrl, textToSpeechVoice, true); + for (const {type, url, voice} of sources) { + this._addAudioSourceInfo(type, url, voice, true, nameMap); requiredAudioSources.delete(type); } for (const type of requiredAudioSources) { - this._addAudioSourceInfo(type, '', '', false); + this._addAudioSourceInfo(type, '', '', false, nameMap); } const data = document.documentElement.dataset; @@ -170,20 +171,36 @@ class DisplayAudio { this._cache.clear(); } - _addAudioSourceInfo(type, url, voice, isInOptions) { + _addAudioSourceInfo(type, url, voice, isInOptions, nameMap) { const index = this._audioSources.length; const downloadable = this._sourceIsDownloadable(type); - let displayName = this._audioSourceTypeNames.get(type); - if (typeof displayName === 'undefined') { displayName = 'Unknown'; } - this._audioSources.push({ + let name = this._audioSourceTypeNames.get(type); + if (typeof name === 'undefined') { name = 'Unknown'; } + + let entries = nameMap.get(name); + if (typeof entries === 'undefined') { + entries = []; + nameMap.set(name, entries); + } + const nameIndex = entries.length; + if (nameIndex === 1) { + entries[0].nameUnique = false; + } + + const source = { index, type, url, voice, isInOptions, downloadable, - displayName - }); + name, + nameIndex, + nameUnique: (nameIndex === 0) + }; + + entries.push(source); + this._audioSources.push(source); } _onAudioPlayButtonClick(dictionaryEntryIndex, headwordIndex, e) { @@ -580,19 +597,23 @@ class DisplayAudio { let showIcons = false; const currentItems = [...menuItemContainer.children]; for (const source of this._audioSources) { - const {index, displayName, isInOptions, downloadable} = source; + const {index, name, nameIndex, nameUnique, isInOptions, downloadable} = source; const entries = this._getMenuItemEntries(source, term, reading); for (let i = 0, ii = entries.length; i < ii; ++i) { - const {valid, index: subIndex, name} = entries[i]; + const {valid, index: subIndex, name: subName} = entries[i]; let node = this._getOrCreateMenuItem(currentItems, index, subIndex); if (node === null) { node = displayGenerator.instantiateTemplate('audio-button-popup-menu-item'); } const labelNode = node.querySelector('.popup-menu-item-audio-button .popup-menu-item-label'); - let label = displayName; + let label = name; + if (!nameUnique) { + label = `${label} ${nameIndex + 1}`; + if (ii > 1) { label = `${label} -`; } + } if (ii > 1) { label = `${label} ${i + 1}`; } - if (typeof name === 'string' && name.length > 0) { label += `: ${name}`; } + if (typeof subName === 'string' && subName.length > 0) { label += `: ${subName}`; } labelNode.textContent = label; const cardButton = node.querySelector('.popup-menu-item-set-primary-audio-button'); diff --git a/ext/js/pages/settings/audio-controller.js b/ext/js/pages/settings/audio-controller.js index 2581893c..c74c1477 100644 --- a/ext/js/pages/settings/audio-controller.js +++ b/ext/js/pages/settings/audio-controller.js @@ -19,15 +19,17 @@ * AudioSystem */ -class AudioController { +class AudioController extends EventDispatcher { constructor(settingsController, modalController) { + super(); this._settingsController = settingsController; this._modalController = modalController; this._audioSystem = new AudioSystem(); this._audioSourceContainer = null; this._audioSourceAddButton = null; this._audioSourceEntries = []; - this._ttsVoiceTestTextInput = null; + this._voiceTestTextInput = null; + this._voices = []; } get settingsController() { @@ -41,7 +43,7 @@ class AudioController { async prepare() { this._audioSystem.prepare(); - this._ttsVoiceTestTextInput = document.querySelector('#text-to-speech-voice-test-text'); + this._voiceTestTextInput = document.querySelector('#text-to-speech-voice-test-text'); this._audioSourceContainer = document.querySelector('#audio-source-list'); this._audioSourceAddButton = document.querySelector('#audio-source-add'); this._audioSourceContainer.textContent = ''; @@ -76,6 +78,14 @@ class AudioController { }]); } + getVoices() { + return this._voices; + } + + setTestVoice(voice) { + this._voiceTestTextInput.dataset.voice = voice; + } + // Private _onOptionsChanged({options}) { @@ -96,9 +106,8 @@ class AudioController { _onTestTextToSpeech() { try { - const text = this._ttsVoiceTestTextInput.value || ''; - const voiceUri = document.querySelector('[data-setting="audio.textToSpeechVoice"]').value; - + const text = this._voiceTestTextInput.value || ''; + const voiceUri = this._voiceTestTextInput.dataset.voice; const audio = this._audioSystem.createTextToSpeechAudio(text, voiceUri); audio.volume = 1.0; audio.play(); @@ -118,25 +127,8 @@ class AudioController { [] ); voices.sort(this._textToSpeechVoiceCompare.bind(this)); - - for (const select of document.querySelectorAll('[data-setting="audio.textToSpeechVoice"]')) { - const fragment = document.createDocumentFragment(); - - let option = document.createElement('option'); - option.value = ''; - option.textContent = 'None'; - fragment.appendChild(option); - - for (const {voice} of voices) { - option = document.createElement('option'); - option.value = voice.voiceURI; - option.textContent = `${voice.name} (${voice.lang})`; - fragment.appendChild(option); - } - - select.textContent = ''; - select.appendChild(fragment); - } + this._voices = voices; + this.trigger('voicesUpdated'); } _textToSpeechVoiceCompare(a, b) { @@ -163,9 +155,9 @@ class AudioController { ); } - _createAudioSourceEntry(index, type) { + _createAudioSourceEntry(index, source) { const node = this._settingsController.instantiateTemplate('audio-source'); - const entry = new AudioSourceEntry(this, index, type, node); + const entry = new AudioSourceEntry(this, index, source, node); this._audioSourceEntries.push(entry); this._audioSourceContainer.appendChild(node); entry.prepare(); @@ -188,25 +180,31 @@ class AudioController { async _addAudioSource() { const type = this._getUnusedAudioSourceType(); + const source = {type, url: '', voice: ''}; const index = this._audioSourceEntries.length; - this._createAudioSourceEntry(index, type); + this._createAudioSourceEntry(index, source); await this._settingsController.modifyProfileSettings([{ action: 'splice', path: 'audio.sources', start: index, deleteCount: 0, - items: [type] + items: [source] }]); } } class AudioSourceEntry { - constructor(parent, index, type, node) { + constructor(parent, index, source, node) { this._parent = parent; this._index = index; - this._type = type; + this._type = source.type; + this._url = source.url; + this._voice = source.voice; this._node = node; this._eventListeners = new EventListenerCollection(); + this._typeSelect = null; + this._urlInput = null; + this._voiceSelect = null; } get index() { @@ -222,14 +220,23 @@ class AudioSourceEntry { } prepare() { - const select = this._node.querySelector('.audio-source-select'); + this._updateTypeParameter(); + const menuButton = this._node.querySelector('.audio-source-menu-button'); + this._typeSelect = this._node.querySelector('.audio-source-type-select'); + this._urlInput = this._node.querySelector('.audio-source-parameter-container[data-field=url] .audio-source-parameter'); + this._voiceSelect = this._node.querySelector('.audio-source-parameter-container[data-field=voice] .audio-source-parameter'); - select.value = this._type; + this._typeSelect.value = this._type; + this._urlInput.value = this._url; - this._eventListeners.addEventListener(select, 'change', this._onAudioSourceSelectChange.bind(this), false); + this._eventListeners.addEventListener(this._typeSelect, 'change', this._onTypeSelectChange.bind(this), false); + this._eventListeners.addEventListener(this._urlInput, 'change', this._onUrlInputChange.bind(this), false); + this._eventListeners.addEventListener(this._voiceSelect, 'change', this._onVoiceSelectChange.bind(this), false); this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false); this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false); + this._eventListeners.on(this._parent, 'voicesUpdated', this._onVoicesUpdated.bind(this)); + this._onVoicesUpdated(); } cleanup() { @@ -241,8 +248,38 @@ class AudioSourceEntry { // Private - _onAudioSourceSelectChange(event) { - this._setType(event.currentTarget.value); + _onVoicesUpdated() { + const voices = this._parent.getVoices(); + + const fragment = document.createDocumentFragment(); + + let option = document.createElement('option'); + option.value = ''; + option.textContent = 'None'; + fragment.appendChild(option); + + for (const {voice} of voices) { + option = document.createElement('option'); + option.value = voice.voiceURI; + option.textContent = `${voice.name} (${voice.lang})`; + fragment.appendChild(option); + } + + this._voiceSelect.textContent = ''; + this._voiceSelect.appendChild(fragment); + this._voiceSelect.value = this._voice; + } + + _onTypeSelectChange(e) { + this._setType(e.currentTarget.value); + } + + _onUrlInputChange(e) { + this._setUrl(e.currentTarget.value); + } + + _onVoiceSelectChange(e) { + this._setVoice(e.currentTarget.value); } _onMenuOpen(e) { @@ -252,6 +289,8 @@ class AudioSourceEntry { switch (this._type) { case 'custom': case 'custom-json': + case 'text-to-speech': + case 'text-to-speech-reading': hasHelp = true; break; } @@ -272,7 +311,35 @@ class AudioSourceEntry { async _setType(value) { this._type = value; - await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}]`, value); + this._updateTypeParameter(); + await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].type`, value); + } + + async _setUrl(value) { + this._url = value; + await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].url`, value); + } + + async _setVoice(value) { + this._voice = value; + await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].voice`, value); + } + + _updateTypeParameter() { + let field = null; + switch (this._type) { + case 'custom': + case 'custom-json': + field = 'url'; + break; + case 'text-to-speech': + case 'text-to-speech-reading': + field = 'voice'; + break; + } + for (const node of this._node.querySelectorAll('.audio-source-parameter-container')) { + node.hidden = (field === null || node.dataset.field !== field); + } } _showHelp(type) { @@ -283,6 +350,11 @@ class AudioSourceEntry { case 'custom-json': this._showModal('audio-source-help-custom-json'); break; + case 'text-to-speech': + case 'text-to-speech-reading': + this._parent.setTestVoice(this._voice); + this._showModal('audio-source-help-text-to-speech'); + break; } } diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js index c961d40e..02ad368c 100644 --- a/ext/js/pages/settings/backup-controller.js +++ b/ext/js/pages/settings/backup-controller.js @@ -278,11 +278,18 @@ class BackupController { const audio = options.audio; if (isObject(audio)) { - const customSourceUrl = audio.customSourceUrl; - if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !this._isLocalhostUrl(customSourceUrl)) { - warnings.push('audio.customSourceUrl uses a non-localhost URL'); - if (!dryRun) { - audio.customSourceUrl = ''; + const sources = audio.sources; + if (Array.isArray(sources)) { + for (let i = 0, ii = sources.length; i < ii; ++i) { + const source = sources[i]; + if (!isObject(source)) { continue; } + const {url} = source; + if (typeof url === 'string' && url.length > 0 && !this._isLocalhostUrl(url)) { + warnings.push(`audio.sources[${i}].url uses a non-localhost URL`); + if (!dryRun) { + sources[i].url = ''; + } + } } } } diff --git a/ext/settings.html b/ext/settings.html index 9f1e688c..38c390c4 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -2365,72 +2365,6 @@