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
Built with ❤️ by Shik3i. KoalaSync is Open Source under the MIT License. + Ko-Fi GitGem Badge
diff --git a/extension/audio-options.css b/extension/audio-options.css new file mode 100644 index 0000000..8176bf0 --- /dev/null +++ b/extension/audio-options.css @@ -0,0 +1,224 @@ +:root { + --bg: #0f172a; + --card: #1e293b; + --panel: #172033; + --accent: #6366f1; + --accent-hover: #818cf8; + --text: #f8fafc; + --text-muted: #94a3b8; + --border: #334155; + --radius: 8px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; +} + +.page-shell { + width: min(760px, calc(100vw - 32px)); + margin: 0 auto; + padding: 32px 0; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + color: var(--accent-hover); + font-size: 28px; + font-weight: 800; +} + +h2 { + font-size: 16px; +} + +.panel { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + margin-bottom: 14px; +} + +.muted-panel { + color: var(--text-muted); +} + +.section-heading, +.master-toggle, +.inline-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.master-toggle, +.inline-toggle { + color: var(--text-muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.preset-group { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); + gap: 10px; + margin: 18px 0; +} + +.preset-card { + min-height: 44px; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; +} + +.preset-card:has(input:checked) { + border-color: var(--accent); + box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35); +} + +.preset-card input { + accent-color: var(--accent); +} + +.custom-grid { + display: grid; + gap: 12px; + padding: 14px; + background: rgba(15, 23, 42, 0.58); + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: var(--radius); +} + +.control-row { + display: grid; + grid-template-columns: 110px minmax(160px, 1fr) 78px 30px; + align-items: center; + gap: 10px; +} + +.control-row label { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +input[type="number"] { + width: 100%; + padding: 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font: inherit; +} + +.unit { + color: var(--text-muted); + font-size: 12px; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 38px; + height: 22px; + flex: 0 0 auto; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + inset: 0; + background-color: #334155; + transition: .3s; + border-radius: 22px; +} + +.slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background-color: #94a3b8; + transition: .3s; + border-radius: 50%; +} + +input:checked + .slider { + background-color: var(--accent); +} + +input:checked + .slider:before { + transform: translateX(16px); + background-color: white; +} + +@media (max-width: 620px) { + .page-header, + .section-heading { + align-items: flex-start; + flex-direction: column; + } + + .control-row { + grid-template-columns: 1fr 74px 28px; + } + + .control-row label { + grid-column: 1 / -1; + } +} diff --git a/extension/audio-options.html b/extension/audio-options.html new file mode 100644 index 0000000..7618a86 --- /dev/null +++ b/extension/audio-options.html @@ -0,0 +1,103 @@ + + + + + KoalaSync Audio Settings + + + +
+ + +
+
+

Compressor

+ +
+ +
+ + + + + +
+ +
+
+ + + + dB +
+
+ + + + dB +
+
+ + + + :1 +
+
+ + + + ms +
+
+ + + + ms +
+
+
+ +
+
+

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 @@ +
+
+ + Open Audio Settings +
+ +
+

Use this if you see "Duplicate Identity" errors.

+ + @@ -570,9 +622,15 @@
-
+
diff --git a/extension/popup.js b/extension/popup.js index 56f886a..2fd41ba 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,4 +1,4 @@ -import { EVENTS, OFFICIAL_LANDING_PAGE_URL } from './shared/constants.js'; +import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js'; import { BLACKLIST_DOMAINS } from './shared/blacklist.js'; import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js'; import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js'; @@ -50,9 +50,16 @@ const elements = { lobbyPeerStatus: document.getElementById('lobbyPeerStatus'), browserNotifications: document.getElementById('browserNotifications'), autoCopyInvite: document.getElementById('autoCopyInvite'), - syncTabCopyInvite: document.getElementById('syncTabCopyInvite'), cancelLobbyBtn: document.getElementById('cancelLobbyBtn'), - langSelector: document.getElementById('langSelector') + langSelector: document.getElementById('langSelector'), + audioProcessingToggle: document.getElementById('audioProcessingToggle'), + audioSettingsLink: document.getElementById('audioSettingsLink'), + settingsSupportLink: document.getElementById('settingsSupportLink'), + settingsReviewLink: document.getElementById('settingsReviewLink'), + settingsVersion: document.getElementById('settingsVersion'), + devSupportLink: document.getElementById('devSupportLink'), + devReviewLink: document.getElementById('devReviewLink'), + syncTabCopyInvite: document.getElementById('syncTabCopyInvite') }; let localPeerId = null; @@ -71,8 +78,100 @@ let pendingConnectionErrorMsg = null; let roomListRefreshTimer = null; let roomListRefreshInterval = null; const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000; +const DEFAULT_AUDIO_SETTINGS = { + enabled: false, + compressor: { + enabled: false, + preset: 'recommended', + customParams: { + threshold: -24, + knee: 30, + ratio: 12, + attack: 0.003, + release: 0.250 + } + } +}; +const FEATURE_HINTS = [ + { + key: 'audio_processing', + selector: '#audioSettingsLink', + i18nTooltip: 'NEW_FEATURE_AUDIO' + } +]; + +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 || {}) + } + } + }; +} // --- Helpers --- +function configureFooterLinks() { + const reviewUrl = getReviewUrl(); + [elements.settingsSupportLink, elements.devSupportLink].forEach(link => { + if (link) link.href = SUPPORT_URL; + }); + [elements.settingsReviewLink, elements.devReviewLink].forEach(link => { + if (link) link.href = reviewUrl; + }); +} + +function updateAudioSettingsLinkVisibility() { + if (elements.audioSettingsLink && elements.audioProcessingToggle) { + elements.audioSettingsLink.style.display = elements.audioProcessingToggle.checked ? 'inline-flex' : 'none'; + } +} + +function openAudioSettingsPage() { + chrome.tabs.create({ url: chrome.runtime.getURL('audio-options.html') }); +} + +async function updateFeatureHints() { + const data = await chrome.storage.sync.get(['dismissedHints']); + const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : []; + + FEATURE_HINTS.forEach(feature => { + const target = document.querySelector(feature.selector); + if (!target) return; + + const existing = target.parentElement?.querySelector(`.feature-hint[data-feature="${feature.key}"]`); + if (dismissed.includes(feature.key)) { + if (existing) existing.remove(); + return; + } + + if (!existing) { + const hint = document.createElement('span'); + hint.className = 'feature-hint'; + hint.dataset.feature = feature.key; + hint.title = getMessage(feature.i18nTooltip); + target.insertAdjacentElement('afterend', hint); + } else { + existing.title = getMessage(feature.i18nTooltip); + } + }); +} + +async function dismissFeatureHint(featureKey) { + const data = await chrome.storage.sync.get(['dismissedHints']); + const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : []; + if (!dismissed.includes(featureKey)) { + await chrome.storage.sync.set({ dismissedHints: [...dismissed, featureKey] }); + } + await updateFeatureHints(); +} + function clearConnectionErrorTimer() { if (connectionErrorTimer) { clearTimeout(connectionErrorTimer); @@ -111,7 +210,7 @@ async function init() { // Load per-device settings (local) + synced preferences (sync) const [localData, syncData] = await Promise.all([ chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']), - chrome.storage.sync.get(['filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale']) + chrome.storage.sync.get(['filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'dismissedHints']) ]); // Migrate from sync → local for existing users @@ -165,6 +264,11 @@ async function init() { if (elements.forceSyncMode) elements.forceSyncMode.value = syncData.forceSyncMode || 'jump-to-others'; if (elements.browserNotifications) elements.browserNotifications.checked = syncData.browserNotifications === true; if (elements.autoCopyInvite) elements.autoCopyInvite.checked = syncData.autoCopyInvite !== false; + if (elements.audioProcessingToggle) { + const audioSettings = mergeAudioSettings(syncData.audioSettings); + elements.audioProcessingToggle.checked = audioSettings.enabled === true; + updateAudioSettingsLinkVisibility(); + } // Set Version Info const versionTxt = `v${chrome.runtime.getManifest().version}`; @@ -172,6 +276,9 @@ async function init() { if (versionEl) versionEl.textContent = versionTxt; const popupVerEl = document.getElementById('popupVersion'); if (popupVerEl) popupVerEl.textContent = versionTxt; + if (elements.settingsVersion) elements.settingsVersion.textContent = versionTxt; + configureFooterLinks(); + await updateFeatureHints(); if (localData.useCustomServer) { setServerMode(true); @@ -959,6 +1066,35 @@ if (elements.autoCopyInvite) { }); } +if (elements.audioProcessingToggle) { + elements.audioProcessingToggle.addEventListener('change', async () => { + const data = await chrome.storage.sync.get(['audioSettings']); + const audioSettings = mergeAudioSettings(data.audioSettings); + audioSettings.enabled = elements.audioProcessingToggle.checked; + if (audioSettings.enabled && !audioSettings.compressor.enabled) { + audioSettings.compressor.enabled = true; + } + await chrome.storage.sync.set({ audioSettings }); + updateAudioSettingsLinkVisibility(); + if (audioSettings.enabled) openAudioSettingsPage(); + }); +} + +if (elements.audioSettingsLink) { + elements.audioSettingsLink.addEventListener('click', async (event) => { + event.preventDefault(); + await dismissFeatureHint('audio_processing'); + openAudioSettingsPage(); + }); +} + +chrome.storage.onChanged.addListener((changes, area) => { + if (area !== 'sync' || !changes.audioSettings || !elements.audioProcessingToggle) return; + const audioSettings = mergeAudioSettings(changes.audioSettings.newValue); + elements.audioProcessingToggle.checked = audioSettings.enabled === true; + updateAudioSettingsLinkVisibility(); +}); + elements.forceSyncMode.addEventListener('change', () => { chrome.storage.sync.set({ forceSyncMode: elements.forceSyncMode.value }); }); @@ -977,6 +1113,9 @@ if (elements.langSelector) { await chrome.storage.sync.set({ locale: selectedLang }); await loadLocale(selectedLang); translateDOM(); + configureFooterLinks(); + updateAudioSettingsLinkVisibility(); + await updateFeatureHints(); // Re-apply connection and room UI state since translateDOM may overwrite dynamic elements chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => { diff --git a/scripts/test-audio-settings.mjs b/scripts/test-audio-settings.mjs new file mode 100644 index 0000000..710f601 --- /dev/null +++ b/scripts/test-audio-settings.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +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 source = fs.readFileSync(sourcePath, 'utf8') + .replace("import { loadLocale, translateDOM, getSystemLanguage } from './i18n.js';", '') + .replace(/init\(\)\.catch[\s\S]*?;\n?$/, ''); + +function makeInput(overrides = {}) { + return { + checked: false, + value: '', + dataset: {}, + addEventListener() {}, + ...overrides + }; +} + +const rows = [ + ['threshold', '-60', '0', '1', false], + ['knee', '0', '40', '1', false], + ['ratio', '1', '20', '0.5', false], + ['attack', '0', '1', '0.001', true], + ['release', '0', '1', '0.005', true] +].map(([param, min, max, step, ms]) => { + const range = makeInput({ value: '0', min, max, step }); + const number = makeInput({ value: '0', min: ms ? '0' : min, max: ms ? '1000' : max, step, dataset: ms ? { msInput: 'true' } : {} }); + return { + dataset: { param }, + querySelector(selector) { + return selector === 'input[type="range"]' ? range : number; + } + }; +}); + +const sandbox = { + console, + loadLocale: async () => {}, + translateDOM: () => {}, + getSystemLanguage: () => 'en', + chrome: { + storage: { + sync: { + get: async () => ({}), + set: () => {}, + }, + onChanged: { + addListener: () => {} + } + } + }, + document: { + getElementById: () => makeInput(), + querySelectorAll: (selector) => selector === '.control-row' ? rows : [makeInput({ value: 'recommended' })] + }, + setTimeout, + clearTimeout +}; + +vm.createContext(sandbox); +vm.runInContext(`${source} +globalThis.__audioSettingsTest = { + mergeAudioSettings, + getParamValue, + setCustomParam, + get currentSettings() { return currentSettings; } +};`, sandbox, { filename: sourcePath }); + +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.getParamValue('threshold', '-999'), -60, 'threshold clamps to minimum'); +assert.equal(helpers.getParamValue('threshold', '999'), 0, 'threshold clamps to maximum'); +assert.equal(helpers.getParamValue('knee', '-1'), 0, 'knee clamps to minimum'); +assert.equal(helpers.getParamValue('knee', '100'), 40, 'knee clamps to maximum'); +assert.equal(helpers.getParamValue('ratio', '0'), 1, 'ratio clamps to minimum'); +assert.equal(helpers.getParamValue('ratio', '999'), 20, 'ratio clamps to maximum'); +assert.equal(helpers.getParamValue('attack', '-1', true), 0, 'attack ms input clamps to minimum seconds'); +assert.equal(helpers.getParamValue('attack', '5000', true), 1, 'attack ms input clamps to maximum seconds'); +assert.equal(helpers.getParamValue('release', '-1', true), 0, 'release ms input clamps to minimum seconds'); +assert.equal(helpers.getParamValue('release', '5000', true), 1, 'release ms input clamps to maximum seconds'); + +helpers.setCustomParam('threshold', 999); +assert.equal(helpers.currentSettings.compressor.customParams.threshold, 0, 'setCustomParam stores clamped values'); + +console.log('audio settings tests passed'); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index dd571c5..1e65cc2 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -12,6 +12,7 @@ const checks = [ env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' } }], ['content video finder', 'node', ['scripts/test-content-video-finder.js']], + ['audio settings', 'node', ['scripts/test-audio-settings.mjs']], ['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']], ['server syntax index', 'node', ['-c', 'server/index.js']], ['server syntax ops', 'node', ['-c', 'server/ops.js']], diff --git a/shared/constants.js b/shared/constants.js index 432cf54..0cddf2f 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -12,6 +12,20 @@ export const APP_VERSION = "1.9.0"; export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net'; export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net'; export const OFFICIAL_SERVER_TOKEN = '62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50'; +export const SUPPORT_URL = 'https://support.koalastuff.net'; +export const KOFI_URL = 'https://ko-fi.com/koaladev'; +export const GITHUB_URL = 'https://github.com/Shik3i/KoalaSync'; + +export function isFirefox() { + const manifest = chrome.runtime.getManifest(); + return !!manifest.browser_specific_settings?.gecko?.id; +} + +export function getReviewUrl() { + return isFirefox() + ? 'https://addons.mozilla.org/firefox/addon/koalasync/reviews/' + : 'https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc/reviews'; +} export const EVENTS = { // Connection & Room diff --git a/website/locales/de.json b/website/locales/de.json index 64ca7bb..f775a64 100644 --- a/website/locales/de.json +++ b/website/locales/de.json @@ -163,5 +163,6 @@ "MOCK_31": "Protokoll (Letzte 50)", "MOCK_32": "LÖSCHEN", "FOOTER_LEGAL_LINK": "de/impressum", - "FOOTER_PRIVACY_LINK": "de/datenschutz" + "FOOTER_PRIVACY_LINK": "de/datenschutz", + "FOOTER_SUPPORT": "Auf Ko-Fi unterstützen" } diff --git a/website/locales/en.json b/website/locales/en.json index 2f7de4f..d3e1a57 100644 --- a/website/locales/en.json +++ b/website/locales/en.json @@ -163,5 +163,6 @@ "MOCK_31": "Logs (Last 50)", "MOCK_32": "CLEAR", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Support on Ko-Fi" } diff --git a/website/locales/es.json b/website/locales/es.json index df984b0..57fb0fc 100644 --- a/website/locales/es.json +++ b/website/locales/es.json @@ -163,5 +163,6 @@ "MOCK_31": "BORRAR", "MOCK_32": "CLEAR", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Apoyar en Ko-Fi" } diff --git a/website/locales/fr.json b/website/locales/fr.json index 101102f..cd08129 100644 --- a/website/locales/fr.json +++ b/website/locales/fr.json @@ -163,5 +163,6 @@ "MOCK_31": "EFFACER", "MOCK_32": "CLEAR", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Soutenir sur Ko-Fi" } diff --git a/website/locales/it.json b/website/locales/it.json index edb250d..ae06a41 100644 --- a/website/locales/it.json +++ b/website/locales/it.json @@ -163,5 +163,6 @@ "MOCK_31": "Log (Ultimi 50)", "MOCK_32": "PULISCI", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Sostieni su Ko-Fi" } diff --git a/website/locales/ja.json b/website/locales/ja.json index d013776..4eda3da 100644 --- a/website/locales/ja.json +++ b/website/locales/ja.json @@ -163,5 +163,6 @@ "MOCK_31": "ログ(直近50件)", "MOCK_32": "消去", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Ko-Fiで支援" } diff --git a/website/locales/ko.json b/website/locales/ko.json index dd5c66f..8a4977e 100644 --- a/website/locales/ko.json +++ b/website/locales/ko.json @@ -163,5 +163,6 @@ "MOCK_31": "로그 (최근 50개)", "MOCK_32": "지우기", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Ko-Fi에서 후원" } diff --git a/website/locales/nl.json b/website/locales/nl.json index 31be1b3..e2540b0 100644 --- a/website/locales/nl.json +++ b/website/locales/nl.json @@ -163,5 +163,6 @@ "MOCK_31": "Logs (Laatste 50)", "MOCK_32": "WISSEN", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Steun op Ko-Fi" } diff --git a/website/locales/pl.json b/website/locales/pl.json index 025d7e9..c5ef97d 100644 --- a/website/locales/pl.json +++ b/website/locales/pl.json @@ -163,5 +163,6 @@ "MOCK_31": "Logi (ostatnie 50)", "MOCK_32": "WYCZYŚĆ", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Wesprzyj na Ko-Fi" } diff --git a/website/locales/pt-BR.json b/website/locales/pt-BR.json index 7e32263..eafa52b 100644 --- a/website/locales/pt-BR.json +++ b/website/locales/pt-BR.json @@ -163,5 +163,6 @@ "MOCK_31": "LIMPAR", "MOCK_32": "CLEAR", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Apoiar no Ko-Fi" } diff --git a/website/locales/pt.json b/website/locales/pt.json index a0304de..eca7d52 100644 --- a/website/locales/pt.json +++ b/website/locales/pt.json @@ -163,5 +163,6 @@ "MOCK_31": "Registos (Últimos 50)", "MOCK_32": "LIMPAR", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Apoiar no Ko-Fi" } diff --git a/website/locales/ru.json b/website/locales/ru.json index 63c892f..22effe7 100644 --- a/website/locales/ru.json +++ b/website/locales/ru.json @@ -163,5 +163,6 @@ "MOCK_31": "ОЧИСТИТЬ", "MOCK_32": "CLEAR", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Поддержать на Ko-Fi" } diff --git a/website/locales/tr.json b/website/locales/tr.json index 222c00b..9a207db 100644 --- a/website/locales/tr.json +++ b/website/locales/tr.json @@ -163,5 +163,6 @@ "MOCK_31": "Günlükler (Son 50)", "MOCK_32": "TEMİZLE", "FOOTER_LEGAL_LINK": "imprint", - "FOOTER_PRIVACY_LINK": "privacy" + "FOOTER_PRIVACY_LINK": "privacy", + "FOOTER_SUPPORT": "Ko-Fi üzerinden destekle" } diff --git a/website/template.html b/website/template.html index b55a7ef..2edd1f4 100644 --- a/website/template.html +++ b/website/template.html @@ -1208,6 +1208,9 @@
{{FOOTER_LEGAL}} {{FOOTER_PRIVACY}} + + Ko-Fi + Mastodon diff --git a/website/www/de/index.html b/website/www/de/index.html index 3826492..f6c3d4e 100644 --- a/website/www/de/index.html +++ b/website/www/de/index.html @@ -1208,6 +1208,9 @@
Impressum Datenschutz + + Ko-Fi + Mastodon diff --git a/website/www/es/index.html b/website/www/es/index.html index 3bda66b..7ad6cdd 100644 --- a/website/www/es/index.html +++ b/website/www/es/index.html @@ -1208,6 +1208,9 @@
Legal Notice Privacy Policy + + Ko-Fi + Mastodon diff --git a/website/www/fr/index.html b/website/www/fr/index.html index 5b9cf3a..09fd32a 100644 --- a/website/www/fr/index.html +++ b/website/www/fr/index.html @@ -1208,6 +1208,9 @@
Legal Notice Privacy Policy + + Ko-Fi + Mastodon diff --git a/website/www/index.html b/website/www/index.html index f2f964a..3f232b5 100644 --- a/website/www/index.html +++ b/website/www/index.html @@ -1208,6 +1208,9 @@
Legal Notice Privacy Policy + + Ko-Fi + Mastodon diff --git a/website/www/it/index.html b/website/www/it/index.html index 5a76bea..9dc6500 100644 --- a/website/www/it/index.html +++ b/website/www/it/index.html @@ -1208,6 +1208,9 @@
Note Legali Informativa sulla Privacy + + Ko-Fi + Mastodon diff --git a/website/www/ja/index.html b/website/www/ja/index.html index e984138..5883374 100644 --- a/website/www/ja/index.html +++ b/website/www/ja/index.html @@ -1208,6 +1208,9 @@
法的通知 プライバシーポリシー + + Ko-Fi + Mastodon diff --git a/website/www/ko/index.html b/website/www/ko/index.html index 3af2ffe..e9b273f 100644 --- a/website/www/ko/index.html +++ b/website/www/ko/index.html @@ -1208,6 +1208,9 @@
법적 고지 개인정보 처리방침 + + Ko-Fi + Mastodon diff --git a/website/www/nl/index.html b/website/www/nl/index.html index 5d76c52..3706fda 100644 --- a/website/www/nl/index.html +++ b/website/www/nl/index.html @@ -1208,6 +1208,9 @@
Juridische kennisgeving Privacybeleid + + Ko-Fi + Mastodon diff --git a/website/www/pl/index.html b/website/www/pl/index.html index 715bd6c..89aa425 100644 --- a/website/www/pl/index.html +++ b/website/www/pl/index.html @@ -1208,6 +1208,9 @@
Nota prawna Polityka prywatności + + Ko-Fi + Mastodon diff --git a/website/www/pt-BR/index.html b/website/www/pt-BR/index.html index 317260a..0d22072 100644 --- a/website/www/pt-BR/index.html +++ b/website/www/pt-BR/index.html @@ -1208,6 +1208,9 @@