Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2021-2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19import {EventListenerCollection} from '../core/event-listener-collection.js';
20import {PopupMenu} from '../dom/popup-menu.js';
21import {querySelectorNotNull} from '../dom/query-selector.js';
22import {getRequiredAudioSourceList} from '../media/audio-downloader.js';
23import {AudioSystem} from '../media/audio-system.js';
24
25export class DisplayAudio {
26 /**
27 * @param {import('./display.js').Display} display
28 */
29 constructor(display) {
30 /** @type {import('./display.js').Display} */
31 this._display = display;
32 /** @type {?import('display-audio').GenericAudio} */
33 this._audioPlaying = null;
34 /** @type {AudioSystem} */
35 this._audioSystem = new AudioSystem();
36 /** @type {number} */
37 this._playbackVolume = 1;
38 /** @type {boolean} */
39 this._autoPlay = false;
40 /** @type {import('settings').FallbackSoundType} */
41 this._fallbackSoundType = 'none';
42 /** @type {?import('core').Timeout} */
43 this._autoPlayAudioTimer = null;
44 /** @type {number} */
45 this._autoPlayAudioDelay = 400;
46 /** @type {EventListenerCollection} */
47 this._eventListeners = new EventListenerCollection();
48 /** @type {Map<string, import('display-audio').CacheItem>} */
49 this._cache = new Map();
50 /** @type {Element} */
51 this._menuContainer = querySelectorNotNull(document, '#popup-menus');
52 /** @type {import('core').TokenObject} */
53 this._entriesToken = {};
54 /** @type {Set<PopupMenu>} */
55 this._openMenus = new Set();
56 /** @type {import('display-audio').AudioSource[]} */
57 this._audioSources = [];
58 /** @type {Map<import('settings').AudioSourceType, string>} */
59 this._audioSourceTypeNames = new Map([
60 ['jpod101', 'JapanesePod101'],
61 ['language-pod-101', 'LanguagePod101'],
62 ['jisho', 'Jisho.org'],
63 ['lingua-libre', 'Lingua Libre'],
64 ['wiktionary', 'Wiktionary'],
65 ['text-to-speech', 'Text-to-speech'],
66 ['text-to-speech-reading', 'Text-to-speech (Kana reading)'],
67 ['custom', 'Custom URL'],
68 ['custom-json', 'Custom URL (JSON)'],
69 ]);
70 /** @type {?boolean} */
71 this._enableDefaultAudioSources = null;
72 /** @type {(event: MouseEvent) => void} */
73 this._onAudioPlayButtonClickBind = this._onAudioPlayButtonClick.bind(this);
74 /** @type {(event: MouseEvent) => void} */
75 this._onAudioPlayButtonContextMenuBind = this._onAudioPlayButtonContextMenu.bind(this);
76 /** @type {(event: import('popup-menu').MenuCloseEvent) => void} */
77 this._onAudioPlayMenuCloseClickBind = this._onAudioPlayMenuCloseClick.bind(this);
78 }
79
80 /** @type {number} */
81 get autoPlayAudioDelay() {
82 return this._autoPlayAudioDelay;
83 }
84
85 set autoPlayAudioDelay(value) {
86 this._autoPlayAudioDelay = value;
87 }
88
89 /** */
90 prepare() {
91 this._audioSystem.prepare();
92 /* eslint-disable @stylistic/no-multi-spaces */
93 this._display.hotkeyHandler.registerActions([
94 ['playAudio', this._onHotkeyActionPlayAudio.bind(this)],
95 ['playAudioFromSource', this._onHotkeyActionPlayAudioFromSource.bind(this)],
96 ]);
97 this._display.registerDirectMessageHandlers([
98 ['displayAudioClearAutoPlayTimer', this._onMessageClearAutoPlayTimer.bind(this)],
99 ]);
100 /* eslint-enable @stylistic/no-multi-spaces */
101 this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this));
102 this._display.on('contentClear', this._onContentClear.bind(this));
103 this._display.on('contentUpdateEntry', this._onContentUpdateEntry.bind(this));
104 this._display.on('contentUpdateComplete', this._onContentUpdateComplete.bind(this));
105 this._display.on('frameVisibilityChange', this._onFrameVisibilityChange.bind(this));
106 const options = this._display.getOptions();
107 if (options !== null) {
108 this._onOptionsUpdated({options});
109 }
110 }
111
112 /** */
113 clearAutoPlayTimer() {
114 if (this._autoPlayAudioTimer === null) { return; }
115 clearTimeout(this._autoPlayAudioTimer);
116 this._autoPlayAudioTimer = null;
117 }
118
119 /** */
120 stopAudio() {
121 if (this._audioPlaying === null) { return; }
122 this._audioPlaying.pause();
123 this._audioPlaying = null;
124 }
125
126 /**
127 * @param {number} dictionaryEntryIndex
128 * @param {number} headwordIndex
129 * @param {?string} [sourceType]
130 */
131 async playAudio(dictionaryEntryIndex, headwordIndex, sourceType = null) {
132 let sources = this._audioSources;
133 if (sourceType !== null) {
134 sources = [];
135 for (const source of this._audioSources) {
136 if (source.type === sourceType) {
137 sources.push(source);
138 }
139 }
140 }
141 await this._playAudio(dictionaryEntryIndex, headwordIndex, sources, null);
142 }
143
144 /**
145 * @param {string} term
146 * @param {string} reading
147 * @returns {import('display-audio').AudioMediaOptions}
148 */
149 getAnkiNoteMediaAudioDetails(term, reading) {
150 /** @type {import('display-audio').AudioSourceShort[]} */
151 const sources = [];
152 let preferredAudioIndex = null;
153 const primaryCardAudio = this._getPrimaryCardAudio(term, reading);
154 if (primaryCardAudio !== null) {
155 const {index, subIndex} = primaryCardAudio;
156 const source = this._audioSources[index];
157 sources.push(this._getSourceData(source));
158 preferredAudioIndex = subIndex;
159 } else {
160 for (const source of this._audioSources) {
161 if (!source.isInOptions) { continue; }
162 sources.push(this._getSourceData(source));
163 }
164 }
165 const enableDefaultAudioSources = this._enableDefaultAudioSources ?? false;
166 return {sources, preferredAudioIndex, enableDefaultAudioSources};
167 }
168
169 // Private
170
171 /**
172 * @param {import('display').EventArgument<'optionsUpdated'>} details
173 */
174 _onOptionsUpdated({options}) {
175 const {
176 general: {language},
177 audio: {enabled, autoPlay, fallbackSoundType, volume, sources, enableDefaultAudioSources},
178 } = options;
179 this._autoPlay = enabled && autoPlay;
180 this._fallbackSoundType = fallbackSoundType;
181 this._playbackVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume / 100)) : 1;
182 this._enableDefaultAudioSources = enableDefaultAudioSources;
183
184 /** @type {Set<import('settings').AudioSourceType>} */
185 const requiredAudioSources = enableDefaultAudioSources ? getRequiredAudioSourceList(language) : new Set();
186 /** @type {Map<string, import('display-audio').AudioSource[]>} */
187 const nameMap = new Map();
188 this._audioSources.length = 0;
189 for (const {type, url, voice} of sources) {
190 this._addAudioSourceInfo(type, url, voice, true, nameMap);
191 requiredAudioSources.delete(type);
192 }
193 for (const type of requiredAudioSources) {
194 this._addAudioSourceInfo(type, '', '', false, nameMap);
195 }
196
197 const data = document.documentElement.dataset;
198 data.audioEnabled = enabled.toString();
199
200 this._cache.clear();
201 }
202
203 /** */
204 _onContentClear() {
205 this._entriesToken = {};
206 this._cache.clear();
207 this.clearAutoPlayTimer();
208 this._eventListeners.removeAllEventListeners();
209 }
210
211 /**
212 * @param {import('display').EventArgument<'contentUpdateEntry'>} details
213 */
214 _onContentUpdateEntry({element}) {
215 const eventListeners = this._eventListeners;
216 for (const button of element.querySelectorAll('.action-button[data-action=play-audio]')) {
217 eventListeners.addEventListener(button, 'click', this._onAudioPlayButtonClickBind, false);
218 eventListeners.addEventListener(button, 'contextmenu', this._onAudioPlayButtonContextMenuBind, false);
219 eventListeners.addEventListener(button, 'menuClose', this._onAudioPlayMenuCloseClickBind, false);
220 }
221 }
222
223 /** */
224 _onContentUpdateComplete() {
225 if (!this._autoPlay || !this._display.frameVisible) { return; }
226
227 this.clearAutoPlayTimer();
228
229 const {dictionaryEntries} = this._display;
230 if (dictionaryEntries.length === 0) { return; }
231
232 const firstDictionaryEntries = dictionaryEntries[0];
233 if (firstDictionaryEntries.type === 'kanji') { return; }
234
235 const callback = () => {
236 this._autoPlayAudioTimer = null;
237 void this.playAudio(0, 0);
238 };
239
240 if (this._autoPlayAudioDelay > 0) {
241 this._autoPlayAudioTimer = setTimeout(callback, this._autoPlayAudioDelay);
242 } else {
243 callback();
244 }
245 }
246
247 /**
248 * @param {import('display').EventArgument<'frameVisibilityChange'>} details
249 */
250 _onFrameVisibilityChange({value}) {
251 if (!value) {
252 // The auto-play timer is stopped, but any audio that has already started playing
253 // is not stopped, as this is a valid use case for some users.
254 this.clearAutoPlayTimer();
255 }
256 }
257
258 /** */
259 _onHotkeyActionPlayAudio() {
260 void this.playAudio(this._display.selectedIndex, 0);
261 }
262
263 /**
264 * @param {unknown} source
265 */
266 _onHotkeyActionPlayAudioFromSource(source) {
267 if (!(typeof source === 'string' || typeof source === 'undefined' || source === null)) { return; }
268 void this.playAudio(this._display.selectedIndex, 0, source);
269 }
270
271 /** @type {import('display').DirectApiHandler<'displayAudioClearAutoPlayTimer'>} */
272 _onMessageClearAutoPlayTimer() {
273 this.clearAutoPlayTimer();
274 }
275
276 /**
277 * @param {import('settings').AudioSourceType} type
278 * @param {string} url
279 * @param {string} voice
280 * @param {boolean} isInOptions
281 * @param {Map<string, import('display-audio').AudioSource[]>} nameMap
282 */
283 _addAudioSourceInfo(type, url, voice, isInOptions, nameMap) {
284 const index = this._audioSources.length;
285 const downloadable = this._sourceIsDownloadable(type);
286 let name = this._audioSourceTypeNames.get(type);
287 if (typeof name === 'undefined') { name = 'Unknown'; }
288
289 let entries = nameMap.get(name);
290 if (typeof entries === 'undefined') {
291 entries = [];
292 nameMap.set(name, entries);
293 }
294 const nameIndex = entries.length;
295 if (nameIndex === 1) {
296 entries[0].nameUnique = false;
297 }
298
299 /** @type {import('display-audio').AudioSource} */
300 const source = {
301 index,
302 type,
303 url,
304 voice,
305 isInOptions,
306 downloadable,
307 name,
308 nameIndex,
309 nameUnique: (nameIndex === 0),
310 };
311
312 entries.push(source);
313 this._audioSources.push(source);
314 }
315
316 /**
317 * @param {MouseEvent} e
318 */
319 _onAudioPlayButtonClick(e) {
320 e.preventDefault();
321
322 const button = /** @type {HTMLButtonElement} */ (e.currentTarget);
323 const headwordIndex = this._getAudioPlayButtonHeadwordIndex(button);
324 const dictionaryEntryIndex = this._display.getElementDictionaryEntryIndex(button);
325
326 if (e.shiftKey) {
327 this._showAudioMenu(button, dictionaryEntryIndex, headwordIndex);
328 } else {
329 void this.playAudio(dictionaryEntryIndex, headwordIndex);
330 }
331 }
332
333 /**
334 * @param {MouseEvent} e
335 */
336 _onAudioPlayButtonContextMenu(e) {
337 e.preventDefault();
338
339 const button = /** @type {HTMLButtonElement} */ (e.currentTarget);
340 const headwordIndex = this._getAudioPlayButtonHeadwordIndex(button);
341 const dictionaryEntryIndex = this._display.getElementDictionaryEntryIndex(button);
342
343 this._showAudioMenu(button, dictionaryEntryIndex, headwordIndex);
344 }
345
346 /**
347 * @param {import('popup-menu').MenuCloseEvent} e
348 */
349 _onAudioPlayMenuCloseClick(e) {
350 const button = /** @type {Element} */ (e.currentTarget);
351 const headwordIndex = this._getAudioPlayButtonHeadwordIndex(button);
352 const dictionaryEntryIndex = this._display.getElementDictionaryEntryIndex(button);
353
354 const {detail: {action, item, menu, shiftKey}} = e;
355 switch (action) {
356 case 'playAudioFromSource':
357 if (shiftKey) {
358 e.preventDefault();
359 }
360 void this._playAudioFromSource(dictionaryEntryIndex, headwordIndex, item);
361 break;
362 case 'setPrimaryAudio':
363 e.preventDefault();
364 this._setPrimaryAudio(dictionaryEntryIndex, headwordIndex, item, menu, true);
365 break;
366 }
367 }
368
369 /**
370 * @param {string} term
371 * @param {string} reading
372 * @param {boolean} create
373 * @returns {import('display-audio').CacheItem|undefined}
374 */
375 _getCacheItem(term, reading, create) {
376 const key = this._getTermReadingKey(term, reading);
377 let cacheEntry = this._cache.get(key);
378 if (typeof cacheEntry === 'undefined' && create) {
379 cacheEntry = {
380 sourceMap: new Map(),
381 primaryCardAudio: null,
382 };
383 this._cache.set(key, cacheEntry);
384 }
385 return cacheEntry;
386 }
387
388 /**
389 * @param {Element} item
390 * @returns {import('display-audio').SourceInfo}
391 */
392 _getMenuItemSourceInfo(item) {
393 const group = /** @type {?HTMLElement} */ (item.closest('.popup-menu-item-group'));
394 if (group !== null) {
395 const {index, subIndex} = group.dataset;
396 if (typeof index === 'string') {
397 const indexNumber = Number.parseInt(index, 10);
398 if (indexNumber >= 0 && indexNumber < this._audioSources.length) {
399 return {
400 source: this._audioSources[indexNumber],
401 subIndex: typeof subIndex === 'string' ? Number.parseInt(subIndex, 10) : null,
402 };
403 }
404 }
405 }
406 return {source: null, subIndex: null};
407 }
408
409 /**
410 * @param {number} dictionaryEntryIndex
411 * @param {number} headwordIndex
412 * @param {import('display-audio').AudioSource[]} sources
413 * @param {?number} audioInfoListIndex
414 * @returns {Promise<import('display-audio').PlayAudioResult>}
415 */
416 async _playAudio(dictionaryEntryIndex, headwordIndex, sources, audioInfoListIndex) {
417 this.stopAudio();
418 this.clearAutoPlayTimer();
419
420 const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex);
421 if (headword === null) {
422 return {audio: null, source: null, subIndex: 0, valid: false};
423 }
424
425 const buttons = this._getAudioPlayButtons(dictionaryEntryIndex, headwordIndex);
426
427 const {term, reading} = headword;
428
429 const progressIndicatorVisible = this._display.progressIndicatorVisible;
430 const overrideToken = progressIndicatorVisible.setOverride(true);
431 try {
432 // Create audio
433 let audio;
434 let title;
435 let source = null;
436 let subIndex = 0;
437 const info = await this._createTermAudio(term, reading, sources, audioInfoListIndex);
438 const valid = (info !== null);
439 if (valid) {
440 ({audio, source, subIndex} = info);
441 const sourceIndex = sources.indexOf(source);
442 title = `From source ${1 + sourceIndex}: ${source.name}`;
443 } else {
444 audio = this._audioSystem.getFallbackAudio(this._fallbackSoundType);
445 title = 'Could not find audio';
446 }
447
448 // Stop any currently playing audio
449 this.stopAudio();
450
451 // Update details
452 const potentialAvailableAudioCount = this._getPotentialAvailableAudioCount(term, reading);
453 for (const button of buttons) {
454 const titleDefault = button.dataset.titleDefault || '';
455 button.title = `${titleDefault}\n${title}`;
456 this._updateAudioPlayButtonBadge(button, potentialAvailableAudioCount);
457 }
458
459 // Play
460 audio.currentTime = 0;
461 audio.volume = this._playbackVolume;
462
463 const playPromise = audio.play();
464 this._audioPlaying = audio;
465
466 if (typeof playPromise !== 'undefined') {
467 try {
468 await playPromise;
469 } catch (e) {
470 // NOP
471 }
472 }
473
474 return {audio, source, subIndex, valid};
475 } finally {
476 progressIndicatorVisible.clearOverride(overrideToken);
477 }
478 }
479
480 /**
481 * @param {number} dictionaryEntryIndex
482 * @param {number} headwordIndex
483 * @param {?HTMLElement} item
484 */
485 async _playAudioFromSource(dictionaryEntryIndex, headwordIndex, item) {
486 if (item === null) { return; }
487 const {source, subIndex} = this._getMenuItemSourceInfo(item);
488 if (source === null) { return; }
489
490 try {
491 const token = this._entriesToken;
492 const {valid} = await this._playAudio(dictionaryEntryIndex, headwordIndex, [source], subIndex);
493 if (valid && token === this._entriesToken) {
494 this._setPrimaryAudio(dictionaryEntryIndex, headwordIndex, item, null, false);
495 }
496 } catch (e) {
497 // NOP
498 }
499 }
500
501 /**
502 * @param {number} dictionaryEntryIndex
503 * @param {number} headwordIndex
504 * @param {?HTMLElement} item
505 * @param {?PopupMenu} menu
506 * @param {boolean} canToggleOff
507 */
508 _setPrimaryAudio(dictionaryEntryIndex, headwordIndex, item, menu, canToggleOff) {
509 if (item === null) { return; }
510 const {source, subIndex} = this._getMenuItemSourceInfo(item);
511 if (source === null || !source.downloadable) { return; }
512
513 const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex);
514 if (headword === null) { return; }
515
516 const {index} = source;
517 const {term, reading} = headword;
518 const cacheEntry = this._getCacheItem(term, reading, true);
519 if (typeof cacheEntry === 'undefined') { return; }
520
521 let {primaryCardAudio} = cacheEntry;
522 primaryCardAudio = (
523 !canToggleOff ||
524 primaryCardAudio === null ||
525 primaryCardAudio.index !== index ||
526 primaryCardAudio.subIndex !== subIndex ?
527 {index: index, subIndex} :
528 null
529 );
530 cacheEntry.primaryCardAudio = primaryCardAudio;
531
532 if (menu !== null) {
533 this._updateMenuPrimaryCardAudio(menu.bodyNode, term, reading);
534 }
535 }
536
537 /**
538 * @param {Element} button
539 * @returns {number}
540 */
541 _getAudioPlayButtonHeadwordIndex(button) {
542 const headwordNode = /** @type {?HTMLElement} */ (button.closest('.headword'));
543 if (headwordNode !== null) {
544 const {index} = headwordNode.dataset;
545 if (typeof index === 'string') {
546 const headwordIndex = Number.parseInt(index, 10);
547 if (Number.isFinite(headwordIndex)) { return headwordIndex; }
548 }
549 }
550 return 0;
551 }
552
553 /**
554 * @param {number} dictionaryEntryIndex
555 * @param {number} headwordIndex
556 * @returns {HTMLButtonElement[]}
557 */
558 _getAudioPlayButtons(dictionaryEntryIndex, headwordIndex) {
559 const results = [];
560 const {dictionaryEntryNodes} = this._display;
561 if (dictionaryEntryIndex >= 0 && dictionaryEntryIndex < dictionaryEntryNodes.length) {
562 const node = dictionaryEntryNodes[dictionaryEntryIndex];
563 const button1 = /** @type {?HTMLButtonElement} */ ((headwordIndex === 0 ? node.querySelector('.action-button[data-action=play-audio]') : null));
564 const button2 = /** @type {?HTMLButtonElement} */ (node.querySelector(`.headword:nth-of-type(${headwordIndex + 1}) .action-button[data-action=play-audio]`));
565 if (button1 !== null) { results.push(button1); }
566 if (button2 !== null) { results.push(button2); }
567 }
568 return results;
569 }
570
571 /**
572 * @param {string} term
573 * @param {string} reading
574 * @param {import('display-audio').AudioSource[]} sources
575 * @param {?number} audioInfoListIndex
576 * @returns {Promise<?import('display-audio').TermAudio>}
577 */
578 async _createTermAudio(term, reading, sources, audioInfoListIndex) {
579 const cacheItem = this._getCacheItem(term, reading, true);
580 if (typeof cacheItem === 'undefined') { return null; }
581 const {sourceMap} = cacheItem;
582
583 for (const source of sources) {
584 const {index} = source;
585
586 let cacheUpdated = false;
587 let sourceInfo = sourceMap.get(index);
588 if (typeof sourceInfo === 'undefined') {
589 const infoListPromise = this._getTermAudioInfoList(source, term, reading);
590 sourceInfo = {infoListPromise, infoList: null};
591 sourceMap.set(index, sourceInfo);
592 cacheUpdated = true;
593 }
594
595 let {infoList} = sourceInfo;
596 if (infoList === null) {
597 infoList = await sourceInfo.infoListPromise;
598 sourceInfo.infoList = infoList;
599 }
600
601 const {audio, index: subIndex, cacheUpdated: cacheUpdated2} = await this._createAudioFromInfoList(source, infoList, audioInfoListIndex);
602 if (cacheUpdated || cacheUpdated2) { this._updateOpenMenu(); }
603 if (audio !== null) {
604 return {audio, source, subIndex};
605 }
606 }
607
608 return null;
609 }
610
611 /**
612 * @param {import('display-audio').AudioSource} source
613 * @param {import('display-audio').AudioInfoList} infoList
614 * @param {?number} audioInfoListIndex
615 * @returns {Promise<import('display-audio').CreateAudioResult>}
616 */
617 async _createAudioFromInfoList(source, infoList, audioInfoListIndex) {
618 let start = 0;
619 let end = infoList.length;
620 if (audioInfoListIndex !== null) {
621 start = Math.max(0, Math.min(end, audioInfoListIndex));
622 end = Math.max(0, Math.min(end, audioInfoListIndex + 1));
623 }
624
625 /** @type {import('display-audio').CreateAudioResult} */
626 const result = {
627 audio: null,
628 index: -1,
629 cacheUpdated: false,
630 };
631 for (let i = start; i < end; ++i) {
632 const item = infoList[i];
633
634 let {audio, audioResolved} = item;
635
636 if (!audioResolved) {
637 let {audioPromise} = item;
638 if (audioPromise === null) {
639 audioPromise = this._createAudioFromInfo(item.info, source);
640 item.audioPromise = audioPromise;
641 }
642
643 result.cacheUpdated = true;
644
645 try {
646 audio = await audioPromise;
647 } catch (e) {
648 continue;
649 } finally {
650 item.audioResolved = true;
651 }
652
653 item.audio = audio;
654 }
655
656 if (audio !== null) {
657 result.audio = audio;
658 result.index = i;
659 break;
660 }
661 }
662 return result;
663 }
664
665 /**
666 * @param {import('audio-downloader').Info} info
667 * @param {import('display-audio').AudioSource} source
668 * @returns {Promise<import('display-audio').GenericAudio>}
669 */
670 async _createAudioFromInfo(info, source) {
671 switch (info.type) {
672 case 'url':
673 return await this._audioSystem.createAudio(info.url, source.type);
674 case 'tts':
675 return this._audioSystem.createTextToSpeechAudio(info.text, info.voice);
676 default:
677 throw new Error(`Unsupported type: ${/** @type {import('core').SafeAny} */ (info).type}`);
678 }
679 }
680
681 /**
682 * @param {import('display-audio').AudioSource} source
683 * @param {string} term
684 * @param {string} reading
685 * @returns {Promise<import('display-audio').AudioInfoList>}
686 */
687 async _getTermAudioInfoList(source, term, reading) {
688 const sourceData = this._getSourceData(source);
689 const languageSummary = this._display.getLanguageSummary();
690 const infoList = await this._display.application.api.getTermAudioInfoList(sourceData, term, reading, languageSummary);
691 return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null}));
692 }
693
694 /**
695 * @param {number} dictionaryEntryIndex
696 * @param {number} headwordIndex
697 * @returns {?import('dictionary').TermHeadword}
698 */
699 _getHeadword(dictionaryEntryIndex, headwordIndex) {
700 const {dictionaryEntries} = this._display;
701 if (dictionaryEntryIndex < 0 || dictionaryEntryIndex >= dictionaryEntries.length) { return null; }
702
703 const dictionaryEntry = dictionaryEntries[dictionaryEntryIndex];
704 if (dictionaryEntry.type === 'kanji') { return null; }
705
706 const {headwords} = dictionaryEntry;
707 if (headwordIndex < 0 || headwordIndex >= headwords.length) { return null; }
708
709 return headwords[headwordIndex];
710 }
711
712 /**
713 * @param {string} term
714 * @param {string} reading
715 * @returns {string}
716 */
717 _getTermReadingKey(term, reading) {
718 return JSON.stringify([term, reading]);
719 }
720
721 /**
722 * @param {HTMLButtonElement} button
723 * @param {?number} potentialAvailableAudioCount
724 */
725 _updateAudioPlayButtonBadge(button, potentialAvailableAudioCount) {
726 if (potentialAvailableAudioCount === null) {
727 delete button.dataset.potentialAvailableAudioCount;
728 } else {
729 button.dataset.potentialAvailableAudioCount = `${potentialAvailableAudioCount}`;
730 }
731
732 /** @type {?HTMLElement} */
733 const badge = button.querySelector('.action-button-badge');
734 if (badge === null) { return; }
735
736 const badgeData = badge.dataset;
737 switch (potentialAvailableAudioCount) {
738 case 0:
739 badgeData.icon = 'cross';
740 badge.hidden = false;
741 break;
742 case 1:
743 case null:
744 delete badgeData.icon;
745 badge.hidden = true;
746 break;
747 default:
748 badgeData.icon = 'plus-thick';
749 badge.hidden = false;
750 break;
751 }
752 }
753
754 /**
755 * @param {string} term
756 * @param {string} reading
757 * @returns {?number}
758 */
759 _getPotentialAvailableAudioCount(term, reading) {
760 const cacheEntry = this._getCacheItem(term, reading, false);
761 if (typeof cacheEntry === 'undefined') { return null; }
762
763 const {sourceMap} = cacheEntry;
764 let count = 0;
765 for (const {infoList} of sourceMap.values()) {
766 if (infoList === null) { continue; }
767 for (const {audio, audioResolved} of infoList) {
768 if (!audioResolved || audio !== null) {
769 ++count;
770 }
771 }
772 }
773 return count;
774 }
775
776 /**
777 * @param {HTMLButtonElement} button
778 * @param {number} dictionaryEntryIndex
779 * @param {number} headwordIndex
780 */
781 _showAudioMenu(button, dictionaryEntryIndex, headwordIndex) {
782 const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex);
783 if (headword === null) { return; }
784
785 const {term, reading} = headword;
786 const popupMenu = this._createMenu(button, term, reading);
787 this._openMenus.add(popupMenu);
788 popupMenu.prepare();
789 popupMenu.on('close', this._onPopupMenuClose.bind(this));
790 }
791
792 /**
793 * @param {import('popup-menu').EventArgument<'close'>} details
794 */
795 _onPopupMenuClose({menu}) {
796 this._openMenus.delete(menu);
797 }
798
799 /**
800 * @param {import('settings').AudioSourceType} source
801 * @returns {boolean}
802 */
803 _sourceIsDownloadable(source) {
804 switch (source) {
805 case 'text-to-speech':
806 case 'text-to-speech-reading':
807 return false;
808 default:
809 return true;
810 }
811 }
812
813 /**
814 * @param {HTMLButtonElement} sourceButton
815 * @param {string} term
816 * @param {string} reading
817 * @returns {PopupMenu}
818 */
819 _createMenu(sourceButton, term, reading) {
820 // Create menu
821 const menuContainerNode = /** @type {HTMLElement} */ (this._display.displayGenerator.instantiateTemplate('audio-button-popup-menu'));
822 /** @type {HTMLElement} */
823 const menuBodyNode = querySelectorNotNull(menuContainerNode, '.popup-menu-body');
824 menuContainerNode.dataset.term = term;
825 menuContainerNode.dataset.reading = reading;
826
827 // Set up items based on options and cache data
828 this._createMenuItems(menuContainerNode, menuBodyNode, term, reading);
829
830 // Update primary card audio display
831 this._updateMenuPrimaryCardAudio(menuBodyNode, term, reading);
832
833 // Create popup menu
834 this._menuContainer.appendChild(menuContainerNode);
835 return new PopupMenu(sourceButton, menuContainerNode);
836 }
837
838 /**
839 * @param {HTMLElement} menuContainerNode
840 * @param {HTMLElement} menuItemContainer
841 * @param {string} term
842 * @param {string} reading
843 */
844 _createMenuItems(menuContainerNode, menuItemContainer, term, reading) {
845 const {displayGenerator} = this._display;
846 let showIcons = false;
847 const currentItems = [...menuItemContainer.children];
848 for (const source of this._audioSources) {
849 const {index, name, nameIndex, nameUnique, isInOptions, downloadable} = source;
850 const entries = this._getMenuItemEntries(source, term, reading);
851 for (let i = 0, ii = entries.length; i < ii; ++i) {
852 const {valid, index: subIndex, name: subName} = entries[i];
853 const existingNode = this._getOrCreateMenuItem(currentItems, index, subIndex);
854 const node = existingNode !== null ? existingNode : /** @type {HTMLElement} */ (displayGenerator.instantiateTemplate('audio-button-popup-menu-item'));
855
856 /** @type {HTMLElement} */
857 const labelNode = querySelectorNotNull(node, '.popup-menu-item-audio-button .popup-menu-item-label');
858 let label = name;
859 if (!nameUnique) {
860 label = `${label} ${nameIndex + 1}`;
861 if (ii > 1) { label = `${label} -`; }
862 }
863 if (ii > 1) { label = `${label} ${i + 1}`; }
864 if (typeof subName === 'string' && subName.length > 0) { label += `: ${subName}`; }
865 labelNode.textContent = label;
866
867 /** @type {HTMLElement} */
868 const cardButton = querySelectorNotNull(node, '.popup-menu-item-set-primary-audio-button');
869 cardButton.hidden = !downloadable;
870
871 if (valid !== null) {
872 /** @type {HTMLElement} */
873 const icon = querySelectorNotNull(node, '.popup-menu-item-audio-button .popup-menu-item-icon');
874 icon.dataset.icon = valid ? 'checkmark' : 'cross';
875 showIcons = true;
876 }
877 node.dataset.index = `${index}`;
878 if (subIndex !== null) {
879 node.dataset.subIndex = `${subIndex}`;
880 }
881 node.dataset.valid = `${valid}`;
882 node.dataset.sourceInOptions = `${isInOptions}`;
883 node.dataset.downloadable = `${downloadable}`;
884
885 menuItemContainer.appendChild(node);
886 }
887 }
888 for (const node of currentItems) {
889 const {parentNode} = node;
890 if (parentNode === null) { continue; }
891 parentNode.removeChild(node);
892 }
893 menuContainerNode.dataset.showIcons = `${showIcons}`;
894 }
895
896 /**
897 * @param {Element[]} currentItems
898 * @param {number} index
899 * @param {?number} subIndex
900 * @returns {?HTMLElement}
901 */
902 _getOrCreateMenuItem(currentItems, index, subIndex) {
903 const indexNumber = `${index}`;
904 const subIndexNumber = `${subIndex !== null ? subIndex : 0}`;
905 for (let i = 0, ii = currentItems.length; i < ii; ++i) {
906 const node = currentItems[i];
907 if (!(node instanceof HTMLElement) || indexNumber !== node.dataset.index) { continue; }
908
909 let subIndex2 = node.dataset.subIndex;
910 if (typeof subIndex2 === 'undefined') { subIndex2 = '0'; }
911 if (subIndexNumber !== subIndex2) { continue; }
912
913 currentItems.splice(i, 1);
914 return node;
915 }
916 return null;
917 }
918
919 /**
920 * @param {import('display-audio').AudioSource} source
921 * @param {string} term
922 * @param {string} reading
923 * @returns {import('display-audio').MenuItemEntry[]}
924 */
925 _getMenuItemEntries(source, term, reading) {
926 const cacheEntry = this._getCacheItem(term, reading, false);
927 if (typeof cacheEntry !== 'undefined') {
928 const {sourceMap} = cacheEntry;
929 const sourceInfo = sourceMap.get(source.index);
930 if (typeof sourceInfo !== 'undefined') {
931 const {infoList} = sourceInfo;
932 if (infoList !== null) {
933 const ii = infoList.length;
934 if (ii === 0) {
935 return [{valid: false, index: null, name: null}];
936 }
937
938 /** @type {import('display-audio').MenuItemEntry[]} */
939 const results = [];
940 for (let i = 0; i < ii; ++i) {
941 const {audio, audioResolved, info: {name}} = infoList[i];
942 const valid = audioResolved ? (audio !== null) : null;
943 const entry = {valid, index: i, name: typeof name === 'string' ? name : null};
944 results.push(entry);
945 }
946 return results;
947 }
948 }
949 }
950 return [{valid: null, index: null, name: null}];
951 }
952
953 /**
954 * @param {string} term
955 * @param {string} reading
956 * @returns {?import('display-audio').PrimaryCardAudio}
957 */
958 _getPrimaryCardAudio(term, reading) {
959 const cacheEntry = this._getCacheItem(term, reading, false);
960 return typeof cacheEntry !== 'undefined' ? cacheEntry.primaryCardAudio : null;
961 }
962
963 /**
964 * @param {HTMLElement} menuBodyNode
965 * @param {string} term
966 * @param {string} reading
967 */
968 _updateMenuPrimaryCardAudio(menuBodyNode, term, reading) {
969 const primaryCardAudio = this._getPrimaryCardAudio(term, reading);
970 const primaryCardAudioIndex = (primaryCardAudio !== null ? primaryCardAudio.index : null);
971 const primaryCardAudioSubIndex = (primaryCardAudio !== null ? primaryCardAudio.subIndex : null);
972 const itemGroups = /** @type {NodeListOf<HTMLElement>} */ (menuBodyNode.querySelectorAll('.popup-menu-item-group'));
973 for (const node of itemGroups) {
974 const {index, subIndex} = node.dataset;
975 if (typeof index !== 'string') { continue; }
976 const indexNumber = Number.parseInt(index, 10);
977 const subIndexNumber = typeof subIndex === 'string' ? Number.parseInt(subIndex, 10) : null;
978 const isPrimaryCardAudio = (indexNumber === primaryCardAudioIndex && subIndexNumber === primaryCardAudioSubIndex);
979 node.dataset.isPrimaryCardAudio = `${isPrimaryCardAudio}`;
980 }
981 }
982
983 /** */
984 _updateOpenMenu() {
985 for (const menu of this._openMenus) {
986 const menuContainerNode = menu.containerNode;
987 const {term, reading} = menuContainerNode.dataset;
988 if (typeof term === 'string' && typeof reading === 'string') {
989 this._createMenuItems(menuContainerNode, menu.bodyNode, term, reading);
990 }
991 menu.updatePosition();
992 }
993 }
994
995 /**
996 * @param {import('display-audio').AudioSource} source
997 * @returns {import('display-audio').AudioSourceShort}
998 */
999 _getSourceData(source) {
1000 const {type, url, voice} = source;
1001 return {type, url, voice};
1002 }
1003}