Auto-translates ASMR subtitles on Kikoeru-based sites. Supports any OpenAI API-compatible LLM, DeepL, LibreTranslate, and LunaTranslator.
// ==UserScript==
// @name ASMR Translator
// @namespace https://github.com/Makar8000
// @description Auto-translates ASMR subtitles on Kikoeru-based sites. Supports any OpenAI API-compatible LLM, DeepL, LibreTranslate, and LunaTranslator.
// @version 2.5.1
// @author Makar
// @license GPL-3.0-or-later
// @match https://*.asmr.one/*
// @match https://*.asmr-100.com/*
// @match https://*.asmr-200.com/*
// @match https://*.asmr-300.com/*
// @match http://localhost:8888/*
// @match http://127.0.0.1:8888/*
// @require https://cdn.jsdelivr.net/gh/sizzlemctwizzle/GM_config@b3ac68b00ba88f7817270ca0ccd28424282a7973/gm_config.js
// @grant GM_registerMenuCommand
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @grant GM.listValues
// @grant GM.xmlHttpRequest
// @grant GM_addStyle
// @grant unsafeWindow
// @connect api.deepl.com
// @connect api-free.deepl.com
// @connect openrouter.ai
// @connect localhost
// @connect 127.0.0.1
// @connect *
// @noframes
// ==/UserScript==
// Constants and other variables
const isLocalKikoeru = !/(^|\.)(asmr\.one|asmr-[123]00\.com)$/.test(location.hostname);
const CJK_REGEX = /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u;
const PREFETCH_LINES = 3;
const CONTEXT_LINES = 3;
const OPENAI_PROMPT_DEFAULT = 'You are a translator. Please help me translate the following {0} text into {1}. You should only tell me the translation result without any additional explanations.';
const OPENAI_EXTRA_PARAMS_DEFAULT = JSON.stringify({ temperature: 0.7, top_p: 0.3 });
let gmconfig = null;
// Read from storage by main()
let isFirstRun = false;
let profiles = null;
let translationCache = null;
const saveProfiles = () => GM.setValue('__profiles', JSON.stringify(profiles));
const saveCache = () => GM.setValue('__translation_cache', JSON.stringify(translationCache));
const profileCache = () => translationCache[profiles.active] ??= {};
// Migration helper for any breaking changes. Currently contains fixes for settings saved by versions before 2.5.0
async function migrateOldSettings() {
let modified = false;
for (const settings of Object.values(profiles.saved)) {
// 2.2.x named the OpenAI settings after LMStudio and stored the server root instead of the chat completions URL
if ('lmstudioEndpoint' in settings) {
settings.lmstudioEndpoint += 'v1/chat/completions';
}
for (const key of Object.keys(settings).filter(k => k.startsWith('lmstudio'))) {
settings[key.replace('lmstudio', 'openai')] = settings[key];
delete settings[key];
modified = true;
}
if (settings.translator === 'LMStudio') {
settings.translator = 'OpenAI API-compatible LLM';
modified = true;
}
// The custom-prompt checkbox is gone: the prompt field is always used, with {0}/{1} for the languages
if ('openaiOverrideSystemPrompt' in settings) {
if (!settings.openaiOverrideSystemPrompt) {
settings.openaiSystemPrompt = OPENAI_PROMPT_DEFAULT;
}
delete settings.openaiOverrideSystemPrompt;
modified = true;
}
// Temperature and Top-P had their own fields before the extra parameters replaced them
if ('openaiTemperature' in settings || 'openaiTopP' in settings) {
settings.openaiExtraParams ??= JSON.stringify({
temperature: Number(settings.openaiTemperature ?? 0.7),
top_p: Number(settings.openaiTopP ?? 0.3),
});
delete settings.openaiTemperature;
delete settings.openaiTopP;
modified = true;
}
// "Disabled" was renamed
if (settings.translator === 'Disabled') {
settings.translator = 'Select a translator...';
modified = true;
}
// DeepL only takes base codes as the source language; the regional ones 2.3.0 offered never worked
if (settings.deeplSourceLang?.includes('-')) {
settings.deeplSourceLang = settings.deeplSourceLang.split('-')[0];
modified = true;
}
}
if (modified) {
await saveProfiles();
}
// The cache setup in main() copied the 2.3.0 cache into the Default profile; the old copy goes once the new one is on disk
if (await GM.getValue('__translation_map') !== undefined) {
await saveCache();
await GM.deleteValue('__translation_map');
}
}
// Sends a JSON request
async function requestJson(url, { method = 'GET', body, headers } = {}) {
const r = await GM.xmlHttpRequest({
method,
url,
headers: { 'Content-Type': 'application/json', ...headers },
data: body && JSON.stringify(body),
});
if (r.status < 200 || r.status >= 300) {
throw new Error(`HTTP ${r.status} from ${url}: ${r.responseText}`);
}
return JSON.parse(r.responseText);
}
const postJson = (url, body, headers) => requestJson(url, { method: 'POST', body, headers });
async function translateDeepl(text, context) {
const apiKey = gmconfig.get('deeplApiKey');
const targetLang = gmconfig.get('deeplTargetLang');
const sourceLang = gmconfig.get('deeplSourceLang');
const body = { text: [text], target_lang: targetLang };
if (sourceLang.toLowerCase() !== 'auto') {
body.source_lang = sourceLang;
}
if (context.length) {
body.context = context.join('\n');
}
const url = apiKey.endsWith(':fx') ? 'https://api-free.deepl.com/v2/translate' : 'https://api.deepl.com/v2/translate';
const resp = await postJson(url, body, { 'Authorization': `DeepL-Auth-Key ${apiKey}` });
const [result] = resp.translations;
// Don't return the translation if it is already in the target language
if (result.detected_source_language.slice(0, 2) === targetLang.slice(0, 2)) {
return '';
}
return result.text;
}
async function translateLibre(text) {
const resp = await postJson(gmconfig.get('libreEndpoint'), {
q: text,
source: gmconfig.get('libreSourceLang'),
target: gmconfig.get('libreTargetLang'),
alternatives: 3,
});
// Use the longest candidate
const candidates = [resp.translatedText, ...(resp.alternatives ?? [])];
return candidates.reduce((best, alt) => alt.length > best.length ? alt : best);
}
async function translateLuna(text) {
const url = new URL('api/translate', gmconfig.get('lunaEndpoint'));
url.searchParams.set('text', text);
const translatorId = gmconfig.get('lunaTranslatorId');
if (translatorId.length) {
url.searchParams.set('id', translatorId);
}
const resp = await requestJson(url.href);
return resp.result;
}
// The extra parameters field as an object
function openaiExtraParams() {
let params;
try {
params = JSON.parse(gmconfig.get('openaiExtraParams').trim() || '{}');
} catch {
params = null;
}
if (typeof params !== 'object' || params === null || Array.isArray(params)) {
console.warn('[Translate] The extra parameters are not a JSON object, sending none');
return {};
}
return params;
}
async function translateOpenai(text, context) {
const auth = gmconfig.get('openaiAuth');
// 'Auto' means: don't name the source language in the prompt
const sourceLang = gmconfig.get('openaiSourceLang').replace(/^auto$/i, '');
const systemPrompt = gmconfig.get('openaiSystemPrompt')
.replaceAll('{0}', sourceLang)
.replaceAll('{1}', gmconfig.get('openaiTargetLang'));
let messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text },
];
if (context.length) {
// Hy-MT2's model card layout for background text, with the prompt as its instruction. It has to be a single user
// message: with the prompt in a system message, the model translated the labels and the background along with the line
messages = [{ role: 'user', content: `[Background Information]\n${context.join('\n')}\n\n${systemPrompt} Take the provided background information into consideration.\n\n[Source Text]\n${text}` }];
}
const resp = await postJson(gmconfig.get('openaiEndpoint'), {
...openaiExtraParams(),
model: gmconfig.get('openaiModel'),
messages,
}, auth.length ? { 'Authorization': `Bearer ${auth}` } : {});
const content = resp.choices?.[0]?.message?.content;
if (!content) {
// Some servers answer 200 with an error object instead of a completion
throw new Error('Bad response: ' + JSON.stringify(resp));
}
return content;
}
// Translator definitions
const TRANSLATORS = {
'Select a translator...': {},
'OpenAI API-compatible LLM': { translate: translateOpenai, section: 'openaiEndpoint' },
'LibreTranslate': { translate: translateLibre, section: 'libreEndpoint' },
'LunaTranslator': {
translate: translateLuna,
section: 'lunaEndpoint',
requires: 'lunaTranslatorId',
missing: 'You need to select a LunaTranslator model. Please ensure your server is running, then select one and press save.',
},
'DeepL': {
translate: translateDeepl,
section: 'deeplApiKey',
requires: 'deeplApiKey',
missing: 'You need to enter a DeepL API key. Please input one and press save.',
},
};
// 32-bit FNV-1a hash
function hash(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 0x01000193);
}
return (h >>> 0).toString(36);
}
// Grabs the cache key
function cacheKey(text, context) {
if (!context.length) {
return text;
}
return `${hash(context.join('\n'))}|${text}`;
}
// Translates one line with the selected translator and stores the result in the cache under `key`.
async function translateAndCache(text, context, key) {
const translate = TRANSLATORS[gmconfig.get('translator')]?.translate;
if (!translate) {
return;
}
console.log('[Translate] Translating:', text);
try {
const translatedText = await translate(text, context);
if (translatedText?.length) {
console.log('[Translate] Result:', translatedText);
profileCache()[key] = translatedText;
scheduleCacheSave();
}
} catch (e) {
console.error('[Translate] Failed', text, e);
}
}
// Requests in flight by cache key, so a line the prefetcher is already translating isn't requested again when it comes up
const inFlight = new Map();
function translateOnce(text, context) {
const key = cacheKey(text, context);
if (!inFlight.has(key)) {
inFlight.set(key, translateAndCache(text, context, key).finally(() => inFlight.delete(key)));
}
return inFlight.get(key);
}
let cacheWriteTimer;
function scheduleCacheSave() {
if (!gmconfig.get('shouldCache')) {
return;
}
clearTimeout(cacheWriteTimer);
cacheWriteTimer = setTimeout(saveCache, 1000);
}
let audioPlayerHooked = false;
function hookToAudioPlayer() {
if (audioPlayerHooked) {
return;
}
audioPlayerHooked = true;
let attempts = 0;
const findAudioPlayerInt = setInterval(function () {
// Grab the audio player (can vary by site / Vue version)
const root = document.querySelector('#q-app, .q-layout');
const store = root?.__vue__?.$store ?? root?.__vue_app__?.config.globalProperties.$store;
const audioPlayer = store?.state?.AudioPlayer;
if (!audioPlayer) {
if (++attempts === 50) {
console.warn('[Translate] No audio player found after 10 seconds');
}
return;
}
clearInterval(findAudioPlayerInt);
console.log('[Translate] Player hooked');
installLyricHook(audioPlayer);
}, 200);
}
// Shows the translation of each line the player sets, translating and prefetching as lines come up
function installLyricHook(audioPlayer) {
// the line most recently set by the player; older in-flight translations are dropped
let latestLyric = '';
// The few lines before line `i`, for translators that take context
const contextFor = (lines, i) => {
if (i < 0 || !gmconfig.get('useContext')) {
return [];
}
return lines.slice(Math.max(0, i - CONTEXT_LINES), i).map(line => line.text?.trim()).filter(Boolean);
};
// Prefetch translations
const prefetched = new Set();
const prefetch = (lines, i) => {
if (i < 0 || !gmconfig.get('prefetch') || !TRANSLATORS[gmconfig.get('translator')]?.translate) {
return;
}
for (let j = i + 1; j < lines.length && j <= i + PREFETCH_LINES; j++) {
const next = lines[j].text?.trim();
if (!next || !CJK_REGEX.test(next)) {
continue;
}
const context = contextFor(lines, j);
const key = cacheKey(next, context);
if (!prefetched.has(key) && !profileCache()[key]) {
prefetched.add(key);
translateOnce(next, context);
}
}
};
async function onLyricChange(v) {
const text = v?.trim();
latestLyric = text;
if (!text?.length) {
return setLyric(text);
}
// Where this line is in the subtitle list, whose name varies by site. The site stores the index, which tells a
// repeated line apart from its earlier copies; it's set right after the lyric, so it's read a microtask later
await Promise.resolve();
const lines = audioPlayer.lrcLines ?? audioPlayer.lyricLines ?? [];
let i = audioPlayer.currentLrcLineIndex ?? audioPlayer.currentLyricLineNumber;
if (lines[i]?.text?.trim() !== text) {
i = lines.findIndex(line => line.text?.trim() === text);
}
const context = contextFor(lines, i);
const key = cacheKey(text, context);
// The current line is requested before the prefetch, so a server that takes one request at a time serves it first
let pending;
if (!profileCache()[key] && CJK_REGEX.test(text)) {
pending = translateOnce(text, context);
}
prefetch(lines, i);
if (pending) {
await pending;
}
if (text !== latestLyric) {
// the player moved on while this line was translating
return;
}
return setLyric(profileCache()[key] ?? text);
}
const setLyric = hookLyricSetter(audioPlayer, onLyricChange);
if (audioPlayer.currentLyric?.length) {
onLyricChange(audioPlayer.currentLyric);
}
}
// Routes every write to the player's lyric through `onSet` and returns a function that writes a value for real
function hookLyricSetter(audioPlayer, onSet) {
// Vue 3 keeps the state behind a proxy; the accessor has to go on the raw object behind it
const target = audioPlayer.__v_raw ?? audioPlayer;
const descriptor = Object.getOwnPropertyDescriptor(target, 'currentLyric');
// Vue 2 keeps each state field in an accessor, so its setter is simply replaced
if (descriptor.set) {
Object.defineProperty(target, 'currentLyric', { set: onSet });
return descriptor.set;
}
// Vue 3 keeps plain fields, so the accessor is ours entirely; our own writes go through the proxy, which is how Vue notices them
let value = descriptor.value;
let writing = false;
Object.defineProperty(target, 'currentLyric', {
enumerable: true,
configurable: true,
get: () => value,
set: (v) => {
if (writing) {
value = v;
} else {
onSet(v);
}
},
});
return (v) => {
writing = true;
audioPlayer.currentLyric = v;
writing = false;
};
}
async function getLunaTranslators(endpoint) {
try {
const list = await requestJson(new URL('api/list/translator', endpoint).href);
return Array.isArray(list) ? list : [];
} catch (e) {
console.error('[Translate] Unable to fetch LunaTranslator model list', e);
return [];
}
}
async function setupConfig() {
let lunaTranslators = [];
// Fills the Luna model dropdown from the server and keeps the saved model ID pointing at one of its entries
const loadLunaTranslators = async () => {
lunaTranslators = await getLunaTranslators(gmconfig.get('lunaEndpoint'));
if (!lunaTranslators.length) {
return;
}
gmconfig.fields.lunaTranslator.settings.options = lunaTranslators.map(tl => tl.name);
const current = lunaTranslators.find(m => m.id === gmconfig.get('lunaTranslatorId')) ?? lunaTranslators[0];
gmconfig.set('lunaTranslatorId', current.id);
gmconfig.set('lunaTranslator', current.name);
};
// Sizes the dialog to its content
const fitFrame = () => {
const { frame } = gmconfig;
const root = frame.contentDocument.documentElement;
frame.style.height = '0px';
root.style.overflow = 'hidden';
frame.style.height = Math.ceil(root.getBoundingClientRect().height) + 'px';
root.style.overflow = '';
gmconfig.center();
};
// Shows only the settings section of the selected translator and refits the dialog.
const showSelectedSection = () => {
const selected = gmconfig.get('translator', true);
for (const [name, { section }] of Object.entries(TRANSLATORS)) {
if (section) {
gmconfig.fields[section].wrapper.parentNode.hidden = name !== selected;
}
}
// The model list comes from the Luna server, so it's only fetched while that section is in view
if (selected === 'LunaTranslator') {
loadLunaTranslators();
}
fitFrame();
};
// Makes `name` the active profile and selects it in the dropdown
const activateProfile = (name) => {
profiles.active = name;
gmconfig.fields.profile.settings.options = Object.keys(profiles.saved);
// also the default, so "Reset to defaults" keeps the dropdown on the active profile
gmconfig.fields.profile.default = name;
gmconfig.set('profile', name);
saveProfiles();
};
// Loads the active profile's settings into the dialog, dropping unsaved edits
const loadProfile = () => {
const stored = profiles.saved[profiles.active];
for (const [id, field] of Object.entries(gmconfig.fields)) {
if (field.save) {
gmconfig.set(id, stored[id] ?? field.default);
}
}
showSelectedSection();
};
// Asks for a profile name; undefined when cancelled, blank or already taken
const askProfileName = (message, suggested) => {
const name = prompt(message, suggested)?.trim();
if (!name) {
return;
}
if (profiles.saved[name]) {
alert(`There is already a profile named "${name}".`);
return;
}
return name;
};
const addProfile = (source) => {
const name = askProfileName('Name of the new profile');
if (name) {
profiles.saved[name] = structuredClone(profiles.saved[source] ?? {});
translationCache[name] = structuredClone(translationCache[source] ?? {});
saveCache();
activateProfile(name);
loadProfile();
}
};
const renameProfile = () => {
const name = askProfileName('New name of the profile', profiles.active);
if (name) {
profiles.saved[name] = profiles.saved[profiles.active];
delete profiles.saved[profiles.active];
translationCache[name] = translationCache[profiles.active];
delete translationCache[profiles.active];
saveCache();
activateProfile(name);
}
};
const deleteProfile = () => {
if (Object.keys(profiles.saved).length === 1) {
alert("The last profile can't be deleted.");
} else if (confirm(`Delete the profile "${profiles.active}"?`)) {
delete profiles.saved[profiles.active];
delete translationCache[profiles.active];
saveCache();
activateProfile(Object.keys(profiles.saved)[0]);
loadProfile();
}
};
gmconfig = new GM_config();
gmconfig.getValue = async () => JSON.stringify(profiles.saved[profiles.active]);
gmconfig.setValue = async (_, val) => {
profiles.saved[profiles.active] = JSON.parse(val);
await saveProfiles();
};
gmconfig.init({
id: 'ASMRConfig',
title: 'Kikoeru Translation Settings',
fields: {
profile: {
label: 'Profile',
section: ['Translation Profile', 'Each profile has its own translator and settings. Switching drops unsaved changes.'],
type: 'select',
options: Object.keys(profiles.saved),
default: profiles.active,
save: false,
},
newProfile: {
label: 'New',
type: 'button',
click: () => addProfile(),
},
duplicateProfile: {
label: 'Duplicate',
type: 'button',
click: () => addProfile(profiles.active),
},
deleteProfile: {
label: 'Delete',
type: 'button',
click: deleteProfile,
},
renameProfile: {
label: 'Rename',
type: 'button',
click: renameProfile,
},
translator: {
label: 'Translator',
type: 'select',
options: Object.keys(TRANSLATORS),
default: 'Select a translator...',
},
shouldCache: {
label: 'Use translation cache',
labelPos: 'right',
type: 'checkbox',
default: true,
},
clearCache: {
label: 'Clear cache',
type: 'button',
click: function () {
if (confirm(`Clear the translation cache of the profile "${profiles.active}"?`)) {
delete translationCache[profiles.active];
saveCache();
}
},
},
prefetch: {
label: 'Prefetch translations',
labelPos: 'right',
title: 'Requests the next few lines while the current one plays, so they show without waiting',
type: 'checkbox',
default: true,
},
useContext: {
label: 'Translate with context',
labelPos: 'right',
title: 'Sends the few lines before each one along with it, for the OpenAI API-compatible LLM and DeepL translators',
type: 'checkbox',
default: true,
},
restyleLyrics: {
label: 'Restyle the lyric bar',
labelPos: 'right',
title: 'Larger white text on a dark band, with the player moved to the top',
type: 'checkbox',
default: true,
},
openaiEndpoint: {
label: 'Chat Completions URL',
section: ['OpenAI API Settings', 'Settings that are specific to OpenAI API-compatible LLMs.'],
size: 32,
title: 'The chat completions url you would like to use for translations. This could be through online services like OpenRouter or a local Ollama/llama.cpp/LMStudio service.',
type: 'text',
default: 'https://openrouter.ai/api/v1/chat/completions'
},
openaiModel: {
label: 'Model',
title: 'The model option to use.',
type: 'text',
default: 'tencent/hy-mt2-30b-a3b'
},
openaiAuth: {
label: 'API Key / Auth Token',
title: 'The API Key or Bearer auth token for the endpoint.',
type: 'text',
default: ''
},
openaiSourceLang: {
label: 'Source Language',
size: 7,
title: 'The language to translate from. Fills {0} in the prompt; Auto leaves it blank.',
type: 'text',
default: 'Auto'
},
openaiTargetLang: {
label: 'Target Language',
size: 7,
title: 'The language to translate to. Fills {1} in the prompt.',
type: 'text',
default: 'English'
},
openaiSystemPrompt: {
label: 'System Prompt:',
title: 'The instruction sent with every line. {0} is replaced with the source language and {1} with the target language. With context on, it is sent inside the user message, between the previous lines and the line to translate.',
type: 'textarea',
default: OPENAI_PROMPT_DEFAULT,
},
openaiExtraParams: {
label: 'Extra Parameters (JSON):',
title: 'Added to every request, under the names your server expects: temperature, top_p, top_k, repeat_penalty or repetition_penalty, reasoning settings. {} sends none, as does anything that is not a JSON object.',
type: 'textarea',
default: OPENAI_EXTRA_PARAMS_DEFAULT,
},
libreEndpoint: {
label: 'API Endpoint',
section: ['LibreTranslate Settings', 'Settings that are specific to LibreTranslate'],
title: 'The LibreTranslate API endpoint you would like to use for translations.',
type: 'text',
default: 'http://localhost:5000/translate'
},
libreSourceLang: {
label: 'Source Language',
title: 'The language to translate from',
type: 'select',
options: ['auto', 'en', 'de', 'es', 'fr', 'id', 'ja', 'ko', 'ru', 'th', 'zh'],
default: 'auto'
},
libreTargetLang: {
label: 'Target Language',
title: 'The language to translate to',
type: 'select',
options: ['en', 'de', 'es', 'fr', 'id', 'ja', 'ko', 'ru', 'th', 'zh'],
default: 'en'
},
lunaEndpoint: {
label: 'API Endpoint',
section: ['LunaTranslator Settings', 'Settings that are specific to LunaTranslator. Note: The Model selection dropdown will not populate unless the endpoint is reachable.'],
title: 'The LunaTranslator API endpoint you would like to use for translations.',
type: 'text',
default: 'http://localhost:2333/'
},
lunaTranslator: {
label: 'LunaTranslator Model',
title: 'The model to use. If the list is empty, check the API Endpoint and that the server is running.',
type: 'select',
save: false,
options: ['No models found'],
default: 'No models found'
},
lunaTranslatorId: {
type: 'hidden',
default: ''
},
deeplApiKey: {
label: 'API Key',
section: ['DeepL Settings', 'Settings that are specific to DeepL'],
size: 35,
title: 'Your DeepL API Key. Please check DeepL documentation for more information.',
type: 'text',
default: ''
},
deeplSourceLang: {
label: 'Source Language',
title: 'The language to translate from',
type: 'select',
options: ['AUTO', 'EN', 'DE', 'ES', 'FR', 'ID', 'JA', 'KO', 'PT', 'RU', 'TH', 'ZH'],
default: 'AUTO'
},
deeplTargetLang: {
label: 'Target Language',
title: 'The language to translate to',
type: 'select',
options: ['EN-US', 'DE', 'ES', 'FR', 'ID', 'JA', 'KO', 'PT-BR', 'RU', 'TH', 'ZH-HANS', 'ZH-HANT'],
default: 'EN-US'
},
},
events: {
init: async function () {
// The model list is only needed up front when Luna is the active translator; the dialog loads it when its section shows
if (this.get('translator') === 'LunaTranslator') {
await loadLunaTranslators();
}
applySettings();
if (isFirstRun) {
this.open();
}
},
open: function (doc) {
// Footer order: Cancel, Apply, Save
const cancelBtn = Object.assign(doc.getElementById('ASMRConfig_closeBtn'), { textContent: 'Cancel', title: 'Close without saving' });
cancelBtn.after(Object.assign(doc.createElement('button'), {
textContent: 'Apply',
title: 'Save settings and keep the window open',
className: 'saveclose_buttons',
onclick: () => this.write(null, null, applyFromWindow),
}), doc.getElementById('ASMRConfig_saveBtn'));
showSelectedSection();
// Listens on the document because loading the Luna list re-renders its select,
// which would drop a listener on the node itself
doc.addEventListener('change', (e) => {
if (e.target === this.fields.profile.node) {
activateProfile(this.get('profile', true));
loadProfile();
} else if (e.target === this.fields.translator.node) {
showSelectedSection();
} else if (e.target === this.fields.lunaTranslator.node) {
// Save the Luna model ID when a model is picked
const name = this.get('lunaTranslator', true);
this.set('lunaTranslatorId', lunaTranslators.find(m => m.name === name)?.id ?? '');
} else if (e.target === this.fields.lunaEndpoint.node) {
// Another server has another model list
loadLunaTranslators();
}
});
},
save: function () {
if (applyFromWindow()) {
this.close();
}
},
},
frameStyle: CONFIG_FRAME_STYLES,
css: CONFIG_CSS,
});
GM_registerMenuCommand('Open Translator Settings', () => {
gmconfig.open();
});
}
function applyFromWindow() {
const translator = TRANSLATORS[gmconfig.get('translator')];
if (translator?.requires && !gmconfig.get(translator.requires).length) {
alert(translator.missing);
return false;
}
applySettings();
return true;
}
function applySettings() {
applyLyricStyle();
if (!gmconfig.get('shouldCache')) {
console.warn('[Translate] Cache disabled');
delete translationCache[profiles.active];
}
if (TRANSLATORS[gmconfig.get('translator')]?.translate) {
hookToAudioPlayer();
} else {
console.warn('[Translate] Auto-translation is disabled');
}
}
let lyricStyle;
function applyLyricStyle() {
if (!lyricStyle) {
let css = `
#lyricsBar {
background-color: unset !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
-webkit-line-clamp: none !important;
}
#lyric {
background-color: rgba(0,0,0,0.95) !important;
padding-left: 10px !important;
padding-right: 10px !important;
color: white !important;
font-size: 30px !important;
}
`;
if (isLocalKikoeru) {
css += `
.fixed-bottom-right {
bottom: unset;
top: 0;
}
.fixed-bottom-right.hideStyle {
transform: translateY(300%) !important;
}
.fixed-bottom-right.showStyle {
transform: translateY(55px) !important;
}
.lyricBackgroundDarkMode {
background-color: unset !important;
}
[id^="lyric_line"] {
font-size: x-large;
}
.audio-name {
user-select: text;
}
`;
} else {
css += `
.audio-player {
bottom: unset !important;
}
#lyric:empty {
padding-left: 0px !important;
padding-right: 0px !important;
}
`;
}
lyricStyle = GM_addStyle(css);
}
lyricStyle.disabled = !gmconfig.get('restyleLyrics');
}
// Changes to the site itself; they don't depend on the translation settings
function applySiteTweaks() {
// Enable downloads on Brave
delete Navigator.prototype.brave;
if (isLocalKikoeru) {
return;
}
// Skip the site's volume-above-100% warning. Its OK button is what normally connects the gain node,
// so connect it ourselves on the first user gesture: an AudioContext created outside one starts suspended and mutes the audio.
const findPlayerInt = setInterval(function () {
if (typeof unsafeWindow.connectAudioGain !== 'function') {
return;
}
clearInterval(findPlayerInt);
const player = Array.from(document.querySelectorAll('div')).find(e => e?.__vue__?.agreeVolumeGainWarning === false)?.__vue__;
player.agreeVolumeGainWarning = true;
for (const event of ['click', 'keydown']) {
document.addEventListener(event, unsafeWindow.connectAudioGain, { once: true, capture: true });
}
}, 200);
}
const CONFIG_FRAME_STYLES = `
max-height: 98vh;
box-sizing: content-box;
width: 480px;
border: 1px solid #343740;
border-radius: 8px;
position: fixed;
z-index: 9999;
background-color: #15161a;
`;
const CONFIG_CSS = `
html, body#ASMRConfig {
background: #15161a;
scrollbar-color: #4a4d57 #15161a;
}
body#ASMRConfig {
margin: 0;
color: #e4e6eb;
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
padding: 14px 20px 0;
}
#ASMRConfig .config_header {
font-size: 19px;
color: #f2f3f5;
}
#ASMRConfig .section_header_holder {
background: #1b1d23;
border: 1px solid #272a31;
border-radius: 8px;
margin: 14px 0 0;
padding: 2px 16px;
}
#ASMRConfig .section_header {
background: transparent;
border: none;
border-bottom: 1px solid #272a31;
color: #7aa2ff;
font-size: 13px;
text-align: left;
margin: 0 -16px 10px;
padding: 12px 16px 10px;
}
#ASMRConfig .section_desc {
background: transparent;
border: none;
color: #868a94;
font-size: 12px;
text-align: left;
margin: -4px 0 12px;
padding: 0;
}
#ASMRConfig .config_var {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
margin: 0 0 10px;
}
#ASMRConfig .field_label {
color: #c7cad1;
}
#ASMRConfig input[type="text"],
#ASMRConfig input[type="number"],
#ASMRConfig select,
#ASMRConfig textarea {
flex: 1 1 auto;
min-width: 120px;
background: #1f2127;
color: #e4e6eb;
border: 1px solid #343740;
border-radius: 5px;
padding: 6px 10px;
font-family: inherit;
box-sizing: border-box;
}
#ASMRConfig input[type="text"]:focus,
#ASMRConfig input[type="number"]:focus,
#ASMRConfig select:focus,
#ASMRConfig textarea:focus {
outline: none;
border-color: #5b7cff;
}
#ASMRConfig textarea {
width: 100%;
flex-basis: 100%;
height: calc(3lh + 14px);
resize: vertical;
}
#ASMRConfig #ASMRConfig_field_openaiExtraParams {
height: calc(2lh + 14px);
}
#ASMRConfig input[type="checkbox"] {
accent-color: #5b7cff;
}
#ASMRConfig input[type="button"],
#ASMRConfig .saveclose_buttons {
background: #272a31;
color: #e4e6eb;
border: 1px solid #3a3d47;
border-radius: 5px;
padding: 6px 14px;
margin: 0;
cursor: pointer;
}
#ASMRConfig input[type="button"]:hover,
#ASMRConfig .saveclose_buttons:hover {
background: #30333b;
}
#ASMRConfig_buttons_holder {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
position: sticky;
bottom: 0;
padding: 14px 0;
background: #15161a;
}
#ASMRConfig #ASMRConfig_saveBtn {
background: #5b7cff;
border-color: #5b7cff;
}
#ASMRConfig #ASMRConfig_saveBtn:hover {
background: #6c8aff;
}
#ASMRConfig #ASMRConfig_closeBtn {
background: transparent;
border-color: #3a3d47;
}
#ASMRConfig #ASMRConfig_closeBtn:hover {
background: #272a31;
}
#ASMRConfig #ASMRConfig_field_deleteProfile {
color: #ff7b72;
}
#ASMRConfig #ASMRConfig_field_deleteProfile:hover {
border-color: #ff7b72;
}
#ASMRConfig .reset_holder {
order: -1;
flex: 1 1 auto;
text-align: left;
}
#ASMRConfig .reset,
#ASMRConfig .reset a {
color: #868a94;
font-size: 11.5px;
}
/* Fields that share a line */
#ASMRConfig [id$="Profile_var"],
#ASMRConfig #ASMRConfig_shouldCache_var,
#ASMRConfig #ASMRConfig_clearCache_var,
#ASMRConfig #ASMRConfig_prefetch_var,
#ASMRConfig #ASMRConfig_useContext_var {
display: inline-flex;
vertical-align: middle;
margin-right: 8px;
}
#ASMRConfig #ASMRConfig_shouldCache_var,
#ASMRConfig #ASMRConfig_prefetch_var {
width: calc(60% - 8px);
}
#ASMRConfig #ASMRConfig_clearCache_var,
#ASMRConfig #ASMRConfig_useContext_var {
width: 40%;
margin-right: 0;
}
#ASMRConfig #ASMRConfig_clearCache_var {
justify-content: flex-end;
}
#ASMRConfig #ASMRConfig_renameProfile_var,
#ASMRConfig #ASMRConfig_deleteProfile_var {
float: right;
}
#ASMRConfig #ASMRConfig_deleteProfile_var {
margin-right: 0;
}
#ASMRConfig #ASMRConfig_translator_var {
margin-top: 14px;
}
/* Half-width fields, two per line */
#ASMRConfig [id$="SourceLang_var"],
#ASMRConfig [id$="TargetLang_var"] {
display: inline-flex;
flex-wrap: nowrap;
vertical-align: middle;
width: calc(50% - 8px);
gap: 6px;
}
/* Left column of each pair: gutter before the right column */
#ASMRConfig [id$="SourceLang_var"] {
margin-right: 16px;
}
#ASMRConfig [id$="SourceLang_var"] :is(input, select),
#ASMRConfig [id$="TargetLang_var"] :is(input, select) {
min-width: 0;
}
#ASMRConfig [id$="SourceLang_var"] .field_label,
#ASMRConfig [id$="TargetLang_var"] .field_label {
flex: 0 0 auto;
white-space: nowrap;
}
`;
// Entry point
async function main() {
isFirstRun = !(await GM.listValues()).length;
// Setup profiles
profiles = JSON.parse(await GM.getValue('__profiles', 'null'));
if (!profiles) {
profiles = { active: 'Default', saved: { Default: JSON.parse(await GM.getValue('ASMRConfig', '{}')) } };
}
// Cache Setup
translationCache = JSON.parse(await GM.getValue('__translation_cache', 'null'));
if (!translationCache) {
translationCache = { Default: JSON.parse(await GM.getValue('__translation_map', '{}')) };
}
// Migrations run once per update
const migratedVersion = await GM.getValue('__version', '');
if (migratedVersion !== GM_info.script.version) {
await migrateOldSettings();
await GM.setValue('__version', GM_info.script.version);
}
applySiteTweaks();
setupConfig();
}
main();