mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-12 12:37:10 +00:00
feat:local-blacklist-and-audio-boost
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -23,6 +23,23 @@
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 data-i18n="AUDIO_DEFAULT_BOOST">Default Boost</h2>
|
||||
<p class="section-copy" data-i18n="AUDIO_DEFAULT_BOOST_HELP">Raises video volume independently of the compressor. Set 0 dB to disable.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-grid">
|
||||
<div class="control-row boost-control">
|
||||
<label for="boostRange" data-i18n="AUDIO_PARAM_BOOST">Boost</label>
|
||||
<input id="boostRange" type="range" min="0" max="20" step="0.5" value="0">
|
||||
<input id="boostNumber" type="number" min="0" max="20" step="0.5" value="0">
|
||||
<span class="unit">dB</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-heading">
|
||||
<h2 data-i18n="AUDIO_COMPRESSOR">Compressor</h2>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
+90
-54
@@ -250,6 +250,11 @@
|
||||
!!window.koalaFindPageApiSeekProvider(window.location.hostname);
|
||||
}
|
||||
|
||||
function seekVideo(video, targetTime) {
|
||||
// Prefer a precise page-level seek API when available (Netflix, Disney+);
|
||||
// for those players the DOM/button seek path is imprecise or impossible.
|
||||
if (shouldUsePageApiSeek()) {
|
||||
expectedSeekTime = targetTime;
|
||||
window.postMessage({ __koalaPageApiSeek: PAGE_API_SEEK_BRIDGE, kind: 'seek', time: targetTime }, '*');
|
||||
return;
|
||||
}
|
||||
@@ -605,24 +610,32 @@
|
||||
|
||||
if (ready || Date.now() >= deadline) {
|
||||
|
||||
hcmDeferredSnapPending = false;
|
||||
|
||||
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
|
||||
hcmDeferredSnapPending = false;
|
||||
|
||||
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
scheduleLifecycleTimeout(poll, 300);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
poll();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Entry point: background told us our local action was blocked in host-only.
|
||||
|
||||
function hcmHandleBlocked(action, target) {
|
||||
|
||||
|
||||
|
||||
|
||||
// Entry point: background told us our local action was blocked in host-only.
|
||||
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
|
||||
|
||||
// host-only + !host before sending), so it's authoritative. Adopt the
|
||||
|
||||
// role/mode from it in case our CONTROL_MODE broadcast hasn't landed yet
|
||||
|
||||
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
|
||||
|
||||
@@ -665,21 +678,31 @@
|
||||
// deferred and "Stay in sync" paths).
|
||||
|
||||
if (target && Number.isFinite(target.targetTime)) hcmSnapBackToHost(target);
|
||||
|
||||
// usable, otherwise re-query+retry (host state may not be known yet)
|
||||
|
||||
// so we never leave the guest silently stuck (consistent with the
|
||||
|
||||
// deferred and "Stay in sync" paths).
|
||||
|
||||
if (target && Number.isFinite(target.targetTime)) hcmSnapBackToHost(target);
|
||||
|
||||
else hcmRequestHostSyncWithRetry();
|
||||
|
||||
} else {
|
||||
|
||||
hcmDeferredSnapBack(); // buffering → wait for ready, then snap once (#3)
|
||||
|
||||
|
||||
else hcmRequestHostSyncWithRetry();
|
||||
|
||||
} else {
|
||||
|
||||
hcmDeferredSnapBack(); // buffering → wait for ready, then snap once (#3)
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
// Deliberate: offer the choice (Teleparty-style), default = snap back.
|
||||
|
||||
hcmShowDesyncDialog(action, target);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- In-page UI (dialog + persistent desync badge) ---
|
||||
|
||||
// Built with the DOM API (CSSOM .style is CSP-safe; inline style="" in innerHTML
|
||||
|
||||
// is stripped by strict style-src on Netflix/YouTube/Disney+). Hosted in a
|
||||
|
||||
// Shadow DOM so the page's CSS can't restyle or hide our controls.
|
||||
@@ -699,46 +722,59 @@
|
||||
if (css) el.style.cssText = css; // CSSOM assignment — not gated by CSP
|
||||
|
||||
if (text != null) el.textContent = text;
|
||||
|
||||
// Shadow DOM so the page's CSS can't restyle or hide our controls.
|
||||
|
||||
let hcmDialogHost = null; // shadow host element for the dialog
|
||||
|
||||
|
||||
return el;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function hcmRemoveDialog() {
|
||||
|
||||
// Cancel any pending auto-stay timer so a replaced dialog's stale closure
|
||||
|
||||
// can't later remove its successor / snap to an outdated target (H-4).
|
||||
|
||||
if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; }
|
||||
function hcmEl(tag, css, text) {
|
||||
|
||||
const el = document.createElement(tag);
|
||||
|
||||
if (css) el.style.cssText = css; // CSSOM assignment — not gated by CSP
|
||||
|
||||
|
||||
if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function hcmShowDesyncDialog(action, target) {
|
||||
|
||||
if (!document.body) { hcmSnapBackToHost(target); return; }
|
||||
|
||||
hcmRemoveDialog();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function hcmRemoveDialog() {
|
||||
|
||||
// Cancel any pending auto-stay timer so a replaced dialog's stale closure
|
||||
|
||||
// can't later remove its successor / snap to an outdated target (H-4).
|
||||
|
||||
if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; }
|
||||
|
||||
if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function hcmShowDesyncDialog(action, target) {
|
||||
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
|
||||
|
||||
|
||||
const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#212a17;color:#f4f2ea;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #2f3625');
|
||||
|
||||
wrap.setAttribute('role', 'dialog');
|
||||
|
||||
const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title);
|
||||
|
||||
const body = hcmEl('div', 'margin-bottom:12px;color:#bcb7a9', hcmStrings.body);
|
||||
|
||||
const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end');
|
||||
|
||||
const soloBtn = hcmEl('button', 'background:#2f3625;color:#f4f2ea;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo);
|
||||
|
||||
const stayBtn = hcmEl('button', 'background:#56ae6c;color:#0a1a0d;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay);
|
||||
|
||||
btnRow.append(soloBtn, stayBtn);
|
||||
|
||||
wrap.append(title, body, btnRow);
|
||||
|
||||
root.appendChild(wrap);
|
||||
|
||||
document.body.appendChild(host);
|
||||
|
||||
hcmDialogHost = host;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プリセット",
|
||||
|
||||
@@ -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": "프리셋",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Пресет",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Попереднє налаштування",
|
||||
|
||||
@@ -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": "预设",
|
||||
|
||||
+79
-4
@@ -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 @@
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label for="filterNoise" title="Filters out non-video tabs and unrelated domains to keep the list clean" data-i18n="LABEL_HIDE_CLUTTER" data-i18n-title="LABEL_HIDE_CLUTTER_TOOLTIP">Hide Clutter Tabs</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="filterNoise" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<div class="settings-inline-actions">
|
||||
<button type="button" id="blacklistEdit" class="settings-action" aria-expanded="false" aria-controls="blacklistEditor" data-i18n="BLACKLIST_EDIT">Edit list</button>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="filterNoise" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="blacklistEditor" class="settings-editor" hidden>
|
||||
<label for="blacklistDomains" data-i18n="BLACKLIST_EDITOR_LABEL">Hidden domains</label>
|
||||
<p class="settings-note" data-i18n="BLACKLIST_EDITOR_HELP">One domain per line. URLs are reduced to their hostname. Stored only on this device.</p>
|
||||
<textarea id="blacklistDomains" rows="8" maxlength="20000" spellcheck="false"></textarea>
|
||||
<div class="settings-editor-actions">
|
||||
<button type="button" id="blacklistReset" class="secondary" data-i18n="BLACKLIST_RESET">Restore defaults</button>
|
||||
<button type="button" id="blacklistSave" class="primary" data-i18n="BLACKLIST_SAVE">Save list</button>
|
||||
</div>
|
||||
<p id="blacklistStatus" class="settings-editor-status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label for="autoCopyInvite" title="Automatically copies the invite link to your clipboard when creating a new room." data-i18n="LABEL_AUTO_COPY_INVITE" data-i18n-title="LABEL_AUTO_COPY_INVITE_TOOLTIP">Auto-Copy Invite Link</label>
|
||||
|
||||
+84
-16
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
@@ -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']],
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user