diff --git a/README.md b/README.md
index ea85d4b..3b3615e 100644
--- a/README.md
+++ b/README.md
@@ -121,5 +121,6 @@ Both the official KoalaSync website and the **v2.0 Browser Extension** feature f
+
+
+
+
+
+
+
Equalizer
+
+ Coming soon
+
+
+
+
+
+
diff --git a/extension/audio-options.js b/extension/audio-options.js
new file mode 100644
index 0000000..8fd2cea
--- /dev/null
+++ b/extension/audio-options.js
@@ -0,0 +1,183 @@
+import { loadLocale, translateDOM, getSystemLanguage } from './i18n.js';
+
+const PRESETS = {
+ recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 },
+ dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 },
+ vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 },
+ 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: {
+ enabled: false,
+ preset: 'recommended',
+ customParams: { ...PRESETS.custom }
+ }
+};
+const PARAM_LIMITS = {
+ threshold: { min: -60, max: 0 },
+ knee: { min: 0, max: 40 },
+ ratio: { min: 1, max: 20 },
+ attack: { min: 0, max: 1 },
+ release: { min: 0, max: 1 }
+};
+
+const elements = {
+ audioEnabled: document.getElementById('audioEnabled'),
+ compressorEnabled: document.getElementById('compressorEnabled'),
+ presetInputs: Array.from(document.querySelectorAll('input[name="preset"]')),
+ controlRows: Array.from(document.querySelectorAll('.control-row'))
+};
+
+let saveTimer = null;
+let isRendering = false;
+
+function cloneDefaultSettings() {
+ return JSON.parse(JSON.stringify(DEFAULT_AUDIO_SETTINGS));
+}
+
+let currentSettings = cloneDefaultSettings();
+
+function mergeAudioSettings(settings = {}) {
+ const safeSettings = settings && typeof settings === 'object' ? settings : {};
+ const defaults = cloneDefaultSettings();
+ return {
+ ...defaults,
+ ...safeSettings,
+ compressor: {
+ ...defaults.compressor,
+ ...(safeSettings.compressor || {}),
+ customParams: {
+ ...defaults.compressor.customParams,
+ ...(safeSettings.compressor?.customParams || {})
+ }
+ }
+ };
+}
+
+function debounceSave() {
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = setTimeout(() => {
+ chrome.storage.sync.set({ audioSettings: currentSettings });
+ }, 40);
+}
+
+function getParamValue(param, value, isMsInput = false) {
+ const parsed = Number(value);
+ const candidate = Number.isFinite(parsed)
+ ? (isMsInput ? parsed / 1000 : parsed)
+ : currentSettings.compressor.customParams[param];
+ const limits = PARAM_LIMITS[param];
+ if (!limits) return candidate;
+ return Math.min(limits.max, Math.max(limits.min, candidate));
+}
+
+function formatNumber(value, param, isMsInput = false) {
+ if (isMsInput) return Math.round(value * 1000);
+ if (param === 'ratio') return Number(value).toFixed(1).replace(/\.0$/, '');
+ return value;
+}
+
+function render() {
+ isRendering = true;
+ elements.audioEnabled.checked = currentSettings.enabled === true;
+ elements.compressorEnabled.checked = currentSettings.compressor.enabled === true;
+
+ const selectedPreset = currentSettings.compressor.preset || 'recommended';
+ elements.presetInputs.forEach(input => {
+ input.checked = input.value === selectedPreset;
+ });
+
+ const params = selectedPreset === 'custom'
+ ? currentSettings.compressor.customParams
+ : PRESETS[selectedPreset] || PRESETS.recommended;
+
+ elements.controlRows.forEach(row => {
+ const param = row.dataset.param;
+ const range = row.querySelector('input[type="range"]');
+ const number = row.querySelector('input[type="number"]');
+ const value = params[param];
+ range.value = value;
+ number.value = formatNumber(value, param, number.dataset.msInput === 'true');
+ });
+ isRendering = false;
+}
+
+function setPreset(preset) {
+ currentSettings.compressor.preset = preset;
+ if (preset === 'custom') {
+ currentSettings.compressor.customParams = {
+ ...PRESETS.custom,
+ ...currentSettings.compressor.customParams
+ };
+ }
+ render();
+ debounceSave();
+}
+
+function setCustomParam(param, value) {
+ currentSettings.compressor.preset = 'custom';
+ currentSettings.compressor.customParams[param] = getParamValue(param, value);
+ render();
+ debounceSave();
+}
+
+async function init() {
+ const syncData = await chrome.storage.sync.get(['audioSettings', 'locale']);
+ const lang = syncData.locale || getSystemLanguage();
+ await loadLocale(lang);
+ translateDOM();
+
+ currentSettings = mergeAudioSettings(syncData.audioSettings);
+ render();
+}
+
+elements.audioEnabled.addEventListener('change', () => {
+ currentSettings.enabled = elements.audioEnabled.checked;
+ if (currentSettings.enabled && !currentSettings.compressor.enabled) {
+ currentSettings.compressor.enabled = true;
+ }
+ render();
+ debounceSave();
+});
+
+elements.compressorEnabled.addEventListener('change', () => {
+ currentSettings.compressor.enabled = elements.compressorEnabled.checked;
+ if (currentSettings.compressor.enabled) currentSettings.enabled = true;
+ render();
+ debounceSave();
+});
+
+elements.presetInputs.forEach(input => {
+ input.addEventListener('change', () => {
+ if (input.checked) setPreset(input.value);
+ });
+});
+
+elements.controlRows.forEach(row => {
+ const param = row.dataset.param;
+ const range = row.querySelector('input[type="range"]');
+ const number = row.querySelector('input[type="number"]');
+
+ range.addEventListener('input', () => {
+ if (isRendering) return;
+ setCustomParam(param, getParamValue(param, range.value));
+ });
+
+ number.addEventListener('input', () => {
+ if (isRendering) return;
+ setCustomParam(param, getParamValue(param, number.value, number.dataset.msInput === 'true'));
+ });
+});
+
+chrome.storage.onChanged.addListener((changes, area) => {
+ if (area !== 'sync' || !changes.audioSettings) return;
+ currentSettings = mergeAudioSettings(changes.audioSettings.newValue);
+ render();
+});
+
+init().catch(err => {
+ console.error('[AudioOptions] Failed to initialize:', err);
+});
diff --git a/extension/background.js b/extension/background.js
index 1e372c1..547f270 100644
--- a/extension/background.js
+++ b/extension/background.js
@@ -1292,6 +1292,20 @@ function leaveOldRoomIfSwitching(newRoomId) {
}
}
+function resetAudioProcessingInTab(tabId) {
+ if (!tabId) return;
+ chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
+}
+
+async function applyAudioSettingsToTab(tabId) {
+ if (!tabId) return;
+ const data = await chrome.storage.sync.get(['audioSettings']);
+ chrome.tabs.sendMessage(tabId, {
+ action: 'APPLY_AUDIO_SETTINGS',
+ settings: data.audioSettings
+ }).catch(() => {});
+}
+
// --- Extension Message Listeners ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
handleAsyncMessage(message, sender, sendResponse);
@@ -1354,6 +1368,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
roomPassword: currentRoom ? currentRoom.password : null
});
} else if (message.type === 'LEAVE_ROOM') {
+ resetAudioProcessingInTab(currentTabId);
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
currentTabId = null;
@@ -1599,6 +1614,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' });
});
} else if (message.type === 'SET_TARGET_TAB') {
+ const previousTabId = currentTabId;
currentTabId = message.tabId;
currentTabTitle = message.tabTitle;
lastContentHeartbeatAt = null;
@@ -1607,14 +1623,21 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt });
updateBadgeStatus();
+
+ if (previousTabId && previousTabId !== currentTabId) {
+ resetAudioProcessingInTab(previousTabId);
+ }
if (currentTabId) {
+ const selectedTabId = currentTabId;
chrome.scripting.executeScript({
- target: { tabId: currentTabId },
+ target: { tabId: selectedTabId },
files: ['content.js']
- }).catch(err => {
- addLog(`Failed to inject into tab: ${err.message}`, 'warn');
- });
+ })
+ .then(() => applyAudioSettingsToTab(selectedTabId))
+ .catch(err => {
+ addLog(`Failed to inject into tab: ${err.message}`, 'warn');
+ });
}
sendResponse({ status: 'ok' });
@@ -1724,6 +1747,17 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
}
+chrome.storage.onChanged.addListener(async (changes, area) => {
+ if (area !== 'sync' || !changes.audioSettings) return;
+ await ensureState();
+ if (!currentTabId) return;
+
+ chrome.tabs.sendMessage(currentTabId, {
+ action: 'APPLY_AUDIO_SETTINGS',
+ settings: changes.audioSettings.newValue
+ }).catch(() => {});
+});
+
// Tab removal listener
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
@@ -1775,7 +1809,9 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
- }).catch(() => {});
+ })
+ .then(() => applyAudioSettingsToTab(tabId))
+ .catch(() => {});
}
});
diff --git a/extension/content.js b/extension/content.js
index 8ef655c..f4c9021 100644
--- a/extension/content.js
+++ b/extension/content.js
@@ -62,10 +62,15 @@
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let lobbyPollTimer = null;
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
+ let _audioSettings = null;
+ let _audioProcessingAllowed = true;
// Cache the autoSyncNextEpisode setting
- chrome.storage.sync.get(['autoSyncNextEpisode'], (data) => {
+ chrome.storage.sync.get(['autoSyncNextEpisode', 'audioSettings'], (data) => {
_autoSyncEnabled = data.autoSyncNextEpisode !== false; // default: enabled
+ _audioSettings = mergeAudioSettings(data.audioSettings);
+ const video = findVideo();
+ if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && changes.autoSyncNextEpisode) {
@@ -114,6 +119,147 @@
return best;
}
+ // --- Audio Processing Module ---
+ const AUDIO_PRESETS = {
+ recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 },
+ dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 },
+ vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 },
+ 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: {
+ enabled: false,
+ preset: 'recommended',
+ customParams: { ...AUDIO_PRESETS.custom }
+ }
+ };
+ let audioCtx = null;
+ let audioChains = new WeakMap();
+ let currentAudioVideo = null;
+
+ function mergeAudioSettings(settings = {}) {
+ const safeSettings = settings && typeof settings === 'object' ? settings : {};
+ return {
+ ...DEFAULT_AUDIO_SETTINGS,
+ ...safeSettings,
+ compressor: {
+ ...DEFAULT_AUDIO_SETTINGS.compressor,
+ ...(safeSettings.compressor || {}),
+ customParams: {
+ ...DEFAULT_AUDIO_SETTINGS.compressor.customParams,
+ ...(safeSettings.compressor?.customParams || {})
+ }
+ }
+ };
+ }
+
+ function initAudioContext() {
+ if (!audioCtx) {
+ try {
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
+ if (!AudioContextClass) return null;
+ audioCtx = new AudioContextClass({ latencyHint: 'interactive' });
+ } catch (e) {
+ reportLog(`Audio Processing unavailable: ${e.message}`, 'warn');
+ return null;
+ }
+ }
+ if (audioCtx.state === 'suspended') {
+ audioCtx.resume().catch(() => {});
+ }
+ return audioCtx;
+ }
+
+ function closeAudioContext() {
+ if (audioCtx) {
+ audioCtx.close().catch(() => {});
+ audioCtx = null;
+ }
+ audioChains = new WeakMap();
+ currentAudioVideo = null;
+ }
+
+ function setupAudioChain(videoEl) {
+ if (audioChains.has(videoEl)) return audioChains.get(videoEl);
+ const ctx = initAudioContext();
+ 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 };
+ audioChains.set(videoEl, chain);
+ currentAudioVideo = videoEl;
+ return chain;
+ } catch (e) {
+ reportLog(`Audio Processing setup failed: ${e.message}`, 'warn');
+ return null;
+ }
+ }
+
+ function rampGain(node, value, t) {
+ const current = node.gain.value;
+ node.gain.cancelScheduledValues(t);
+ node.gain.setValueAtTime(current, t);
+ node.gain.linearRampToValueAtTime(value, t + 0.04);
+ }
+
+ 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;
+ }
+
+ function bypassCurrentAudioProcessing() {
+ if (currentAudioVideo) applyAudioBypass(currentAudioVideo);
+ }
+
+ function applyAudioSettings(videoEl, settings) {
+ const mergedSettings = mergeAudioSettings(settings);
+ if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) {
+ 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;
+ }
+ }
+
// --- Episode Auto-Sync: Detection ---
function getMediaTitle() {
return (navigator.mediaSession && navigator.mediaSession.metadata)
@@ -351,6 +497,22 @@
return true;
}
+ if (message.action === 'APPLY_AUDIO_SETTINGS') {
+ _audioProcessingAllowed = true;
+ _audioSettings = mergeAudioSettings(message.settings);
+ const video = findVideo();
+ if (video) applyAudioSettings(video, _audioSettings);
+ sendResponse({ ok: true });
+ return true;
+ }
+
+ if (message.action === 'RESET_AUDIO_PROCESSING') {
+ _audioProcessingAllowed = false;
+ bypassCurrentAudioProcessing();
+ sendResponse({ ok: true });
+ return true;
+ }
+
if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message;
let actionCompleted = false;
@@ -655,7 +817,10 @@
});
// Reset on page hide/show (bfcache, tab discard)
- window.addEventListener('pagehide', () => { pageVisible = false; });
+ window.addEventListener('pagehide', () => {
+ pageVisible = false;
+ closeAudioContext();
+ });
window.addEventListener('pageshow', (event) => {
// event.persisted is true ONLY when restored from bfcache, not on initial load
if (event.persisted && !pageVisible) {
@@ -722,6 +887,9 @@
function setupListeners() {
const video = findVideo();
if (video) {
+ if (currentAudioVideo && currentAudioVideo !== video) {
+ bypassCurrentAudioProcessing();
+ }
const existing = video._koalaHandlers;
if (existing) {
video.removeEventListener('play', existing.play);
@@ -741,6 +909,8 @@
if (!lastKnownMediaTitle) {
lastKnownMediaTitle = getMediaTitle();
}
+
+ if (_audioSettings && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
}
}
@@ -755,6 +925,7 @@
if (!video && lastVideoSrc !== undefined) {
reportLog('Video element removed from page', 'warn');
lastVideoSrc = undefined;
+ closeAudioContext();
return;
}
diff --git a/extension/locales/de.json b/extension/locales/de.json
index 743b2b5..751d779 100644
--- a/extension/locales/de.json
+++ b/extension/locales/de.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Alle anderen Teilnehmer haben den Raum verlassen",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zeitüberschreitung — nicht alle Teilnehmer haben die Episode geladen",
"LOBBY_CANCEL_USER": "Vom Benutzer abgebrochen",
- "NOTIF_ERROR_TITLE": "KoalaSync-Fehler"
+ "NOTIF_ERROR_TITLE": "KoalaSync-Fehler",
+ "FOOTER_SUPPORT": "♥ Unterstützen",
+ "FOOTER_REVIEW": "★ Bewerten",
+ "LABEL_AUDIO_PROCESSING": "Audio-Verarbeitung",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Wendet Audioeffekte wie Kompression auf die Videowiedergabe an",
+ "AUDIO_OPEN_SETTINGS": "Audio-Einstellungen öffnen",
+ "NEW_FEATURE_AUDIO": "Neu: Audio-Verarbeitung — probiere den Kompressor aus!",
+ "AUDIO_PAGE_TITLE": "Audio-Einstellungen",
+ "AUDIO_MASTER_TOGGLE": "Audio-Verarbeitung",
+ "AUDIO_COMPRESSOR": "Kompressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Aktiviert",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Empfohlen",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamikumfang",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Sprache verbessern",
+ "AUDIO_PRESET_SMOOTH": "Sanft",
+ "AUDIO_PRESET_CUSTOM": "Benutzerdefiniert",
+ "AUDIO_PARAM_THRESHOLD": "Schwellwert",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Demnächst"
}
diff --git a/extension/locales/en.json b/extension/locales/en.json
index 9b6a0a0..4d74865 100644
--- a/extension/locales/en.json
+++ b/extension/locales/en.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "All other peers left",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — not all peers loaded the episode",
"LOBBY_CANCEL_USER": "Cancelled by user",
- "NOTIF_ERROR_TITLE": "KoalaSync Error"
+ "NOTIF_ERROR_TITLE": "KoalaSync Error",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
}
diff --git a/extension/locales/es.json b/extension/locales/es.json
index 8739806..1711a0e 100644
--- a/extension/locales/es.json
+++ b/extension/locales/es.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Todos los demás participantes se han ido",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tiempo de espera agotado: no todos los participantes cargaron el episodio",
"LOBBY_CANCEL_USER": "Cancelado por el usuario",
- "NOTIF_ERROR_TITLE": "Error de KoalaSync"
+ "NOTIF_ERROR_TITLE": "Error de KoalaSync",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
}
diff --git a/extension/locales/fr.json b/extension/locales/fr.json
index 48482a7..9d761a3 100644
--- a/extension/locales/fr.json
+++ b/extension/locales/fr.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Tous les autres membres sont partis",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Délai dépassé — tous les membres n'ont pas chargé l'épisode",
"LOBBY_CANCEL_USER": "Annulé par l'utilisateur",
- "NOTIF_ERROR_TITLE": "Erreur KoalaSync"
+ "NOTIF_ERROR_TITLE": "Erreur KoalaSync",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
}
diff --git a/extension/locales/it.json b/extension/locales/it.json
index 86455b3..95b2e90 100644
--- a/extension/locales/it.json
+++ b/extension/locales/it.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Tutti gli altri partecipanti hanno lasciato la stanza",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — non tutti i partecipanti hanno caricato l'episodio",
"LOBBY_CANCEL_USER": "Annullato dall'utente",
- "NOTIF_ERROR_TITLE": "Errore KoalaSync"
-}
\ No newline at end of file
+ "NOTIF_ERROR_TITLE": "Errore KoalaSync",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
+}
diff --git a/extension/locales/ja.json b/extension/locales/ja.json
index b49abd7..6d8864d 100644
--- a/extension/locales/ja.json
+++ b/extension/locales/ja.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "他のすべてのメンバーが退室しました",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "タイムアウト — 一部のメンバーがエピソードを読み込めませんでした",
"LOBBY_CANCEL_USER": "ユーザーによってキャンセルされました",
- "NOTIF_ERROR_TITLE": "KoalaSyncエラー"
-}
\ No newline at end of file
+ "NOTIF_ERROR_TITLE": "KoalaSyncエラー",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
+}
diff --git a/extension/locales/ko.json b/extension/locales/ko.json
index 49bb860..6a971f3 100644
--- a/extension/locales/ko.json
+++ b/extension/locales/ko.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "다른 모든 피어가 나갔습니다",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "시간 초과 — 모든 피어가 에피소드를 로드하지 못했습니다",
"LOBBY_CANCEL_USER": "사용자에 의해 취소됨",
- "NOTIF_ERROR_TITLE": "KoalaSync 오류"
-}
\ No newline at end of file
+ "NOTIF_ERROR_TITLE": "KoalaSync 오류",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
+}
diff --git a/extension/locales/nl.json b/extension/locales/nl.json
index 7aceaf3..233b6ba 100644
--- a/extension/locales/nl.json
+++ b/extension/locales/nl.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Alle andere deelnemers zijn vertrokken",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Time-out — niet alle deelnemers hebben de aflevering geladen",
"LOBBY_CANCEL_USER": "Geannuleerd door gebruiker",
- "NOTIF_ERROR_TITLE": "KoalaSync-fout"
-}
\ No newline at end of file
+ "NOTIF_ERROR_TITLE": "KoalaSync-fout",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
+}
diff --git a/extension/locales/pl.json b/extension/locales/pl.json
index 61eee14..66f1c5c 100644
--- a/extension/locales/pl.json
+++ b/extension/locales/pl.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Wszyscy inni uczestnicy wyszli",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Limit czasu — nie wszyscy uczestnicy załadowali odcinek",
"LOBBY_CANCEL_USER": "Anulowane przez użytkownika",
- "NOTIF_ERROR_TITLE": "Błąd KoalaSync"
-}
\ No newline at end of file
+ "NOTIF_ERROR_TITLE": "Błąd KoalaSync",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
+}
diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json
index 20f9130..570efa3 100644
--- a/extension/locales/pt-BR.json
+++ b/extension/locales/pt-BR.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Todos os outros participantes saíram",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo limite esgotado — nem todos os participantes carregaram o episódio",
"LOBBY_CANCEL_USER": "Cancelado pelo usuário",
- "NOTIF_ERROR_TITLE": "Erro do KoalaSync"
+ "NOTIF_ERROR_TITLE": "Erro do KoalaSync",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
}
diff --git a/extension/locales/pt.json b/extension/locales/pt.json
index b17a466..2ec7e88 100644
--- a/extension/locales/pt.json
+++ b/extension/locales/pt.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Todos os outros participantes saíram",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — nem todos os participantes carregaram o episódio",
"LOBBY_CANCEL_USER": "Cancelado pelo utilizador",
- "NOTIF_ERROR_TITLE": "Erro do KoalaSync"
-}
\ No newline at end of file
+ "NOTIF_ERROR_TITLE": "Erro do KoalaSync",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
+}
diff --git a/extension/locales/ru.json b/extension/locales/ru.json
index 74afe41..c0dd397 100644
--- a/extension/locales/ru.json
+++ b/extension/locales/ru.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Все остальные участники вышли",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Время ожидания истекло — не все участники загрузили серию",
"LOBBY_CANCEL_USER": "Отменено пользователем",
- "NOTIF_ERROR_TITLE": "Ошибка KoalaSync"
+ "NOTIF_ERROR_TITLE": "Ошибка KoalaSync",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
}
diff --git a/extension/locales/tr.json b/extension/locales/tr.json
index fdd63a6..ba8f079 100644
--- a/extension/locales/tr.json
+++ b/extension/locales/tr.json
@@ -179,5 +179,28 @@
"LOBBY_CANCEL_PEERS_LEFT": "Diğer tüm bağlantılar ayrıldı",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zaman aşımı — tüm bağlantılar bölümü yüklemedi",
"LOBBY_CANCEL_USER": "Kullanıcı tarafından iptal edildi",
- "NOTIF_ERROR_TITLE": "KoalaSync Hatası"
-}
\ No newline at end of file
+ "NOTIF_ERROR_TITLE": "KoalaSync Hatası",
+ "FOOTER_SUPPORT": "♥ Support",
+ "FOOTER_REVIEW": "★ Rate us",
+ "LABEL_AUDIO_PROCESSING": "Audio Processing",
+ "LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
+ "AUDIO_OPEN_SETTINGS": "Open Audio Settings",
+ "NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
+ "AUDIO_PAGE_TITLE": "Audio Settings",
+ "AUDIO_MASTER_TOGGLE": "Audio Processing",
+ "AUDIO_COMPRESSOR": "Compressor",
+ "AUDIO_COMPRESSOR_ENABLE": "Enabled",
+ "AUDIO_PRESET": "Preset",
+ "AUDIO_PRESET_RECOMMENDED": "Recommended",
+ "AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
+ "AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
+ "AUDIO_PRESET_SMOOTH": "Smooth",
+ "AUDIO_PRESET_CUSTOM": "Custom",
+ "AUDIO_PARAM_THRESHOLD": "Threshold",
+ "AUDIO_PARAM_KNEE": "Knee",
+ "AUDIO_PARAM_RATIO": "Ratio",
+ "AUDIO_PARAM_ATTACK": "Attack",
+ "AUDIO_PARAM_RELEASE": "Release",
+ "AUDIO_EQUALIZER": "Equalizer",
+ "AUDIO_COMING_SOON": "Coming soon"
+}
diff --git a/extension/popup.html b/extension/popup.html
index c9aba09..5e54e3d 100644
--- a/extension/popup.html
+++ b/extension/popup.html
@@ -331,6 +331,39 @@
transform: translateX(16px);
background-color: white;
}
+ .popup-footer {
+ margin-top: 20px;
+ text-align: center;
+ border-top: 1px solid rgba(255,255,255,0.05);
+ padding-top: 10px;
+ color: var(--text-muted);
+ font-size: 10px;
+ opacity: 0.6;
+ }
+ .popup-footer a {
+ color: var(--text-muted);
+ text-decoration: none;
+ transition: color 0.2s, opacity 0.2s;
+ }
+ .popup-footer a:hover {
+ color: var(--accent);
+ opacity: 1;
+ }
+ .feature-hint {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+ margin-left: 4px;
+ animation: featurePulse 2s ease-in-out infinite;
+ cursor: help;
+ vertical-align: middle;
+ }
+ @keyframes featurePulse {
+ 0%, 100% { opacity: 0.4; transform: scale(1); }
+ 50% { opacity: 1; transform: scale(1.3); }
+ }
@@ -535,11 +568,30 @@
+