From 48d6c1dc0bcbc8712e24382c2f125ba518e62bac Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:07:34 +0200 Subject: [PATCH] feat:local-blacklist-and-audio-boost --- docs/CHANGELOG.md | 6 ++ docs/ROADMAP.md | 18 ---- extension/audio-options.css | 8 ++ extension/audio-options.html | 17 ++++ extension/audio-options.js | 58 ++++++++++- extension/background.js | 2 +- extension/content.js | 144 +++++++++++++++++----------- extension/locales/de.json | 12 +++ extension/locales/en.json | 12 +++ extension/locales/es.json | 12 +++ extension/locales/fr.json | 12 +++ extension/locales/it.json | 12 +++ extension/locales/ja.json | 12 +++ extension/locales/ko.json | 12 +++ extension/locales/nl.json | 12 +++ extension/locales/pl.json | 12 +++ extension/locales/pt-BR.json | 12 +++ extension/locales/pt.json | 12 +++ extension/locales/ru.json | 12 +++ extension/locales/tr.json | 12 +++ extension/locales/uk.json | 12 +++ extension/locales/zh.json | 12 +++ extension/popup.html | 83 +++++++++++++++- extension/popup.js | 100 +++++++++++++++---- scripts/test-audio-settings.mjs | 35 ++++++- scripts/test-blacklist-settings.mjs | 46 +++++++++ scripts/verify-release.mjs | 1 + shared/blacklist.js | 70 ++++++++++++++ 28 files changed, 670 insertions(+), 98 deletions(-) create mode 100644 scripts/test-blacklist-settings.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 85eb40a..8edf86f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## Unreleased + +### Added +- **Extension: Editable Hide-Clutter list** — Adds a validated, deduplicated domain editor in Settings, prefilled with the shipped blacklist, with a defaults reset and per-device persistence in `chrome.storage.local`. +- **Extension: Independent default audio boost** — Adds a configurable `0–20 dB` output gain in half-decibel steps. The boost works with or without the compressor and applies live to the selected video tab. + ## [v3.0.2] — 2026-07-31 This release adds three focused chat improvements: encrypted quick-reaction emojis, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a9744a2..5cd8aba 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,24 +30,6 @@ *Prioritized for upcoming phases.* -### Customizable Hide-Clutter Tab List - -- **Priority:** P2 -- **Category:** UX / Personalization -- **Background:** The Hide Clutter tabs feature already has a blacklist. Users should be - able to extend that list with tabs they frequently keep open but never want included. -- **Planned behavior:** Provide a custom editable list, prefilled with the existing - blacklist, so users can add and manage their own entries. - -### Default Audio Boost - -- **Priority:** P2 -- **Category:** Audio / Playback -- **Background:** Extend the audio settings with a configurable default boost that raises - the level by a chosen amount in dB. -- **Planned behavior:** Apply the default gain in addition to the existing compressor, - with the boost controlled independently in the audio settings. - ### Invite link with target URL for auto-redirect - **Priority:** P2 diff --git a/extension/audio-options.css b/extension/audio-options.css index 8c3be91..9030c19 100644 --- a/extension/audio-options.css +++ b/extension/audio-options.css @@ -107,6 +107,14 @@ h2 { font-size: 16px; } +.section-copy { + max-width: 560px; + margin: 6px 0 0; + color: var(--text-muted); + font-size: 12px; + line-height: 1.5; +} + .panel { background: var(--card); border: 1px solid var(--border); diff --git a/extension/audio-options.html b/extension/audio-options.html index 86181c8..5eea660 100644 --- a/extension/audio-options.html +++ b/extension/audio-options.html @@ -23,6 +23,23 @@ +
+
+
+

Default Boost

+

Raises video volume independently of the compressor. Set 0 dB to disable.

+
+
+
+
+ + + + dB +
+
+
+

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 @@
- +
+ + +
+
+
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}`)); +}