Compressor
diff --git a/extension/audio-options.js b/extension/audio-options.js
index 5342377..d850b56 100644
--- a/extension/audio-options.js
+++ b/extension/audio-options.js
@@ -10,12 +10,14 @@ const PRESETS = {
const DEFAULT_AUDIO_SETTINGS = {
enabled: false,
+ boostDb: 0,
compressor: {
enabled: false,
preset: 'recommended',
customParams: { ...PRESETS.custom }
}
};
+const BOOST_DB_LIMITS = { min: 0, max: 20 };
const PARAM_LIMITS = {
threshold: { min: -60, max: 0 },
knee: { min: 0, max: 40 },
@@ -26,9 +28,11 @@ const PARAM_LIMITS = {
const elements = {
audioEnabled: document.getElementById('audioEnabled'),
+ boostRange: document.getElementById('boostRange'),
+ boostNumber: document.getElementById('boostNumber'),
compressorEnabled: document.getElementById('compressorEnabled'),
presetInputs: Array.from(document.querySelectorAll('input[name="preset"]')),
- controlRows: Array.from(document.querySelectorAll('.control-row')),
+ controlRows: Array.from(document.querySelectorAll('.control-row[data-param]')),
backLink: document.getElementById('backLink')
};
@@ -41,12 +45,20 @@ function cloneDefaultSettings() {
let currentSettings = cloneDefaultSettings();
+function normalizeBoostDb(value) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) return DEFAULT_AUDIO_SETTINGS.boostDb;
+ const clamped = Math.min(BOOST_DB_LIMITS.max, Math.max(BOOST_DB_LIMITS.min, parsed));
+ return Math.round(clamped * 2) / 2;
+}
+
function mergeAudioSettings(settings = {}) {
const safeSettings = settings && typeof settings === 'object' ? settings : {};
const defaults = cloneDefaultSettings();
return {
...defaults,
...safeSettings,
+ boostDb: normalizeBoostDb(safeSettings.boostDb),
compressor: {
...defaults.compressor,
...(safeSettings.compressor || {}),
@@ -61,10 +73,18 @@ function mergeAudioSettings(settings = {}) {
function debounceSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
+ saveTimer = null;
chrome.storage.local.set({ audioSettings: currentSettings });
}, 40);
}
+async function flushPendingSave() {
+ if (!saveTimer) return;
+ clearTimeout(saveTimer);
+ saveTimer = null;
+ await chrome.storage.local.set({ audioSettings: currentSettings });
+}
+
function getParamValue(param, value, isMsInput = false) {
const parsed = Number(value);
const candidate = Number.isFinite(parsed)
@@ -84,6 +104,9 @@ function formatNumber(value, param, isMsInput = false) {
function render() {
isRendering = true;
elements.audioEnabled.checked = currentSettings.enabled === true;
+ const boostDb = normalizeBoostDb(currentSettings.boostDb);
+ elements.boostRange.value = boostDb;
+ elements.boostNumber.value = boostDb;
elements.compressorEnabled.checked = currentSettings.compressor.enabled === true;
const selectedPreset = currentSettings.compressor.preset || 'recommended';
@@ -106,6 +129,17 @@ function render() {
isRendering = false;
}
+function setBoostDb(value) {
+ currentSettings.boostDb = normalizeBoostDb(value);
+ if (currentSettings.boostDb > 0) {
+ currentSettings.enabled = true;
+ } else if (!currentSettings.compressor.enabled) {
+ currentSettings.enabled = false;
+ }
+ render();
+ debounceSave();
+}
+
function setPreset(preset) {
currentSettings.compressor.preset = preset;
if (preset === 'custom') {
@@ -138,16 +172,27 @@ async function init() {
elements.audioEnabled.addEventListener('change', () => {
currentSettings.enabled = elements.audioEnabled.checked;
- if (currentSettings.enabled && !currentSettings.compressor.enabled) {
+ if (currentSettings.enabled && !currentSettings.compressor.enabled && currentSettings.boostDb === 0) {
currentSettings.compressor.enabled = true;
}
render();
debounceSave();
});
+[elements.boostRange, elements.boostNumber].forEach(input => {
+ input.addEventListener('input', () => {
+ if (isRendering) return;
+ setBoostDb(input.value);
+ });
+});
+
elements.compressorEnabled.addEventListener('change', () => {
currentSettings.compressor.enabled = elements.compressorEnabled.checked;
- if (currentSettings.compressor.enabled) currentSettings.enabled = true;
+ if (currentSettings.compressor.enabled) {
+ currentSettings.enabled = true;
+ } else if (currentSettings.boostDb === 0) {
+ currentSettings.enabled = false;
+ }
render();
debounceSave();
});
@@ -181,12 +226,17 @@ chrome.storage.onChanged.addListener((changes, area) => {
});
if (elements.backLink) {
- elements.backLink.addEventListener('click', (e) => {
+ elements.backLink.addEventListener('click', async (e) => {
e.preventDefault();
+ await flushPendingSave();
window.close();
});
}
+window.addEventListener('pagehide', () => {
+ flushPendingSave().catch(() => {});
+});
+
init().catch(err => {
console.error('[AudioOptions] Failed to initialize:', err);
});
diff --git a/extension/background.js b/extension/background.js
index c945667..3d3d99d 100644
--- a/extension/background.js
+++ b/extension/background.js
@@ -465,7 +465,7 @@ function emitEpisodeLobbyForCurrentPrivacy() {
const LEGACY_SYNC_KEYS = [
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey',
'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username',
- 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
+ 'filterNoise', 'customBlacklistDomains', 'autoSyncNextEpisode', 'forceSyncMode',
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
];
diff --git a/extension/content.js b/extension/content.js
index fbe7147..8e8bdd9 100644
--- a/extension/content.js
+++ b/extension/content.js
@@ -250,6 +250,11 @@
if (area === 'local' && changes.autoSyncNextEpisode) {
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
}
+ if (area === 'local' && changes.audioSettings) {
+ _audioSettings = mergeAudioSettings(changes.audioSettings.newValue);
+ const video = findVideo();
+ if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
+ }
}
chrome.storage.onChanged.addListener(handleStorageChanged);
@@ -605,24 +610,32 @@
smooth: { threshold: -30, ratio: 1.5, attack: 0.030, release: 0.250, knee: 20 },
custom: { threshold: -24, ratio: 12, attack: 0.003, release: 0.250, knee: 30 }
};
- const DEFAULT_AUDIO_SETTINGS = {
- enabled: false,
- compressor: {
+ const DEFAULT_AUDIO_SETTINGS = {
+ enabled: false,
+ boostDb: 0,
+ compressor: {
enabled: false,
preset: 'recommended',
customParams: { ...AUDIO_PRESETS.custom }
}
};
let audioCtx = null;
- let audioChains = new WeakMap();
- let currentAudioVideo = null;
+ let audioChains = new WeakMap();
+ let currentAudioVideo = null;
+
+ function normalizeBoostDb(value) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) return DEFAULT_AUDIO_SETTINGS.boostDb;
+ return Math.min(20, Math.max(0, Math.round(parsed * 2) / 2));
+ }
function mergeAudioSettings(settings = {}) {
const safeSettings = settings && typeof settings === 'object' ? settings : {};
- return {
- ...DEFAULT_AUDIO_SETTINGS,
- ...safeSettings,
- compressor: {
+ return {
+ ...DEFAULT_AUDIO_SETTINGS,
+ ...safeSettings,
+ boostDb: normalizeBoostDb(safeSettings.boostDb),
+ compressor: {
...DEFAULT_AUDIO_SETTINGS.compressor,
...(safeSettings.compressor || {}),
customParams: {
@@ -665,21 +678,31 @@
if (!ctx) return null;
try {
- const src = ctx.createMediaElementSource(videoEl);
- const compressor = ctx.createDynamicsCompressor();
- const dryGain = ctx.createGain();
- const compGain = ctx.createGain();
-
- src.connect(dryGain);
- dryGain.connect(ctx.destination);
- src.connect(compressor);
- compressor.connect(compGain);
- compGain.connect(ctx.destination);
-
- dryGain.gain.value = 1;
- compGain.gain.value = 0;
-
- const chain = { compressor, dryGain, compGain, active: false };
+ const src = ctx.createMediaElementSource(videoEl);
+ const compressor = ctx.createDynamicsCompressor();
+ const dryGain = ctx.createGain();
+ const compGain = ctx.createGain();
+ const outputGain = ctx.createGain();
+ const limiter = ctx.createDynamicsCompressor();
+
+ src.connect(dryGain);
+ dryGain.connect(outputGain);
+ src.connect(compressor);
+ compressor.connect(compGain);
+ compGain.connect(outputGain);
+ outputGain.connect(limiter);
+ limiter.connect(ctx.destination);
+
+ dryGain.gain.value = 1;
+ compGain.gain.value = 0;
+ outputGain.gain.value = 1;
+ limiter.threshold.value = 0;
+ limiter.knee.value = 0;
+ limiter.ratio.value = 20;
+ limiter.attack.value = 0;
+ limiter.release.value = 0.1;
+
+ const chain = { compressor, dryGain, compGain, outputGain, limiter, active: false, signature: '' };
audioChains.set(videoEl, chain);
currentAudioVideo = videoEl;
return chain;
@@ -699,46 +722,59 @@
function applyAudioBypass(videoEl) {
const chain = audioChains.get(videoEl);
if (!chain || !chain.active) return;
- const t = chain.dryGain.context.currentTime;
- rampGain(chain.dryGain, 1, t);
- rampGain(chain.compGain, 0, t);
- chain.active = false;
- reportLog('Audio compressor disabled', 'info');
+ const t = chain.dryGain.context.currentTime;
+ rampGain(chain.dryGain, 1, t);
+ rampGain(chain.compGain, 0, t);
+ rampGain(chain.outputGain, 1, t);
+ chain.limiter.threshold.setValueAtTime(0, t);
+ chain.active = false;
+ chain.signature = '';
+ reportLog('Audio processing disabled', 'info');
}
function bypassCurrentAudioProcessing() {
if (currentAudioVideo) applyAudioBypass(currentAudioVideo);
}
- function applyAudioSettings(videoEl, settings) {
- const mergedSettings = mergeAudioSettings(settings);
- if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) {
- applyAudioBypass(videoEl);
- return;
- }
+ function applyAudioSettings(videoEl, settings) {
+ const mergedSettings = mergeAudioSettings(settings);
+ const compressorEnabled = mergedSettings.compressor?.enabled === true;
+ const boostDb = normalizeBoostDb(mergedSettings.boostDb);
+ if (!mergedSettings.enabled || (!compressorEnabled && boostDb === 0)) {
+ applyAudioBypass(videoEl);
+ return;
+ }
const chain = setupAudioChain(videoEl);
if (!chain) return;
- const cSettings = mergedSettings.compressor;
- const params = cSettings.preset === 'custom'
- ? cSettings.customParams
- : AUDIO_PRESETS[cSettings.preset] || AUDIO_PRESETS.recommended;
-
- chain.compressor.threshold.value = params.threshold ?? -24;
- chain.compressor.knee.value = params.knee ?? 15;
- chain.compressor.ratio.value = params.ratio ?? 8;
- chain.compressor.attack.value = params.attack ?? 0.010;
- chain.compressor.release.value = params.release ?? 0.300;
-
- if (!chain.active) {
- const t = chain.dryGain.context.currentTime;
- rampGain(chain.dryGain, 0, t);
- rampGain(chain.compGain, 1, t);
- chain.active = true;
- reportLog('Audio compressor enabled', 'info');
- }
- }
+ if (compressorEnabled) {
+ const cSettings = mergedSettings.compressor;
+ const params = cSettings.preset === 'custom'
+ ? cSettings.customParams
+ : AUDIO_PRESETS[cSettings.preset] || AUDIO_PRESETS.recommended;
+
+ chain.compressor.threshold.value = params.threshold ?? -24;
+ chain.compressor.knee.value = params.knee ?? 15;
+ chain.compressor.ratio.value = params.ratio ?? 8;
+ chain.compressor.attack.value = params.attack ?? 0.010;
+ chain.compressor.release.value = params.release ?? 0.300;
+ }
+
+ const t = chain.dryGain.context.currentTime;
+ rampGain(chain.dryGain, compressorEnabled ? 0 : 1, t);
+ rampGain(chain.compGain, compressorEnabled ? 1 : 0, t);
+ rampGain(chain.outputGain, Math.pow(10, boostDb / 20), t);
+ chain.limiter.threshold.setValueAtTime(boostDb > 0 ? -1 : 0, t);
+
+ const signature = `${compressorEnabled ? 'compressor' : 'dry'}:${boostDb}`;
+ if (chain.signature !== signature) {
+ const boostLabel = boostDb > 0 ? ` + ${boostDb} dB boost` : '';
+ reportLog(`Audio processing enabled (${compressorEnabled ? 'compressor' : 'boost only'}${boostLabel})`, 'info');
+ }
+ chain.signature = signature;
+ chain.active = true;
+ }
// --- Episode Auto-Sync: Detection ---
function getMediaTitle() {
diff --git a/extension/locales/de.json b/extension/locales/de.json
index 94a3131..26cf419 100644
--- a/extension/locales/de.json
+++ b/extension/locales/de.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Anonymer Koala",
"LABEL_HIDE_CLUTTER": "Aufgeräumte Tab-Liste",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtert Nicht-Video-Tabs und irrelevante Domains heraus, um die Liste sauber zu halten",
+ "BLACKLIST_EDIT": "Liste bearbeiten",
+ "BLACKLIST_EDITOR_LABEL": "Ausgeblendete Domains",
+ "BLACKLIST_EDITOR_HELP": "Eine Domain pro Zeile. URLs werden auf ihren Hostnamen reduziert. Speicherung nur auf diesem Gerät.",
+ "BLACKLIST_RESET": "Standards wiederherstellen",
+ "BLACKLIST_SAVE": "Liste speichern",
+ "BLACKLIST_STATUS_INVALID": "Nicht gespeichert. Bitte diese Einträge prüfen: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Nicht gespeichert. Die Liste unterstützt höchstens {max} Domains.",
+ "BLACKLIST_STATUS_SAVED": "{count} ausgeblendete Domains auf diesem Gerät gespeichert.",
+ "BLACKLIST_STATUS_DEFAULTS": "Standard-Domainliste wiederhergestellt.",
"LABEL_AUTO_SYNC_NEXT": "Nächste Episode auto-syncen",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausiert automatisch und wartet auf alle Teilnehmer bei Episodenwechsel, startet dann synchron.",
"LABEL_AUTO_COPY_INVITE": "Einladungslink auto-kopieren",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Zurück",
"AUDIO_PAGE_TITLE": "Audio-Einstellungen",
"AUDIO_MASTER_TOGGLE": "Audio-Verarbeitung",
+ "AUDIO_DEFAULT_BOOST": "Standard-Verstärkung",
+ "AUDIO_DEFAULT_BOOST_HELP": "Erhöht die Videolautstärke unabhängig vom Kompressor. 0 dB deaktiviert die Verstärkung.",
+ "AUDIO_PARAM_BOOST": "Verstärkung",
"AUDIO_COMPRESSOR": "Kompressor",
"AUDIO_COMPRESSOR_ENABLE": "Aktiviert",
"AUDIO_PRESET": "Preset",
diff --git a/extension/locales/en.json b/extension/locales/en.json
index dcd5e7b..38ea622 100644
--- a/extension/locales/en.json
+++ b/extension/locales/en.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Anonymous Koala",
"LABEL_HIDE_CLUTTER": "Hide Clutter Tabs",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filters out non-video tabs and unrelated domains to keep the list clean",
+ "BLACKLIST_EDIT": "Edit list",
+ "BLACKLIST_EDITOR_LABEL": "Hidden domains",
+ "BLACKLIST_EDITOR_HELP": "One domain per line. URLs are reduced to their hostname. Stored only on this device.",
+ "BLACKLIST_RESET": "Restore defaults",
+ "BLACKLIST_SAVE": "Save list",
+ "BLACKLIST_STATUS_INVALID": "Not saved. Check these entries: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Not saved. The list supports at most {max} domains.",
+ "BLACKLIST_STATUS_SAVED": "Saved {count} hidden domains on this device.",
+ "BLACKLIST_STATUS_DEFAULTS": "Default domain list restored.",
"LABEL_AUTO_SYNC_NEXT": "Auto-Sync Next Episode",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pauses automatically and waits for all peers when an episode changes, then sync-starts together.",
"LABEL_AUTO_COPY_INVITE": "Auto-Copy Invite Link",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Back",
"AUDIO_PAGE_TITLE": "Audio Settings",
"AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_DEFAULT_BOOST": "Default Boost",
+ "AUDIO_DEFAULT_BOOST_HELP": "Raises video volume independently of the compressor. Set 0 dB to disable.",
+ "AUDIO_PARAM_BOOST": "Boost",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Enabled",
"AUDIO_PRESET": "Preset",
diff --git a/extension/locales/es.json b/extension/locales/es.json
index c42aedb..d92ec2e 100644
--- a/extension/locales/es.json
+++ b/extension/locales/es.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Koala anónimo",
"LABEL_HIDE_CLUTTER": "Ocultar pestañas sin video",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra pestañas que no son de video y dominios no relacionados para mantener limpia la lista",
+ "BLACKLIST_EDIT": "Editar lista",
+ "BLACKLIST_EDITOR_LABEL": "Dominios ocultos",
+ "BLACKLIST_EDITOR_HELP": "Un dominio por línea. Las URL se reducen a su nombre de host. Se guarda solo en este dispositivo.",
+ "BLACKLIST_RESET": "Restaurar valores predeterminados",
+ "BLACKLIST_SAVE": "Guardar lista",
+ "BLACKLIST_STATUS_INVALID": "No se guardó. Revisa estas entradas: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "No se guardó. La lista admite como máximo {max} dominios.",
+ "BLACKLIST_STATUS_SAVED": "Se guardaron {count} dominios ocultos en este dispositivo.",
+ "BLACKLIST_STATUS_DEFAULTS": "Se restauró la lista de dominios predeterminada.",
"LABEL_AUTO_SYNC_NEXT": "Sincro auto del siguiente episodio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automáticamente y espera a todos al cambiar de episodio, luego inicia de forma sincrónica.",
"LABEL_AUTO_COPY_INVITE": "Auto-copiar enlace",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Volver",
"AUDIO_PAGE_TITLE": "Configuración de audio",
"AUDIO_MASTER_TOGGLE": "Procesamiento de audio",
+ "AUDIO_DEFAULT_BOOST": "Amplificación predeterminada",
+ "AUDIO_DEFAULT_BOOST_HELP": "Aumenta el volumen del video independientemente del compresor. Usa 0 dB para desactivarla.",
+ "AUDIO_PARAM_BOOST": "Amplificación",
"AUDIO_COMPRESSOR": "Compresor",
"AUDIO_COMPRESSOR_ENABLE": "Activado",
"AUDIO_PRESET": "Preajuste",
diff --git a/extension/locales/fr.json b/extension/locales/fr.json
index 83533a3..b6477c5 100644
--- a/extension/locales/fr.json
+++ b/extension/locales/fr.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Koala anonyme",
"LABEL_HIDE_CLUTTER": "Liste d'onglets épurée",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtre les onglets non-vidéo et les domaines non pertinents pour garder la liste propre",
+ "BLACKLIST_EDIT": "Modifier la liste",
+ "BLACKLIST_EDITOR_LABEL": "Domaines masqués",
+ "BLACKLIST_EDITOR_HELP": "Un domaine par ligne. Les URL sont réduites à leur nom d’hôte. Stockage uniquement sur cet appareil.",
+ "BLACKLIST_RESET": "Restaurer les valeurs par défaut",
+ "BLACKLIST_SAVE": "Enregistrer la liste",
+ "BLACKLIST_STATUS_INVALID": "Non enregistré. Vérifiez ces entrées : {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Non enregistré. La liste accepte au maximum {max} domaines.",
+ "BLACKLIST_STATUS_SAVED": "{count} domaines masqués enregistrés sur cet appareil.",
+ "BLACKLIST_STATUS_DEFAULTS": "Liste de domaines par défaut restaurée.",
"LABEL_AUTO_SYNC_NEXT": "Synchro auto l'épisode suivant",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Met en pause et attend tous les membres lors d'un changement d'épisode, puis démarre de manière synchrone.",
"LABEL_AUTO_COPY_INVITE": "Copie auto du lien",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Retour",
"AUDIO_PAGE_TITLE": "Paramètres audio",
"AUDIO_MASTER_TOGGLE": "Traitement audio",
+ "AUDIO_DEFAULT_BOOST": "Amplification par défaut",
+ "AUDIO_DEFAULT_BOOST_HELP": "Augmente le volume vidéo indépendamment du compresseur. Réglez sur 0 dB pour désactiver.",
+ "AUDIO_PARAM_BOOST": "Amplification",
"AUDIO_COMPRESSOR": "Compresseur",
"AUDIO_COMPRESSOR_ENABLE": "Activé",
"AUDIO_PRESET": "Préréglage",
diff --git a/extension/locales/it.json b/extension/locales/it.json
index 8a3c1e5..d4efd0a 100644
--- a/extension/locales/it.json
+++ b/extension/locales/it.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Koala Anonimo",
"LABEL_HIDE_CLUTTER": "Nascondi schede senza video",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Mantiene pulito l'elenco filtrando le schede non pertinenti",
+ "BLACKLIST_EDIT": "Modifica elenco",
+ "BLACKLIST_EDITOR_LABEL": "Domini nascosti",
+ "BLACKLIST_EDITOR_HELP": "Un dominio per riga. Gli URL vengono ridotti al nome host. Salvato solo su questo dispositivo.",
+ "BLACKLIST_RESET": "Ripristina predefiniti",
+ "BLACKLIST_SAVE": "Salva elenco",
+ "BLACKLIST_STATUS_INVALID": "Non salvato. Controlla queste voci: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Non salvato. L'elenco supporta al massimo {max} domini.",
+ "BLACKLIST_STATUS_SAVED": "Salvati {count} domini nascosti su questo dispositivo.",
+ "BLACKLIST_STATUS_DEFAULTS": "Elenco domini predefinito ripristinato.",
"LABEL_AUTO_SYNC_NEXT": "Sincro auto prossimo episodio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Attende tutti i partecipanti prima di avviare il prossimo episodio.",
"LABEL_AUTO_COPY_INVITE": "Copia automatica invito",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Indietro",
"AUDIO_PAGE_TITLE": "Impostazioni Audio",
"AUDIO_MASTER_TOGGLE": "Elaborazione Audio",
+ "AUDIO_DEFAULT_BOOST": "Amplificazione predefinita",
+ "AUDIO_DEFAULT_BOOST_HELP": "Aumenta il volume del video indipendentemente dal compressore. Imposta 0 dB per disattivare.",
+ "AUDIO_PARAM_BOOST": "Amplificazione",
"AUDIO_COMPRESSOR": "Compressore",
"AUDIO_COMPRESSOR_ENABLE": "Attivo",
"AUDIO_PRESET": "Preimpostazione",
diff --git a/extension/locales/ja.json b/extension/locales/ja.json
index 383657c..a3e41b2 100644
--- a/extension/locales/ja.json
+++ b/extension/locales/ja.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "匿名コアラ",
"LABEL_HIDE_CLUTTER": "不要なタブを非表示",
"LABEL_HIDE_CLUTTER_TOOLTIP": "リストをすっきりさせるため、ビデオのないタブや無関係なドメインをフィルタリングします",
+ "BLACKLIST_EDIT": "リストを編集",
+ "BLACKLIST_EDITOR_LABEL": "非表示ドメイン",
+ "BLACKLIST_EDITOR_HELP": "1行に1つのドメインを入力します。URLはホスト名に変換され、この端末にのみ保存されます。",
+ "BLACKLIST_RESET": "既定値に戻す",
+ "BLACKLIST_SAVE": "リストを保存",
+ "BLACKLIST_STATUS_INVALID": "保存されませんでした。次の項目を確認してください: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "保存されませんでした。リストは最大{max}ドメインまでです。",
+ "BLACKLIST_STATUS_SAVED": "この端末に{count}件の非表示ドメインを保存しました。",
+ "BLACKLIST_STATUS_DEFAULTS": "既定のドメインリストを復元しました。",
"LABEL_AUTO_SYNC_NEXT": "次のエピソードを自動同期",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "エピソード変更時に自動的に一時停止して全員を待ち、準備ができたら同時に再生を開始します。",
"LABEL_AUTO_COPY_INVITE": "招待リンク自動コピー",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← 戻る",
"AUDIO_PAGE_TITLE": "オーディオ設定",
"AUDIO_MASTER_TOGGLE": "オーディオ処理",
+ "AUDIO_DEFAULT_BOOST": "デフォルトブースト",
+ "AUDIO_DEFAULT_BOOST_HELP": "コンプレッサーとは独立して動画音量を上げます。無効にするには0 dBに設定します。",
+ "AUDIO_PARAM_BOOST": "ブースト",
"AUDIO_COMPRESSOR": "コンプレッサー",
"AUDIO_COMPRESSOR_ENABLE": "有効",
"AUDIO_PRESET": "プリセット",
diff --git a/extension/locales/ko.json b/extension/locales/ko.json
index ee7c353..2bc7c27 100644
--- a/extension/locales/ko.json
+++ b/extension/locales/ko.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "익명의 코알라",
"LABEL_HIDE_CLUTTER": "복잡한 탭 숨기기",
"LABEL_HIDE_CLUTTER_TOOLTIP": "목록을 깔끔하게 유지하기 위해 비디오가 없는 탭과 관련 없는 도메인을 필터링합니다",
+ "BLACKLIST_EDIT": "목록 편집",
+ "BLACKLIST_EDITOR_LABEL": "숨긴 도메인",
+ "BLACKLIST_EDITOR_HELP": "한 줄에 도메인 하나를 입력하세요. URL은 호스트 이름으로 변환되며 이 기기에만 저장됩니다.",
+ "BLACKLIST_RESET": "기본값 복원",
+ "BLACKLIST_SAVE": "목록 저장",
+ "BLACKLIST_STATUS_INVALID": "저장되지 않았습니다. 다음 항목을 확인하세요: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "저장되지 않았습니다. 목록은 최대 {max}개 도메인을 지원합니다.",
+ "BLACKLIST_STATUS_SAVED": "이 기기에 숨긴 도메인 {count}개를 저장했습니다.",
+ "BLACKLIST_STATUS_DEFAULTS": "기본 도메인 목록을 복원했습니다.",
"LABEL_AUTO_SYNC_NEXT": "다음 에피소드 자동 동기화",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "에피소드가 변경되면 자동으로 일시정지하고 모든 참여자를 기다린 후 함께 동기화하여 시작합니다.",
"LABEL_AUTO_COPY_INVITE": "초대 링크 자동 복사",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← 뒤로",
"AUDIO_PAGE_TITLE": "오디오 설정",
"AUDIO_MASTER_TOGGLE": "오디오 처리",
+ "AUDIO_DEFAULT_BOOST": "기본 부스트",
+ "AUDIO_DEFAULT_BOOST_HELP": "컴프레서와 별도로 동영상 볼륨을 높입니다. 끄려면 0 dB로 설정하세요.",
+ "AUDIO_PARAM_BOOST": "부스트",
"AUDIO_COMPRESSOR": "컴프레서",
"AUDIO_COMPRESSOR_ENABLE": "활성화",
"AUDIO_PRESET": "프리셋",
diff --git a/extension/locales/nl.json b/extension/locales/nl.json
index bb56eac..9f8d9ad 100644
--- a/extension/locales/nl.json
+++ b/extension/locales/nl.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Anonieme Koala",
"LABEL_HIDE_CLUTTER": "Overbodige tabbladen verbergen",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtert tabbladen zonder video's en niet-gerelateerde domeinen om de lijst schoon te houden",
+ "BLACKLIST_EDIT": "Lijst bewerken",
+ "BLACKLIST_EDITOR_LABEL": "Verborgen domeinen",
+ "BLACKLIST_EDITOR_HELP": "Eén domein per regel. URL's worden teruggebracht tot hun hostnaam. Alleen opgeslagen op dit apparaat.",
+ "BLACKLIST_RESET": "Standaardwaarden herstellen",
+ "BLACKLIST_SAVE": "Lijst opslaan",
+ "BLACKLIST_STATUS_INVALID": "Niet opgeslagen. Controleer deze items: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Niet opgeslagen. De lijst ondersteunt maximaal {max} domeinen.",
+ "BLACKLIST_STATUS_SAVED": "{count} verborgen domeinen opgeslagen op dit apparaat.",
+ "BLACKLIST_STATUS_DEFAULTS": "Standaard domeinlijst hersteld.",
"LABEL_AUTO_SYNC_NEXT": "Volgende aflevering automatisch syncen",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pauzeert automatisch en wacht op alle deelnemers wanneer een aflevering verandert, en start dan gezamenlijk gesynchroniseerd.",
"LABEL_AUTO_COPY_INVITE": "Uitnodigingslink automatisch kopiëren",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Terug",
"AUDIO_PAGE_TITLE": "Audio-instellingen",
"AUDIO_MASTER_TOGGLE": "Audioverwerking",
+ "AUDIO_DEFAULT_BOOST": "Standaardversterking",
+ "AUDIO_DEFAULT_BOOST_HELP": "Verhoogt het videovolume onafhankelijk van de compressor. Stel in op 0 dB om uit te schakelen.",
+ "AUDIO_PARAM_BOOST": "Versterking",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Ingeschakeld",
"AUDIO_PRESET": "Voorinstelling",
diff --git a/extension/locales/pl.json b/extension/locales/pl.json
index b1148ef..a0c8d03 100644
--- a/extension/locales/pl.json
+++ b/extension/locales/pl.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Anonimowy Koala",
"LABEL_HIDE_CLUTTER": "Ukryj zbędne karty",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtruje karty bez wideo i niepowiązane domeny, aby zachować porządek",
+ "BLACKLIST_EDIT": "Edytuj listę",
+ "BLACKLIST_EDITOR_LABEL": "Ukryte domeny",
+ "BLACKLIST_EDITOR_HELP": "Jedna domena w wierszu. Adresy URL są skracane do nazwy hosta. Zapis tylko na tym urządzeniu.",
+ "BLACKLIST_RESET": "Przywróć domyślne",
+ "BLACKLIST_SAVE": "Zapisz listę",
+ "BLACKLIST_STATUS_INVALID": "Nie zapisano. Sprawdź te wpisy: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Nie zapisano. Lista obsługuje maksymalnie {max} domen.",
+ "BLACKLIST_STATUS_SAVED": "Zapisano {count} ukrytych domen na tym urządzeniu.",
+ "BLACKLIST_STATUS_DEFAULTS": "Przywrócono domyślną listę domen.",
"LABEL_AUTO_SYNC_NEXT": "Auto-sync następnego odcinka",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Automatycznie wstrzymuje i czeka na wszystkich uczestników po zmianie odcinka, a następnie uruchamia się wspólnie.",
"LABEL_AUTO_COPY_INVITE": "Auto-kopiowanie zaproszenia",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Wstecz",
"AUDIO_PAGE_TITLE": "Ustawienia dźwięku",
"AUDIO_MASTER_TOGGLE": "Przetwarzanie dźwięku",
+ "AUDIO_DEFAULT_BOOST": "Domyślne wzmocnienie",
+ "AUDIO_DEFAULT_BOOST_HELP": "Zwiększa głośność wideo niezależnie od kompresora. Ustaw 0 dB, aby wyłączyć.",
+ "AUDIO_PARAM_BOOST": "Wzmocnienie",
"AUDIO_COMPRESSOR": "Kompresor",
"AUDIO_COMPRESSOR_ENABLE": "Włączony",
"AUDIO_PRESET": "Preset",
diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json
index 0220416..20c492c 100644
--- a/extension/locales/pt-BR.json
+++ b/extension/locales/pt-BR.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Coala anônimo",
"LABEL_HIDE_CLUTTER": "Ocultar abas sem vídeo",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra abas não relacionadas para manter a lista limpa",
+ "BLACKLIST_EDIT": "Editar lista",
+ "BLACKLIST_EDITOR_LABEL": "Domínios ocultos",
+ "BLACKLIST_EDITOR_HELP": "Um domínio por linha. URLs são reduzidas ao nome do host. Salvo somente neste dispositivo.",
+ "BLACKLIST_RESET": "Restaurar padrões",
+ "BLACKLIST_SAVE": "Salvar lista",
+ "BLACKLIST_STATUS_INVALID": "Não foi salvo. Verifique estas entradas: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Não foi salvo. A lista aceita no máximo {max} domínios.",
+ "BLACKLIST_STATUS_SAVED": "{count} domínios ocultos salvos neste dispositivo.",
+ "BLACKLIST_STATUS_DEFAULTS": "Lista de domínios padrão restaurada.",
"LABEL_AUTO_SYNC_NEXT": "Sincro auto do próximo episódio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda todos ao mudar de episódio.",
"LABEL_AUTO_COPY_INVITE": "Auto-copiar link de convite",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Voltar",
"AUDIO_PAGE_TITLE": "Configurações de áudio",
"AUDIO_MASTER_TOGGLE": "Processamento de áudio",
+ "AUDIO_DEFAULT_BOOST": "Ganho padrão",
+ "AUDIO_DEFAULT_BOOST_HELP": "Aumenta o volume do vídeo independentemente do compressor. Use 0 dB para desativar.",
+ "AUDIO_PARAM_BOOST": "Ganho",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Ativado",
"AUDIO_PRESET": "Predefinição",
diff --git a/extension/locales/pt.json b/extension/locales/pt.json
index 710c2c4..0791ceb 100644
--- a/extension/locales/pt.json
+++ b/extension/locales/pt.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Coala Anónimo",
"LABEL_HIDE_CLUTTER": "Ocultar separadores sem vídeo",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra separadores não relacionados para manter a lista limpa",
+ "BLACKLIST_EDIT": "Editar lista",
+ "BLACKLIST_EDITOR_LABEL": "Domínios ocultos",
+ "BLACKLIST_EDITOR_HELP": "Um domínio por linha. Os URL são reduzidos ao nome do anfitrião. Guardado apenas neste dispositivo.",
+ "BLACKLIST_RESET": "Repor predefinições",
+ "BLACKLIST_SAVE": "Guardar lista",
+ "BLACKLIST_STATUS_INVALID": "Não foi guardado. Verifique estas entradas: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Não foi guardado. A lista suporta no máximo {max} domínios.",
+ "BLACKLIST_STATUS_SAVED": "{count} domínios ocultos guardados neste dispositivo.",
+ "BLACKLIST_STATUS_DEFAULTS": "Lista de domínios predefinida restaurada.",
"LABEL_AUTO_SYNC_NEXT": "Sincro Auto do Próximo Episódio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda por todos ao mudar de episódio.",
"LABEL_AUTO_COPY_INVITE": "Copiar Convite Automaticamente",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Voltar",
"AUDIO_PAGE_TITLE": "Configurações de Áudio",
"AUDIO_MASTER_TOGGLE": "Processamento de Áudio",
+ "AUDIO_DEFAULT_BOOST": "Ganho predefinido",
+ "AUDIO_DEFAULT_BOOST_HELP": "Aumenta o volume do vídeo independentemente do compressor. Defina 0 dB para desativar.",
+ "AUDIO_PARAM_BOOST": "Ganho",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Ativado",
"AUDIO_PRESET": "Predefinição",
diff --git a/extension/locales/ru.json b/extension/locales/ru.json
index 3bb12b8..0ac0afa 100644
--- a/extension/locales/ru.json
+++ b/extension/locales/ru.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Анонимный Коала",
"LABEL_HIDE_CLUTTER": "Чистый список вкладок",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Скрывает вкладки без видео и несвязанные домены для порядка",
+ "BLACKLIST_EDIT": "Изменить список",
+ "BLACKLIST_EDITOR_LABEL": "Скрытые домены",
+ "BLACKLIST_EDITOR_HELP": "Один домен в строке. URL сокращаются до имени хоста. Хранится только на этом устройстве.",
+ "BLACKLIST_RESET": "Восстановить значения по умолчанию",
+ "BLACKLIST_SAVE": "Сохранить список",
+ "BLACKLIST_STATUS_INVALID": "Не сохранено. Проверьте записи: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Не сохранено. Список поддерживает не более {max} доменов.",
+ "BLACKLIST_STATUS_SAVED": "На этом устройстве сохранено скрытых доменов: {count}.",
+ "BLACKLIST_STATUS_DEFAULTS": "Список доменов по умолчанию восстановлен.",
"LABEL_AUTO_SYNC_NEXT": "Автосинхрон следующей серии",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Автоматически ставит на паузу и ждет всех при смене серии, затем запускает видео синхронно.",
"LABEL_AUTO_COPY_INVITE": "Автокопирование ссылки",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Назад",
"AUDIO_PAGE_TITLE": "Настройки звука",
"AUDIO_MASTER_TOGGLE": "Обработка звука",
+ "AUDIO_DEFAULT_BOOST": "Усиление по умолчанию",
+ "AUDIO_DEFAULT_BOOST_HELP": "Увеличивает громкость видео независимо от компрессора. Установите 0 дБ для отключения.",
+ "AUDIO_PARAM_BOOST": "Усиление",
"AUDIO_COMPRESSOR": "Компрессор",
"AUDIO_COMPRESSOR_ENABLE": "Включено",
"AUDIO_PRESET": "Пресет",
diff --git a/extension/locales/tr.json b/extension/locales/tr.json
index 0f32aca..5e6dca1 100644
--- a/extension/locales/tr.json
+++ b/extension/locales/tr.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Anonim Koala",
"LABEL_HIDE_CLUTTER": "Gereksiz Sekmeleri Gizle",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Listeyi temiz tutmak için video olmayan sekmeleri ve alakasız alan adlarını filtreler",
+ "BLACKLIST_EDIT": "Listeyi düzenle",
+ "BLACKLIST_EDITOR_LABEL": "Gizli alan adları",
+ "BLACKLIST_EDITOR_HELP": "Her satıra bir alan adı. URL'ler ana makine adına indirgenir. Yalnızca bu cihazda saklanır.",
+ "BLACKLIST_RESET": "Varsayılanları geri yükle",
+ "BLACKLIST_SAVE": "Listeyi kaydet",
+ "BLACKLIST_STATUS_INVALID": "Kaydedilmedi. Şu girdileri kontrol edin: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Kaydedilmedi. Liste en fazla {max} alan adını destekler.",
+ "BLACKLIST_STATUS_SAVED": "Bu cihazda {count} gizli alan adı kaydedildi.",
+ "BLACKLIST_STATUS_DEFAULTS": "Varsayılan alan adı listesi geri yüklendi.",
"LABEL_AUTO_SYNC_NEXT": "Sonraki Bölümü Otomatik Eşitle",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Bir bölüm değiştiğinde otomatik olarak duraklatılır ve tüm bağlantıların hazır olmasını bekler, ardından birlikte senkronize olarak başlar.",
"LABEL_AUTO_COPY_INVITE": "Davet Linkini Otomatik Kopyala",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Geri",
"AUDIO_PAGE_TITLE": "Ses ayarları",
"AUDIO_MASTER_TOGGLE": "Ses işleme",
+ "AUDIO_DEFAULT_BOOST": "Varsayılan ses artışı",
+ "AUDIO_DEFAULT_BOOST_HELP": "Video sesini kompresörden bağımsız olarak artırır. Devre dışı bırakmak için 0 dB ayarlayın.",
+ "AUDIO_PARAM_BOOST": "Ses artışı",
"AUDIO_COMPRESSOR": "Kompresör",
"AUDIO_COMPRESSOR_ENABLE": "Etkin",
"AUDIO_PRESET": "Ön ayar",
diff --git a/extension/locales/uk.json b/extension/locales/uk.json
index 2c5a918..af6b633 100644
--- a/extension/locales/uk.json
+++ b/extension/locales/uk.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "Анонімна коала",
"LABEL_HIDE_CLUTTER": "Приховати безладні вкладки",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Відфільтровує вкладки, не пов’язані з відео, і непов’язані домени, щоб зберегти список чистим",
+ "BLACKLIST_EDIT": "Редагувати список",
+ "BLACKLIST_EDITOR_LABEL": "Приховані домени",
+ "BLACKLIST_EDITOR_HELP": "Один домен у рядку. URL скорочуються до імені хоста. Зберігається лише на цьому пристрої.",
+ "BLACKLIST_RESET": "Відновити типові значення",
+ "BLACKLIST_SAVE": "Зберегти список",
+ "BLACKLIST_STATUS_INVALID": "Не збережено. Перевірте записи: {domains}",
+ "BLACKLIST_STATUS_LIMIT": "Не збережено. Список підтримує щонайбільше {max} доменів.",
+ "BLACKLIST_STATUS_SAVED": "На цьому пристрої збережено прихованих доменів: {count}.",
+ "BLACKLIST_STATUS_DEFAULTS": "Типовий список доменів відновлено.",
"LABEL_AUTO_SYNC_NEXT": "Автоматична синхронізація наступного епізоду",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Автоматично призупиняється та чекає на всіх однорангових пристроїв, коли епізод зміниться, а потім синхронізація починається разом.",
"LABEL_AUTO_COPY_INVITE": "Автоматичне копіювання посилання для запрошення",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← Назад",
"AUDIO_PAGE_TITLE": "Параметри звуку",
"AUDIO_MASTER_TOGGLE": "Обробка аудіо",
+ "AUDIO_DEFAULT_BOOST": "Типове підсилення",
+ "AUDIO_DEFAULT_BOOST_HELP": "Збільшує гучність відео незалежно від компресора. Установіть 0 дБ, щоб вимкнути.",
+ "AUDIO_PARAM_BOOST": "Підсилення",
"AUDIO_COMPRESSOR": "Компресор",
"AUDIO_COMPRESSOR_ENABLE": "Увімкнено",
"AUDIO_PRESET": "Попереднє налаштування",
diff --git a/extension/locales/zh.json b/extension/locales/zh.json
index c76220f..4508a91 100644
--- a/extension/locales/zh.json
+++ b/extension/locales/zh.json
@@ -96,6 +96,15 @@
"PLACEHOLDER_USERNAME": "无名考拉",
"LABEL_HIDE_CLUTTER": "隐藏杂乱标签",
"LABEL_HIDE_CLUTTER_TOOLTIP": "过滤掉非视频选项卡和不相关的域以保持列表干净",
+ "BLACKLIST_EDIT": "编辑列表",
+ "BLACKLIST_EDITOR_LABEL": "隐藏的域名",
+ "BLACKLIST_EDITOR_HELP": "每行一个域名。URL 会转换为主机名,并且仅保存在此设备上。",
+ "BLACKLIST_RESET": "恢复默认值",
+ "BLACKLIST_SAVE": "保存列表",
+ "BLACKLIST_STATUS_INVALID": "未保存。请检查这些条目:{domains}",
+ "BLACKLIST_STATUS_LIMIT": "未保存。列表最多支持 {max} 个域名。",
+ "BLACKLIST_STATUS_SAVED": "已在此设备上保存 {count} 个隐藏域名。",
+ "BLACKLIST_STATUS_DEFAULTS": "已恢复默认域名列表。",
"LABEL_AUTO_SYNC_NEXT": "自动同步下一集",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "当情节发生变化时,自动暂停并等待所有对等点,然后一起开始同步。",
"LABEL_AUTO_COPY_INVITE": "自动复制邀请链接",
@@ -215,6 +224,9 @@
"AUDIO_BACK": "← 返回",
"AUDIO_PAGE_TITLE": "音频设置",
"AUDIO_MASTER_TOGGLE": "音频处理",
+ "AUDIO_DEFAULT_BOOST": "默认增益",
+ "AUDIO_DEFAULT_BOOST_HELP": "独立于压缩器提高视频音量。设置为 0 dB 可关闭。",
+ "AUDIO_PARAM_BOOST": "增益",
"AUDIO_COMPRESSOR": "压缩器",
"AUDIO_COMPRESSOR_ENABLE": "启用",
"AUDIO_PRESET": "预设",
diff --git a/extension/popup.html b/extension/popup.html
index e816202..9ade1ea 100644
--- a/extension/popup.html
+++ b/extension/popup.html
@@ -1215,6 +1215,68 @@
line-height: 1.45;
text-align: left;
}
+ .settings-inline-actions {
+ width: 154px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ }
+ .settings-inline-actions .settings-action {
+ min-width: 0;
+ }
+ .settings-editor {
+ margin: 0 0 10px;
+ padding: 12px;
+ border: 1px solid var(--border-soft);
+ border-radius: 10px;
+ background: var(--surface-deep);
+ }
+ .settings-editor > label {
+ display: block;
+ margin: 0 0 4px;
+ color: var(--text);
+ font-size: 12px;
+ font-weight: 700;
+ }
+ .settings-editor textarea {
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 150px;
+ margin: 10px 0;
+ padding: 10px;
+ resize: vertical;
+ border: 1px solid var(--border-strong);
+ border-radius: 9px;
+ background: var(--bg);
+ color: var(--text);
+ font: 11px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
+ }
+ .settings-editor-actions {
+ display: flex;
+ gap: 8px;
+ }
+ .settings-editor-actions button {
+ flex: 1;
+ min-height: 36px;
+ margin: 0;
+ padding: 8px;
+ font-size: 11px;
+ }
+ .settings-editor-status {
+ margin: 8px 0 0;
+ font-size: 10px;
+ line-height: 1.4;
+ }
+ .settings-editor-status:empty {
+ display: none;
+ }
+ .settings-editor-status[data-state="success"] {
+ color: var(--accent);
+ }
+ .settings-editor-status[data-state="error"] {
+ color: var(--error);
+ }
[data-chat-setting] {
transition: opacity 0.2s;
}
@@ -1693,10 +1755,23 @@
+
+
+
One domain per line. URLs are reduced to their hostname. Stored only on this device.
+
+
+
+
+
+
diff --git a/extension/popup.js b/extension/popup.js
index cc023c3..28a4d42 100644
--- a/extension/popup.js
+++ b/extension/popup.js
@@ -1,5 +1,11 @@
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
-import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
+import {
+ CUSTOM_BLACKLIST_STORAGE_KEY,
+ MAX_BLACKLIST_DOMAINS,
+ getEffectiveBlacklistDomains,
+ isUrlBlacklisted,
+ parseBlacklistDomains
+} from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
@@ -43,6 +49,12 @@ const elements = {
roomInfo: document.getElementById('roomInfo'),
inviteLink: document.getElementById('inviteLink'),
filterNoise: document.getElementById('filterNoise'),
+ blacklistEdit: document.getElementById('blacklistEdit'),
+ blacklistEditor: document.getElementById('blacklistEditor'),
+ blacklistDomains: document.getElementById('blacklistDomains'),
+ blacklistSave: document.getElementById('blacklistSave'),
+ blacklistReset: document.getElementById('blacklistReset'),
+ blacklistStatus: document.getElementById('blacklistStatus'),
regenId: document.getElementById('regenId'),
restartTourBtn: document.getElementById('restartTourBtn'),
lastActionCard: document.getElementById('lastActionCard'),
@@ -343,7 +355,7 @@ function setRoomRefreshCooldown() {
async function init() {
// Local-only by design — settings and room credentials never come from
// storage.sync (only onboardingComplete + dismissedHints live there).
- const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
+ const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username', 'filterNoise', CUSTOM_BLACKLIST_STORAGE_KEY, 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
let activeLang = localData.locale;
if (!activeLang) {
@@ -373,6 +385,7 @@ async function init() {
elements.username.value = username;
syncDevToolsVisibility();
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
+ renderBlacklistEditor(localData[CUSTOM_BLACKLIST_STORAGE_KEY]);
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
if (elements.chatNotifications) elements.chatNotifications.checked = localData.chatNotifications !== false;
@@ -1115,11 +1128,57 @@ function detectPeerChanges(newPeers) {
lastKnownPeers = newPeers;
}
+function setBlacklistStatus(message = '', state = '') {
+ if (!elements.blacklistStatus) return;
+ elements.blacklistStatus.textContent = message;
+ elements.blacklistStatus.dataset.state = state;
+}
+
+function renderBlacklistEditor(storedDomains, preserveStatus = false) {
+ if (!elements.blacklistDomains) return;
+ const domains = getEffectiveBlacklistDomains(storedDomains);
+ elements.blacklistDomains.value = domains.join('\n');
+ if (!preserveStatus) setBlacklistStatus();
+}
+
+function setBlacklistEditorOpen(open) {
+ if (!elements.blacklistEditor || !elements.blacklistEdit) return;
+ elements.blacklistEditor.hidden = !open;
+ elements.blacklistEdit.setAttribute('aria-expanded', String(open));
+ if (open) elements.blacklistDomains?.focus();
+}
+
+async function saveBlacklistDomains() {
+ if (!elements.blacklistDomains) return;
+ const { domains, invalid } = parseBlacklistDomains(elements.blacklistDomains.value);
+ if (invalid.length > 0) {
+ setBlacklistStatus(getMessage('BLACKLIST_STATUS_INVALID', { domains: invalid.join(', ') }), 'error');
+ return;
+ }
+ if (domains.length > MAX_BLACKLIST_DOMAINS) {
+ setBlacklistStatus(getMessage('BLACKLIST_STATUS_LIMIT', { max: MAX_BLACKLIST_DOMAINS }), 'error');
+ return;
+ }
+
+ await chrome.storage.local.set({ [CUSTOM_BLACKLIST_STORAGE_KEY]: domains });
+ renderBlacklistEditor(domains);
+ setBlacklistStatus(getMessage('BLACKLIST_STATUS_SAVED', { count: domains.length }), 'success');
+ await populateTabs();
+}
+
+async function resetBlacklistDomains() {
+ await chrome.storage.local.remove(CUSTOM_BLACKLIST_STORAGE_KEY);
+ renderBlacklistEditor();
+ setBlacklistStatus(getMessage('BLACKLIST_STATUS_DEFAULTS'), 'success');
+ await populateTabs();
+}
+
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const token = {};
populateTabsToken = token;
- const data = await chrome.storage.local.get(['filterNoise']);
+ const data = await chrome.storage.local.get(['filterNoise', CUSTOM_BLACKLIST_STORAGE_KEY]);
+ const blacklistDomains = getEffectiveBlacklistDomains(data[CUSTOM_BLACKLIST_STORAGE_KEY]);
const isFilterActive = data.filterNoise !== false;
let currentTargetTabId = providedTargetTabId;
@@ -1161,18 +1220,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const filteredTabs = tabs.filter(tab => {
if (!tab.url || tab.url.startsWith('chrome://')) return false;
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
- const urlStr = tab.url.toLowerCase();
- if (BLACKLIST_DOMAINS.some(d => {
- const domain = d.toLowerCase();
- try {
- const hostname = new URL(tab.url).hostname.toLowerCase();
- if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain);
- if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain);
- } catch {
- /* ignore invalid URLs */
- }
- return urlStr.includes(domain);
- })) return false;
+ if (isUrlBlacklisted(tab.url, blacklistDomains)) return false;
}
return true;
});
@@ -1465,6 +1513,24 @@ elements.filterNoise.addEventListener('change', () => {
});
});
+if (elements.blacklistEdit) {
+ elements.blacklistEdit.addEventListener('click', () => {
+ setBlacklistEditorOpen(elements.blacklistEditor?.hidden !== false);
+ });
+}
+
+if (elements.blacklistSave) {
+ elements.blacklistSave.addEventListener('click', () => {
+ saveBlacklistDomains().catch(error => setBlacklistStatus(error.message, 'error'));
+ });
+}
+
+if (elements.blacklistReset) {
+ elements.blacklistReset.addEventListener('click', () => {
+ resetBlacklistDomains().catch(error => setBlacklistStatus(error.message, 'error'));
+ });
+}
+
elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
});
@@ -1586,7 +1652,9 @@ if (elements.audioSettingsLink) {
}
chrome.storage.onChanged.addListener((changes, area) => {
- if (area !== 'sync' || !changes.audioSettings) return;
+ if (area !== 'local' || !changes[CUSTOM_BLACKLIST_STORAGE_KEY]) return;
+ renderBlacklistEditor(changes[CUSTOM_BLACKLIST_STORAGE_KEY].newValue, true);
+ populateTabs();
});
elements.forceSyncMode.addEventListener('change', () => {
diff --git a/scripts/test-audio-settings.mjs b/scripts/test-audio-settings.mjs
index 1b5c473..a23b1fa 100644
--- a/scripts/test-audio-settings.mjs
+++ b/scripts/test-audio-settings.mjs
@@ -8,6 +8,8 @@ import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const sourcePath = path.join(repoRoot, 'extension/audio-options.js');
+const contentPath = path.join(repoRoot, 'extension/content.js');
+const htmlPath = path.join(repoRoot, 'extension/audio-options.html');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import { loadLocale, translateDOM, getSystemLanguage } from './i18n.js';", '')
.replace(/init\(\)\.catch[\s\S]*?;\n?$/, '');
@@ -61,7 +63,11 @@ const sandbox = {
},
document: {
getElementById: () => makeInput(),
- querySelectorAll: (selector) => selector === '.control-row' ? rows : [makeInput({ value: 'recommended' })]
+ querySelectorAll: (selector) => selector === '.control-row[data-param]' ? rows : [makeInput({ value: 'recommended' })]
+ },
+ window: {
+ addEventListener: () => {},
+ close: () => {}
},
setTimeout,
clearTimeout
@@ -71,7 +77,9 @@ vm.createContext(sandbox);
vm.runInContext(`${source}
globalThis.__audioSettingsTest = {
mergeAudioSettings,
+ normalizeBoostDb,
getParamValue,
+ setBoostDb,
setCustomParam,
get currentSettings() { return currentSettings; }
};`, sandbox, { filename: sourcePath });
@@ -80,6 +88,11 @@ const helpers = sandbox.__audioSettingsTest;
assert.doesNotThrow(() => helpers.mergeAudioSettings(null), 'mergeAudioSettings tolerates null storage values');
assert.doesNotThrow(() => helpers.mergeAudioSettings('bad'), 'mergeAudioSettings tolerates non-object storage values');
+assert.equal(helpers.normalizeBoostDb(-5), 0, 'boost clamps to 0 dB minimum');
+assert.equal(helpers.normalizeBoostDb(99), 20, 'boost clamps to 20 dB maximum');
+assert.equal(helpers.normalizeBoostDb(7.26), 7.5, 'boost rounds to half-decibel steps');
+assert.equal(helpers.normalizeBoostDb('bad'), 0, 'invalid boost falls back to 0 dB');
+assert.equal(helpers.mergeAudioSettings({ boostDb: 8 }).boostDb, 8, 'boost persists independently of compressor');
assert.equal(helpers.getParamValue('threshold', '-999'), -60, 'threshold clamps to minimum');
assert.equal(helpers.getParamValue('threshold', '999'), 0, 'threshold clamps to maximum');
@@ -95,4 +108,24 @@ assert.equal(helpers.getParamValue('release', '5000', true), 1, 'release ms inpu
helpers.setCustomParam('threshold', 999);
assert.equal(helpers.currentSettings.compressor.customParams.threshold, 0, 'setCustomParam stores clamped values');
+helpers.setBoostDb(6);
+assert.equal(helpers.currentSettings.boostDb, 6, 'setBoostDb stores the normalized boost');
+assert.equal(helpers.currentSettings.enabled, true, 'positive boost enables audio processing');
+helpers.setBoostDb(0);
+assert.equal(helpers.currentSettings.enabled, false, 'zero boost disables processing when compressor is off');
+
+const contentSource = fs.readFileSync(contentPath, 'utf8');
+assert.match(contentSource, /const outputGain = ctx\.createGain\(\)/, 'content chain creates a shared output gain');
+assert.match(contentSource, /const limiter = ctx\.createDynamicsCompressor\(\)/, 'content chain creates a post-boost limiter');
+assert.match(contentSource, /outputGain\.connect\(limiter\)/, 'boost output feeds the limiter');
+assert.match(contentSource, /limiter\.connect\(ctx\.destination\)/, 'limiter feeds the audio destination');
+assert.match(contentSource, /chain\.limiter\.threshold\.setValueAtTime\(0, t\)/, 'audio bypass resets the limiter ceiling');
+assert.match(contentSource, /Math\.pow\(10, boostDb \/ 20\)/, 'content chain converts decibels to linear gain');
+assert.match(contentSource, /changes\.audioSettings\.newValue/, 'content updates active video when local audio settings change');
+assert.match(source, /querySelectorAll\('\.control-row\[data-param\]'\)/, 'boost row is excluded from compressor parameter handling');
+assert.match(source, /await flushPendingSave\(\);[\s\S]*?window\.close\(\)/, 'back navigation flushes the final audio setting');
+
+const htmlSource = fs.readFileSync(htmlPath, 'utf8');
+assert.match(htmlSource, /id="boostRange"[^>]+max="20"[^>]+step="0\.5"/, 'audio UI exposes a bounded half-decibel boost slider');
+
console.log('audio settings tests passed');
diff --git a/scripts/test-blacklist-settings.mjs b/scripts/test-blacklist-settings.mjs
new file mode 100644
index 0000000..770e142
--- /dev/null
+++ b/scripts/test-blacklist-settings.mjs
@@ -0,0 +1,46 @@
+#!/usr/bin/env node
+
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ BLACKLIST_DOMAINS,
+ CUSTOM_BLACKLIST_STORAGE_KEY,
+ getEffectiveBlacklistDomains,
+ isUrlBlacklisted,
+ normalizeBlacklistDomain,
+ parseBlacklistDomains
+} from '../shared/blacklist.js';
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+assert.equal(CUSTOM_BLACKLIST_STORAGE_KEY, 'customBlacklistDomains');
+assert.equal(normalizeBlacklistDomain(' Example.COM. '), 'example.com');
+assert.equal(normalizeBlacklistDomain('https://Video.Example.com/watch/123'), 'video.example.com');
+assert.equal(normalizeBlacklistDomain('*.example.com'), null, 'wildcards are rejected');
+assert.equal(normalizeBlacklistDomain('not a domain'), null, 'spaces are rejected');
+
+const parsed = parseBlacklistDomains('Example.com\nhttps://sub.example.com/path\nexample.com\n');
+assert.deepEqual(parsed.domains, ['example.com', 'sub.example.com'], 'domains are normalized and deduplicated');
+assert.deepEqual(parsed.invalid, []);
+
+const invalid = parseBlacklistDomains('example.com\nnot a domain');
+assert.deepEqual(invalid.invalid, ['not a domain'], 'invalid entries are reported without partial silent saves');
+
+assert.deepEqual(getEffectiveBlacklistDomains(undefined), BLACKLIST_DOMAINS, 'missing local setting uses shipped defaults');
+assert.deepEqual(getEffectiveBlacklistDomains([]), [], 'an explicitly empty local list stays empty');
+assert.equal(isUrlBlacklisted('https://mail.google.com/inbox', ['google.com']), true, 'subdomains match a parent domain');
+assert.equal(isUrlBlacklisted('https://notgoogle.com/', ['google.com']), false, 'lookalike domains do not match');
+assert.equal(isUrlBlacklisted('not a url', ['example.com']), false, 'invalid URLs are ignored');
+
+const popupSource = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
+assert.match(popupSource, /chrome\.storage\.local\.set\(\{ \[CUSTOM_BLACKLIST_STORAGE_KEY\]: domains \}\)/, 'custom list is saved locally');
+assert.doesNotMatch(popupSource, /chrome\.storage\.sync\.set\(\{ \[CUSTOM_BLACKLIST_STORAGE_KEY\]/, 'custom list is never synced');
+assert.match(popupSource, /isUrlBlacklisted\(tab\.url, blacklistDomains\)/, 'tab filtering uses the effective custom list');
+
+const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
+assert.match(popupHtml, /id="blacklistDomains"/, 'settings UI contains the editable domain list');
+assert.match(popupHtml, /id="blacklistReset"/, 'settings UI contains a defaults reset');
+
+console.log('blacklist settings tests passed');
diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs
index f357268..db3d515 100644
--- a/scripts/verify-release.mjs
+++ b/scripts/verify-release.mjs
@@ -19,6 +19,7 @@ const checks = [
['names generator', 'node', ['scripts/test-names.mjs']],
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
+ ['blacklist settings', 'node', ['scripts/test-blacklist-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
['chat settings', 'node', ['scripts/test-chat-settings.mjs']],
['host access recovery', 'node', ['scripts/test-host-access.mjs']],
diff --git a/shared/blacklist.js b/shared/blacklist.js
index 816ee74..c872508 100644
--- a/shared/blacklist.js
+++ b/shared/blacklist.js
@@ -178,3 +178,73 @@ export const BLACKLIST_DOMAINS = [
'lichess.org',
'skribbl.io'
];
+
+export const CUSTOM_BLACKLIST_STORAGE_KEY = 'customBlacklistDomains';
+export const MAX_BLACKLIST_DOMAINS = 500;
+
+/**
+ * Normalize a user-entered domain or URL to a hostname.
+ * Returns null when the value cannot safely be used as a hostname filter.
+ */
+export function normalizeBlacklistDomain(value) {
+ if (typeof value !== 'string') return null;
+ const input = value.trim().toLowerCase();
+ if (!input) return '';
+
+ try {
+ const parsed = new URL(input.includes('://') ? input : `https://${input}`);
+ const hostname = parsed.hostname.toLowerCase().replace(/^\.+|\.+$/g, '');
+ if (!hostname || hostname.length > 253) return null;
+
+ const labels = hostname.split('.');
+ const valid = labels.every(label => (
+ label.length > 0
+ && label.length <= 63
+ && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
+ ));
+ return valid ? hostname : null;
+ } catch {
+ return null;
+ }
+}
+
+export function parseBlacklistDomains(value) {
+ const entries = Array.isArray(value)
+ ? value
+ : String(value ?? '').split(/\r?\n/);
+ const domains = [];
+ const invalid = [];
+ const seen = new Set();
+
+ for (const entry of entries) {
+ const normalized = normalizeBlacklistDomain(entry);
+ if (normalized === '') continue;
+ if (normalized === null) {
+ invalid.push(String(entry).trim());
+ continue;
+ }
+ if (!seen.has(normalized)) {
+ seen.add(normalized);
+ domains.push(normalized);
+ }
+ }
+
+ return { domains, invalid };
+}
+
+export function getEffectiveBlacklistDomains(storedDomains) {
+ if (!Array.isArray(storedDomains)) return [...BLACKLIST_DOMAINS];
+ return parseBlacklistDomains(storedDomains).domains.slice(0, MAX_BLACKLIST_DOMAINS);
+}
+
+export function isUrlBlacklisted(rawUrl, domains = BLACKLIST_DOMAINS) {
+ if (typeof rawUrl !== 'string' || !rawUrl) return false;
+ let hostname;
+ try {
+ hostname = new URL(rawUrl).hostname.toLowerCase().replace(/\.$/, '');
+ } catch {
+ return false;
+ }
+
+ return domains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`));
+}