Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 1003 lines 38 kB view raw
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}