☰

ASMR Translator

Auto-translates ASMR subtitles on Kikoeru-based sites. Supports any OpenAI API-compatible LLM, DeepL, LibreTranslate, and LunaTranslator.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==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();