From 25568637fe82988522ddd5c4d8642702b898a293 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Mon, 18 Jan 2021 00:16:40 -0500 Subject: [PATCH] Display audio (#1269) * Update display definition/definition node handling * Separate display audio controls into a separate class --- ext/bg/search.html | 1 + ext/fg/float.html | 1 + ext/mixed/display-templates.html | 4 +- ext/mixed/js/display-audio.js | 184 +++++++++++++++++++++++++++++++ ext/mixed/js/display.js | 154 +++++--------------------- 5 files changed, 216 insertions(+), 128 deletions(-) create mode 100644 ext/mixed/js/display-audio.js diff --git a/ext/bg/search.html b/ext/bg/search.html index dae657e8..f8e4d21c 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -88,6 +88,7 @@ + diff --git a/ext/fg/float.html b/ext/fg/float.html index e10659f2..52fe3e66 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -101,6 +101,7 @@ + diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html index 39f3b978..66cd9785 100644 --- a/ext/mixed/display-templates.html +++ b/ext/mixed/display-templates.html @@ -10,7 +10,7 @@ - +
@@ -44,7 +44,7 @@
- +
diff --git a/ext/mixed/js/display-audio.js b/ext/mixed/js/display-audio.js new file mode 100644 index 00000000..c423446e --- /dev/null +++ b/ext/mixed/js/display-audio.js @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * AudioSystem + */ + +class DisplayAudio { + constructor(display) { + this._display = display; + this._audioPlaying = null; + this._audioSystem = new AudioSystem(true); + this._autoPlayAudioTimer = null; + this._autoPlayAudioDelay = 400; + this._eventListeners = new EventListenerCollection(); + } + + get autoPlayAudioDelay() { + return this._autoPlayAudioDelay; + } + + set autoPlayAudioDelay(value) { + this._autoPlayAudioDelay = value; + } + + prepare() { + this._audioSystem.prepare(); + } + + updateOptions(options) { + const data = document.documentElement.dataset; + data.audioEnabled = `${options.audio.enabled && options.audio.sources.length > 0}`; + } + + cleanupEntries() { + this.clearAutoPlayTimer(); + this._eventListeners.removeAllEventListeners(); + } + + setupEntry(entry, definitionIndex) { + for (const button of entry.querySelectorAll('.action-play-audio')) { + const expressionIndex = this._getAudioPlayButtonExpressionIndex(button); + this._eventListeners.addEventListener(button, 'click', this._onAudioPlayButtonClick.bind(this, definitionIndex, expressionIndex), false); + } + } + + setupEntriesComplete() { + const {audio} = this._display.getOptions(); + if (!audio.enabled || !audio.autoPlay) { return; } + + this.clearAutoPlayTimer(); + + const definitions = this._display.definitions; + if (definitions.length === 0) { return; } + + const firstDefinition = definitions[0]; + if (firstDefinition.type === 'kanji') { return; } + + const callback = () => { + this._autoPlayAudioTimer = null; + this.playAudio(0, 0); + }; + + if (this._autoPlayAudioDelay > 0) { + this._autoPlayAudioTimer = setTimeout(callback, this._autoPlayAudioDelay); + } else { + callback(); + } + } + + clearAutoPlayTimer() { + if (this._autoPlayAudioTimer === null) { return; } + clearTimeout(this._autoPlayAudioTimer); + this._autoPlayAudioTimer = null; + } + + stopAudio() { + if (this._audioPlaying === null) { return; } + this._audioPlaying.pause(); + this._audioPlaying = null; + } + + async playAudio(definitionIndex, expressionIndex) { + this.stopAudio(); + this.clearAutoPlayTimer(); + + const {definitions} = this._display; + if (definitionIndex < 0 || definitionIndex >= definitions.length) { return; } + + const definition = definitions[definitionIndex]; + if (definition.type === 'kanji') { return; } + + const {expressions} = definition; + if (expressionIndex < 0 || expressionIndex >= expressions.length) { return; } + + const {expression, reading} = expressions[expressionIndex]; + const {sources, textToSpeechVoice, customSourceUrl, volume} = this._display.getOptions().audio; + + const progressIndicatorVisible = this._display.progressIndicatorVisible; + const overrideToken = progressIndicatorVisible.setOverride(true); + try { + // Create audio + let audio; + let info; + try { + let index; + ({audio, index} = await this._audioSystem.createDefinitionAudio(sources, expression, reading, {textToSpeechVoice, customSourceUrl})); + info = `From source ${1 + index}: ${sources[index]}`; + } catch (e) { + audio = this._audioSystem.getFallbackAudio(); + info = 'Could not find audio'; + } + + // Stop any currently playing audio + this.stopAudio(); + + // Update details + for (const button of this._getAudioPlayButtons(definitionIndex, expressionIndex)) { + const titleDefault = button.dataset.titleDefault || ''; + button.title = `${titleDefault}\n${info}`; + } + + // Play + audio.currentTime = 0; + audio.volume = Number.isFinite(volume) ? Math.max(0.0, Math.min(1.0, volume / 100.0)) : 1.0; + + const playPromise = audio.play(); + this._audioPlaying = audio; + + if (typeof playPromise !== 'undefined') { + try { + await playPromise; + } catch (e) { + // NOP + } + } + } finally { + progressIndicatorVisible.clearOverride(overrideToken); + } + } + + // Private + + _onAudioPlayButtonClick(definitionIndex, expressionIndex, e) { + e.preventDefault(); + this.playAudio(definitionIndex, expressionIndex); + } + + _getAudioPlayButtonExpressionIndex(button) { + const expressionNode = button.closest('.term-expression'); + if (expressionNode !== null) { + const expressionIndex = parseInt(expressionNode.dataset.index, 10); + if (Number.isFinite(expressionIndex)) { return expressionIndex; } + } + return 0; + } + + _getAudioPlayButtons(definitionIndex, expressionIndex) { + const results = []; + const {definitionNodes} = this._display; + if (definitionIndex >= 0 && definitionIndex < definitionNodes.length) { + const node = definitionNodes[definitionIndex]; + const button1 = (expressionIndex === 0 ? node.querySelector('.action-play-audio') : null); + const button2 = node.querySelector(`.term-expression:nth-of-type(${expressionIndex + 1}) .action-play-audio`); + if (button1 !== null) { results.push(button1); } + if (button2 !== null) { results.push(button2); } + } + return results; + } +} diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 0b0236da..a9d59aff 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -17,7 +17,7 @@ /* global * AnkiNoteBuilder - * AudioSystem + * DisplayAudio * DisplayGenerator * DisplayHistory * DisplayNotification @@ -42,16 +42,13 @@ class Display extends EventDispatcher { this._hotkeyHandler = hotkeyHandler; this._container = document.querySelector('#definitions'); this._definitions = []; + this._definitionNodes = []; this._optionsContext = {depth: 0, url: window.location.href}; this._options = null; this._index = 0; - this._audioPlaying = null; - this._audioSystem = new AudioSystem(true); this._styleNode = null; this._eventListeners = new EventListenerCollection(); this._setContentToken = null; - this._autoPlayAudioTimer = null; - this._autoPlayAudioDelay = 400; this._mediaLoader = new MediaLoader(); this._displayGenerator = new DisplayGenerator({ japaneseUtil, @@ -110,6 +107,7 @@ class Display extends EventDispatcher { this._frameResizeEventListeners = new EventListenerCollection(); this._tagNotification = null; this._tagNotificationContainer = document.querySelector('#content-footer'); + this._displayAudio = new DisplayAudio(this); this._hotkeyHandler.registerActions([ ['close', () => { this.close(); }], @@ -151,11 +149,11 @@ class Display extends EventDispatcher { } get autoPlayAudioDelay() { - return this._autoPlayAudioDelay; + return this._displayAudio.autoPlayAudioDelay; } set autoPlayAudioDelay(value) { - this._autoPlayAudioDelay = value; + this._displayAudio.autoPlayAudioDelay = value; } get queryParserVisible() { @@ -183,6 +181,18 @@ class Display extends EventDispatcher { return this._hotkeyHandler; } + get definitions() { + return this._definitions; + } + + get definitionNodes() { + return this._definitionNodes; + } + + get progressIndicatorVisible() { + return this._progressIndicatorVisible; + } + async prepare() { // State setup const {documentElement} = document; @@ -192,7 +202,7 @@ class Display extends EventDispatcher { // Prepare await this._displayGenerator.prepare(); - this._audioSystem.prepare(); + this._displayAudio.prepare(); this._queryParser.prepare(); this._history.prepare(); @@ -274,6 +284,7 @@ class Display extends EventDispatcher { this._updateDocumentOptions(options); this._updateTheme(options.general.popupTheme); this.setCustomCss(options.general.customPopupCss); + this._displayAudio.updateOptions(options); this._queryParser.setOptions({ selectedParser: options.parsing.selectedParser, @@ -296,25 +307,8 @@ class Display extends EventDispatcher { this._updateDefinitionTextScanner(options); } - autoPlayAudio() { - this.clearAutoPlayTimer(); - - if (this._definitions.length === 0) { return; } - - const callback = () => this._playAudio(0, 0); - - if (this._autoPlayAudioDelay > 0) { - this._autoPlayAudioTimer = setTimeout(callback, this._autoPlayAudioDelay); - } else { - callback(); - } - } - clearAutoPlayTimer() { - if (this._autoPlayAudioTimer !== null) { - clearTimeout(this._autoPlayAudioTimer); - this._autoPlayAudioTimer = null; - } + this._displayAudio.clearAutoPlayTimer(); } setContent(details) { @@ -518,7 +512,10 @@ class Display extends EventDispatcher { this._closePopups(); this._eventListeners.removeAllEventListeners(); this._mediaLoader.unloadAll(); + this._displayAudio.cleanupEntries(); this._hideTagNotification(false); + this._definitions = []; + this._definitionNodes = []; // Prepare const urlSearchParams = new URLSearchParams(location.search); @@ -688,15 +685,6 @@ class Display extends EventDispatcher { } } - _onAudioPlay(e) { - e.preventDefault(); - const link = e.currentTarget; - const definitionIndex = this._getClosestDefinitionIndex(link); - if (definitionIndex < 0) { return; } - const expressionIndex = Math.max(0, this._getClosestExpressionIndex(link)); - this._playAudio(definitionIndex, expressionIndex); - } - _onNoteAdd(e) { e.preventDefault(); const link = e.currentTarget; @@ -807,7 +795,6 @@ class Display extends EventDispatcher { _updateDocumentOptions(options) { const data = document.documentElement.dataset; data.ankiEnabled = `${options.anki.enable}`; - data.audioEnabled = `${options.audio.enabled && options.audio.sources.length > 0}`; data.glossaryLayoutMode = `${options.general.glossaryLayoutMode}`; data.compactTags = `${options.general.compactTags}`; data.enableSearchTags = `${options.scanning.enableSearchTags}`; @@ -921,7 +908,9 @@ class Display extends EventDispatcher { this._displayGenerator.createKanjiEntry(definition) ); entry.dataset.index = `${i}`; + this._definitionNodes.push(entry); this._addEntryEventListeners(entry); + this._displayAudio.setupEntry(entry, i); container.appendChild(entry); if (focusEntry === i) { this._focusEntry(i, false); @@ -936,13 +925,7 @@ class Display extends EventDispatcher { this._windowScroll.to(x, y); } - if ( - isTerms && - this._options.audio.enabled && - this._options.audio.autoPlay - ) { - this.autoPlayAudio(); - } + this._displayAudio.setupEntriesComplete(); this._updateAdderButtons(token, isTerms, definitions); } @@ -1209,76 +1192,12 @@ class Display extends EventDispatcher { return true; } - async _playAudio(definitionIndex, expressionIndex) { - if (definitionIndex < 0 || definitionIndex >= this._definitions.length) { return; } - - const definition = this._definitions[definitionIndex]; - if (definition.type === 'kanji') { return; } - - const {expressions} = definition; - if (expressionIndex < 0 || expressionIndex >= expressions.length) { return; } - - const {expression, reading} = expressions[expressionIndex]; - - const overrideToken = this._progressIndicatorVisible.setOverride(true); - try { - this._stopPlayingAudio(); - - let audio, info; - try { - const {sources, textToSpeechVoice, customSourceUrl} = this._options.audio; - let index; - ({audio, index} = await this._audioSystem.createDefinitionAudio(sources, expression, reading, {textToSpeechVoice, customSourceUrl})); - info = `From source ${1 + index}: ${sources[index]}`; - } catch (e) { - audio = this._audioSystem.getFallbackAudio(); - info = 'Could not find audio'; - } - - const button = this._audioButtonFindImage(definitionIndex, expressionIndex); - if (button !== null) { - let titleDefault = button.dataset.titleDefault; - if (!titleDefault) { - titleDefault = button.title || ''; - button.dataset.titleDefault = titleDefault; - } - button.title = `${titleDefault}\n${info}`; - } - - this._stopPlayingAudio(); - - const volume = Math.max(0.0, Math.min(1.0, this._options.audio.volume / 100.0)); - this._audioPlaying = audio; - audio.currentTime = 0; - audio.volume = Number.isFinite(volume) ? volume : 1.0; - const playPromise = audio.play(); - if (typeof playPromise !== 'undefined') { - try { - await playPromise; - } catch (e2) { - // NOP - } - } - } catch (e) { - this.onError(e); - } finally { - this._progressIndicatorVisible.clearOverride(overrideToken); - } - } - async _playAudioCurrent() { - return await this._playAudio(this._index, 0); - } - - _stopPlayingAudio() { - if (this._audioPlaying !== null) { - this._audioPlaying.pause(); - this._audioPlaying = null; - } + return await this._displayAudio.playAudio(this._index, 0); } _getEntry(index) { - const entries = this._container.querySelectorAll('.entry'); + const entries = this._definitionNodes; return index >= 0 && index < entries.length ? entries[index] : null; } @@ -1293,10 +1212,6 @@ class Display extends EventDispatcher { return this._getClosestIndex(element, '.entry'); } - _getClosestExpressionIndex(element) { - return this._getClosestIndex(element, '.term-expression'); - } - _getClosestIndex(element, selector) { const node = element.closest(selector); if (node === null) { return -1; } @@ -1324,18 +1239,6 @@ class Display extends EventDispatcher { viewerButton.dataset.noteId = noteId; } - _audioButtonFindImage(index, expressionIndex) { - const entry = this._getEntry(index); - if (entry === null) { return null; } - - const container = ( - expressionIndex >= 0 ? - entry.querySelector(`.term-expression:nth-of-type(${expressionIndex + 1})`) : - entry - ); - return container !== null ? container.querySelector('.action-play-audio>img') : null; - } - _getElementTop(element) { const elementRect = element.getBoundingClientRect(); const documentRect = this._contentScrollBodyElement.getBoundingClientRect(); @@ -1699,7 +1602,6 @@ class Display extends EventDispatcher { this._eventListeners.addEventListener(entry, 'click', this._onEntryClick.bind(this)); this._addMultipleEventListeners(entry, '.action-add-note', 'click', this._onNoteAdd.bind(this)); this._addMultipleEventListeners(entry, '.action-view-note', 'click', this._onNoteView.bind(this)); - this._addMultipleEventListeners(entry, '.action-play-audio', 'click', this._onAudioPlay.bind(this)); this._addMultipleEventListeners(entry, '.kanji-link', 'click', this._onKanjiLookup.bind(this)); this._addMultipleEventListeners(entry, '.debug-log-link', 'click', this._onDebugLogClick.bind(this)); this._addMultipleEventListeners(entry, '.tag', 'click', this._onTagClick.bind(this));