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);