[Unsupported] [Userscript] WaniKani Review Audio Tweak (reupload/fixed)

Here’s my version of the script with the features I mentioned.

// ==UserScript==
// @name          WaniKani Review Audio Tweak 2 Fork
// @namespace     est_fills_cando
// @description   Allow audio to be played after review meaning questions, when reading has been previously answered correctly. Also includes setting for enabling autoplay when answer is incorrect (default: off). Originally by Takuya Kobayashi.
// @author        est_fills_cando
// @version       1.0.12
// @include       https://www.wanikani.com/review/session*
// @include       https://preview.wanikani.com/review/session*
// @run-at        document-end
// @grant         none
// ==/UserScript==

// Original version by Takuya Kobayashi: https://greasyfork.org/en/scripts/10184-wanikani-review-audio-tweak
// Based on Version 2 by seanblue

(function ($, wkof, wanakana) {
    'use strict';

    let cachedAudio = new Map(); // map of lists, each list is cached jquery audio object
    let callback, audioCallback, enableAudioCallback;
    let audChain = [];

    function main() {
        audioCallback = window.additionalContent.Audio;
        window.additionalContent.audio = function() { return audioCallback(...arguments); }; // wrapped so we can still change audioCallback even after additionalContent.audio is copied
        let enableAudioLoaded = new Promise(function (resolve, reject) {
            waitProperty(window, 'wkEnableAudio', function() {
                enableAudioCallback = window.wkEnableAudio;
                window.wkEnableAudio = function() { return enableAudioCallback(window.audioAutoplay); };
                resolve();
            });
        });

        let promise = Promise.resolve();
        if (wkof) {
            wkof.include('Settings,Menu');
            promise = wkof.ready('Settings');
        }
        promise = promise.then(load_settings);
        if (wkof) {
            Promise.all([wkof.ready('Menu'),promise]).then(install_menu);
        }
        promise.then(install_tweaks);
        Promise.all([promise, enableAudioLoaded]).then(install_enableAudio_callback);
    }


    //
    // Core Logic
    //

    function install_tweaks() {
        // start buffering audio for new items whenever activeQueue changes
        onEachJStorageChange('activeQueue', cache_audios);

        // remove audio of completed items from cachedAudio to prevent memory leak
        // for compatibility with scripts that move items from activeQueue to reviewQueue,
        // we also check reviewQueue before deleting
        onEachJStorageChange('activeQueue', function() {
            let wholeQueue = $.jStorage.get('reviewQueue').concat($.jStorage.get('activeQueue'));
            //cleanup_cache(wholeQueue);
            cleanup_cache($.jStorage.get('activeQueue'));
        });

        onKeydownOnce('j', function(evt) {
            $('#option-audio').click();
            evt.stopImmediatePropagation();
        });

        // play / allow playing audio after submitting answer
        callback = function (version, audioAutoplay) {
            let currentItem = $.jStorage.get('currentItem');
            let questionType = $.jStorage.get('questionType');
            let liElem = $('#option-audio');
            let buttonElem = liElem.find('button');

            $('audio').remove();
            if (hasAudio(currentItem)) {
                buttonElem.removeClass("audio-unavailable");
                buttonElem.addClass("audio-idle");
                buttonElem.attr("title", "Pronunciation");
                buttonElem.removeAttr('disabled');
            } else {
                buttonElem.addClass("audio-unavailable");
                buttonElem.removeClass("audio-idle");
                buttonElem.attr("title", "Audio not available");
                buttonElem.attr('disabled', '');
                // for some reason, WK code doesn't add disabled attribute here
            }

            if (mightWantAudio(currentItem, questionType) && hasAudio(currentItem)) {
                let select = null;
                let play_when_wrong = null;
                let typed = null;
                let typedAbnormal = null;
                let heard = null;
                let audioElems = cachedAudio.get(currentItem.id);
                if (questionType == 'reading') {
                    select = settings.reading_select
                    play_when_wrong = settings.reading_play_when_wrong;
                    typedAbnormal = $('#answer-form fieldset input[type="text"]').val().trim() || '';
                    typed = audioElems.map(e => normalize_kana(e.pronunciation)).filter(e => e == normalize_kana(typedAbnormal))[0] || '';
                    $.jStorage.set(typedKey(currentItem.id), typed);
                    $.jStorage.setTTL(typedKey(currentItem.id), 2*3600*1000);
                } else if (questionType == 'meaning') {
                    select = settings.meaning_select;
                    play_when_wrong = settings.meaning_play_when_wrong;
                    typed = $.jStorage.get(typedKey(currentItem.id)) || '';
                    heard = $.jStorage.get(heardKey(currentItem.id)) || '';
                }

                if (!play_when_wrong && !$('#answer-form fieldset').hasClass('correct'))
                    audioAutoplay = false;
                else if (play_when_wrong && $('#answer-form fieldset').hasClass('incorrect'))
                    audioAutoplay = true;

                if (select == 'same_as_typed')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) == normalize_kana(typed))
                else if (select == 'different_from_typed')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) != normalize_kana(typed))
                else if (select == 'same_as_heard')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) == normalize_kana(heard))
                else if (select == 'different_from_heard')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) != normalize_kana(heard))
                else if (select == 'different_from_all')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) != normalize_kana(typed))
                        .looseFilter((e) => normalize_kana(e.pronunciation) != normalize_kana(heard));
                else if (select == 'random') {}
                // pass
                else
                    throw 'unrecognized select'

                let audioElem = audioElems[Math.floor(real_random() * audioElems.length)];
                if (questionType == 'reading') {
                    heard = audioElem.pronunciation;
                    $.jStorage.set(heardKey(currentItem.id), heard);
                    $.jStorage.setTTL(heardKey(currentItem.id), 2*3600*1000);
                }

                liElem.removeClass('disabled');
                if (version==='old')
                    audioElem.appendTo(liElem.children('span'));
                audioElem[0].autoplay = audioAutoplay;
                audioElem[0].playbackRate = settings.speed;
                audioElem[0].defaultPlaybackRate = settings.speed;

                // Changing autoplay won't trigger playback unless we call load(). However, we
                // don't use load() here because it laso triggers an unecessary network request to try downloading the audio again.
                // Instead, we just directly call play() which doesn't have this issue.
                if (audioAutoplay)
                    audioElem[0].play()

                buttonElem.off('click');
                liElem.off('click');
                liElem.on('click', function () {
                     audioElem[0].play();
                });
                audChain = [audioElem];
            }
        };
        audioCallback = autoplay => callback('old', autoplay);
    }

    function install_enableAudio_callback() {
        enableAudioCallback = autoplay => callback('new', autoplay);
    }

    function cache_audios(items, force_update) {
        for (const item of items)
            cache_audio(item, force_update)
    }

    function cache_audio(item, force_update) {
        if ( (!cachedAudio.has(item.id) || force_update) && hasAudio(item) ) {
            let liElem = $('#option-audio');
            let buttonElem = liElem.find('button');

            // group audio by pronunciation and voice actor
            let groups = {};
            for (const audio of getAudios(item)) {
                const key = JSON.stringify([audio.pronunciation, audio.voice_actor_id]);
                if (!groups[key])
                    groups[key] = [];
                groups[key].push(audio);
            }

            let audioElems = new Array();
            for (const key in groups) {
                const group = groups[key].sort((a,b) => a.content_type == 'audio/ogg' && b.content_type == 'audio/mpeg' ? 1 : -1);
                let audioElem = $('<audio></audio>', { preload: 'none' });
                for (const audio of group)
                    $('<source></source>', {
                        src: audio.url,
                        type: audio.content_type
                    }).appendTo(audioElem);

                audioElem[0].preload = 'auto';
                audioElem[0].autoplay = false;
                audioElem[0].load();

                [audioElem.pronunciation, audioElem.voice_actor_id] = JSON.parse(key);

                audioElem[0].addEventListener('play', function () {
                    buttonElem.removeClass('audio-idle').addClass('audio-play');
                });

                audioElem[0].addEventListener('ended', function () {
                    buttonElem.removeClass('audio-play').addClass('audio-idle');
                });

                audioElems.push(audioElem);
            }

            cachedAudio.set(item.id, audioElems);
        }
    }

    function cleanup_cache(queue) {
        let remainingIds = new Set(queue.map((it) => it.id));
        let plainChain = audChain;
        for (const id of new Set(cachedAudio.keys())) {
            if (!remainingIds.has(id)) {
                let del = true;
                for (let jq of cachedAudio.get(id)) {
                    if (!plainChain.includes(jq)) {
                        for (let a of jq) {
                            a.src = null;
                            a.srcObject = null;
                            a.preload = 'none';
                        }
                    } else {
                        del = false;
                    }
                }
                if (del)
                    cachedAudio.delete(id);
                $.jStorage.deleteKey(typedKey(id));
                $.jStorage.deleteKey(heardKey(id));
            }
        }
    }


    //
    // Helper / Utility Methods
    //

    // key for typed response for reading stored in jstorage
    function typedKey(id) {
        return 'rat/' + id + '/typed';
    }

    // key for heard audio after reading stored in jstorage
    function heardKey(id) {
        return 'rat/' + id + '/heard';
    }

    function itemStat(item) {
        let itemStatKey = (item.voc ? 'v' : item.kan ? 'k' : 'r') + item.id;
        return ($.jStorage.get(itemStatKey) || {});
    }

    function isDefaultVA(audio) {
        return audio.voice_actor_id === window.WaniKani.default_voice_actor_id;
    }

    function getAudios(currentItem) {
        let auds = currentItem.aud;
        if (!settings.random_va) {
            let default_va_readings = new Set(auds
                                              .filter(isDefaultVA)
                                              .map((aud) => normalize_kana(aud.pronunciation))
                                             );
            auds = auds.filter( (e) =>  isDefaultVA(e) || !default_va_readings.has(normalize_kana(e.pronunciation)));
        }
        return auds;
    }

    function mightWantAudio(currentItem, questionType) {
        if (questionType === 'reading') {
            return true;
        } else if (questionType == 'meaning') {
            if (settings.meaning_select == 'none')
                return false;
            else if (itemStat(currentItem).rc >= 1 || !settings.meaning_no_reading_spoilers)
                return true;
        }
    }

    function hasAudio(currentItem) {
        return currentItem.aud && getAudios(currentItem).length > 0;
    }

    function normalize_kana(kana) {
        return wanakana.toKatakana(wanakana.toRomaji(kana))
    }

    //
    // Generic Library Functions
    //

    // get fresh Math.random in case it was altered by another script
    let real_random = null;
    (function() {
        var iframe = document.createElement('iframe');
        document.body.appendChild(iframe);
        real_random = iframe.contentWindow.Math.random;
        iframe.remove();
    })();

    // like filter but returns the input if the filtered result would be empty
    Object.defineProperty(Array.prototype, 'looseFilter', {value:function (callback) {
        let filtered = this.filter(callback);
        if (filtered.length != 0)
            return filtered;
        else
            return this.slice();
    }});

    // Calls callback each time jStorage value stored at key,
    // is updated with the updated value,
    // including cases where the value is overwritten with itself,
    // including once for the initial value when this function is executed
    // but never when the new value is null
    //
    // The call is executed asynchronously as a microtask, which is useful
    // when several jStorage keys are being updated at once to avoid
    // running the callback part way through the updates.
    //
    // Since the callback runs as a microtask, the value stored could
    // be different from the value passed to the callback if another change
    // is made to it before the microtask runs.
    function onEachJStorageChange(key, callback) {
        callback = (function(callback) {return function() {
            queueMicrotask(function() {
                let val = $.jStorage.get(key);
                if (val !== null)
                    callback(val);
            });
        }})(callback);
        $.jStorage.listenKeyChange(key, function (key, action) {
            if (action === 'updated') {
                callback();
            }
        });
        callback();
    }

    // helper method for waiting for a property to be defined on an element
    // callback is called synchronously immediately after the property is defined
    if (!window.waitProperty) {
        let objPropCallbacks = new Map();
        window.waitProperty = function (obj, prop, callback) {
            if (obj[prop] !== undefined) {
                callback(obj[prop]);
                return;
            }
            if (!objPropCallbacks.has(obj))
                objPropCallbacks.set(obj, new Map());
            let propCallbacks = objPropCallbacks.get(obj);

            let callbacks;
            if (!propCallbacks.has(prop)) {
                propCallbacks.set(prop, []);

                function runCallbacks(val) {
                    for (let callback of callbacks) {
                        callback(val);
                    }
                }

                let _val;
                Object.defineProperty(obj, prop, {
                    get: () => _val,
                    set: function(val) {_val = val; delete obj[prop]; obj[prop] = val; runCallbacks(val); callbacks.length = 0;},
                    configurable: true,
                    enumerable: true
                });
            }
            callbacks = propCallbacks.get(prop);
            callbacks.push(callback);
        }
    }

        function onKeydownOnce(key, handler, options) {
        if (!options)
            options = {};
        if (!options.ignoredTargets)
            options.ignoredTargets = [];
        let handled = false;
        document.addEventListener('keydown', function(evt) {
            if (evt.key == key && !handled && !options.ignoredTargets.includes(evt.target.tagName.toLowerCase())) {
                handled = true;
                return handler(evt);
            }
        }, true);

        document.body.addEventListener('keyup', function(evt) {
            if (evt.key == key && handled) {
                handled = false;
            }
        }, true);
    }


    //
    // Settings and Menu
    //
    let default_settings =
        {reading_select: 'same_as_typed',
         meaning_select: 'same_as_heard',
         reading_play_when_wrong: false,
         meaning_play_when_wrong: true,
         meaning_no_reading_spoilers: true,
         random_va: false,
         speed: 1,
        };
    let settings = default_settings;
    let settings_key = 'review_audio_tweak';
    async function load_settings() {
        if (wkof)
            settings = await wkof.Settings.load(settings_key,default_settings)
    }

    function install_menu() {
        let menu_meta =
            {name: 'review_audio_tweak_settings',
             submenu: 'Settings',
             title: 'Review Audio Tweak 2',
             on_click: () => (new wkof.Settings(settings_schema)).open(),
            };
        wkof.Menu.insert_script_link(menu_meta);
    }

    let settings_schema =
        {script_id: 'review_audio_tweak',
         title: 'Review Audio Tweak 2',
         on_close: function () {
             settings = wkof.settings[settings_key];
             cache_audios($.jStorage.get('activeQueue'), true);
         },
         content: {
             reading: {
                 type: 'group',
                 label: 'Reading Questions',
                 content: {
                     reading_select: {
                         type: 'dropdown',
                         label: 'Reading to Play',
                         hover_tip: 'Select which reading should be played after answering a reading question.',
                         //full_width: true,
                         content: {
                             same_as_typed: 'reading you typed',
                             different_from_typed: 'reading you didn\'t type',
                             random: 'random reading',
                         }

                     },
                     reading_play_when_wrong: {
                         type: 'checkbox',
                         label: 'Autoplay on Incorrect Answer',
                         hover_tip: 'Autoplay audio even when you get the answer wrong.',
                     },
                 },
             },
             meaning: {
                 type: 'group',
                 label: 'Meaning Questions',
                 content: {
                     meaning_select: {
                         type: 'dropdown',
                         label: 'Reading to Play',
                         hover_tip: 'Select which reading should be played after answering a meaning question.',
                         //full_width: true,
                         content: {
                             same_as_heard: 'reading heard in reading question',
                             different_from_heard: 'reading you didn\'t hear',
                             same_as_typed: 'reading typed in reading question',
                             different_from_typed: 'reading you didn\'t type',
                             different_from_all: 'reading you didn\'t type / hear',
                             random: 'random reading',
                             none: 'none',
                         }
                     },
                     meaning_no_reading_spoilers: {
                         type: 'checkbox',
                         label: 'Don\'t Play Reading if Reading Question still Unfinished',
                         hover_tip: 'Do not play the reading if the reading question has not yet been correctly answered for this review session.',
                     },
                     meaning_play_when_wrong: {
                         type: 'checkbox',
                         label: 'Autoplay on Incorrect Answer too',
                         hover_tip: 'Autoplay audio even when you get the answer wrong.',
                     },
                 },
             },
             random_va: {
                 type: 'checkbox',
                 label: 'Randomize Voice Actor',
                 hover_tip: 'Randomize the voice actor each time audio is played.',
             },
             speed: {
                 type: 'number',
                 label: 'Speed',
                 hover_tip: 'Speed Factor at which to Play Audio.',
             },
         },
        }


    //
    // Invoke main()
    //
    main();

})(window.jQuery, window.wkof, window.wanakana);
3 Likes