☰

ASMR Translator

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

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==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();