A tool to import/export EQ settings between SquigLink (txt) or Hangout Audio URLs and FiiO Web EQ.
// ==UserScript==
// @name FiiO Web EQ Import/Export Tool (SquigLink)
// @namespace http://tampermonkey.net/
// @version 2.95
// @description A tool to import/export EQ settings between SquigLink (txt) or Hangout Audio URLs and FiiO Web EQ.
// @author NateAFish
// @match https://fiiocontrol.fiio.com/*
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const CACHE_KEY = 'tm_squig_sites_data';
const CACHE_DURATION = 24 * 60 * 60 * 1000;
const style = document.createElement('style');
style.innerHTML = `
.tm-loading-fade { transition: opacity 0.3s ease !important; }
.tm-fade-in { opacity: 1 !important; }
.tm-fade-out { opacity: 0 !important; }
.tm-squig-container {
position: relative;
display: flex;
align-items: center;
margin-right: 15px;
cursor: pointer;
height: 100%;
user-select: none;
}
.tm-squig-trigger {
font-size: 14px;
color: #ffffff !important;
padding: 0 5px;
}
.tm-squig-trigger:hover {
opacity: 0.8;
}
.tm-squig-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%) translateY(-10px);
width: 260px;
background-color: var(--el-bg-color-overlay, #ffffff);
border: 1px solid var(--el-border-color-light, #e4e7ed);
border-radius: 4px;
box-shadow: var(--el-box-shadow-light, 0 2px 12px 0 rgba(0, 0, 0, 0.1));
overflow: visible;
padding: 0;
z-index: 2050;
text-align: left;
margin-top: 16px;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;
}
.tm-squig-scroll-pane {
max-height: 60vh;
overflow-y: auto;
border-radius: 4px;
padding-bottom: 5px;
background-color: var(--el-bg-color-overlay, #ffffff);
}
.tm-squig-scroll-pane::-webkit-scrollbar { width: 6px; }
.tm-squig-scroll-pane::-webkit-scrollbar-track { background: transparent; }
.tm-squig-scroll-pane::-webkit-scrollbar-thumb {
background: var(--el-border-color-dark, #dcdfe6);
border-radius: 3px;
}
.tm-squig-scroll-pane::-webkit-scrollbar-thumb:hover {
background: var(--el-text-color-secondary, #909399);
}
.tm-squig-dropdown.is-active {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.tm-squig-dropdown::before {
content: "";
position: absolute;
top: -5px;
left: 50%;
width: 8px;
height: 8px;
background: var(--el-bg-color-overlay, #ffffff);
border: 1px solid var(--el-border-color-light, #e4e7ed);
border-right: none;
border-bottom: none;
transform: translateX(-50%) rotate(45deg);
z-index: 2051;
pointer-events: none;
}
.tm-menu-category {
padding: 10px 15px 6px;
font-size: 12px;
color: #c8102e !important;
font-weight: bold;
position: sticky;
top: 0;
z-index: 10;
background: var(--el-bg-color-overlay, rgba(255, 255, 255, 0.98));
@supports (backdrop-filter: blur(8px)) {
background: var(--el-bg-color-overlay, rgba(255, 255, 255, 0.8));
backdrop-filter: blur(8px);
}
border-bottom: 1px solid var(--el-border-color-lighter, #ebeef5);
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
}
.tm-menu-item {
display: block;
padding: 8px 20px;
font-size: 13px;
color: var(--el-text-color-regular, #606266);
text-decoration: none;
transition: background-color 0.2s, color 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tm-menu-item:hover {
background-color: var(--el-fill-color-light, #ecf5ff);
color: var(--el-color-primary, #409eff);
}
`;
document.head.appendChild(style);
const isChinese = navigator.language.startsWith('zh');
const TEXT = {
exportBtn: isChinese ? "导出 EQ" : "Export EQ",
importBtn: isChinese ? "导入参数 EQ" : "Import Parametric EQ",
urlBtn: isChinese ? "导入 Hangout 链接" : "Import Hangout URL",
fileName: "FiiO_EQ_Export.txt",
dialogTitle: isChinese ? "导入 Hangout Audio 数据" : "Import Hangout Audio Data",
dialogPlaceholder: isChinese ? "在此粘贴分享链接 (https://...)" : "Paste share link here (https://...)",
cancel: isChinese ? "取消" : "Cancel",
confirm: isChinese ? "确认" : "Confirm",
statusInit: isChinese ? "初始化..." : "Initializing...",
statusImporting: isChinese ? "写入中..." : "Writing...",
detectSlots: (n) => isChinese ? `识别到 ${n} 个频段` : `Detected ${n} bands`,
setPreamp: (v) => isChinese ? `Preamp: ${v} dB` : `Preamp: ${v} dB`,
setBandType: (i, max, t) => isChinese ? `频段 ${i}/${max}: ${t}` : `Band ${i}/${max}: ${t}`,
resetBand: (i, max) => isChinese ? `重置频段 ${i}/${max}` : `Reset Band ${i}/${max}`,
finish: isChinese ? "导入完成,请点击保存" : "Done. Please Save.",
errNoSlots: isChinese ? "未找到EQ频段,请刷新页面" : "No EQ bands found",
errParse: isChinese ? "解析失败: " : "Parse error: ",
errNoData: isChinese ? "无效数据" : "No data",
errInvalidURL: isChinese ? "链接无效" : "Invalid URL",
errExportNoData: isChinese ? "无法导出,页面未加载" : "Export failed, page not loaded",
errExportFail: isChinese ? "导出错误: " : "Export error: "
};
const ACTION_DELAY = 120;
function init() {
const checkExist = setInterval(function() {
if (window.location.href.includes('/equalizer/custom')) {
const btnContainer = document.querySelector('.el-row.is-justify-end');
if (btnContainer && !document.getElementById('tampermonkey-export-btn')) {
addButtons(btnContainer);
}
}
}, 1000);
const checkNavbar = setInterval(function() {
const rightPanel = document.querySelector('.navbar .content-right');
if (rightPanel && !document.getElementById('tm-squig-menu')) {
addSquigLinksMenu(rightPanel);
}
}, 1000);
}
async function getSquigData() {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const parsed = JSON.parse(cached);
const now = new Date().getTime();
if (now - parsed.timestamp < CACHE_DURATION) {
return parsed.data;
}
} catch (e) {
console.warn(e);
}
}
try {
const response = await fetch('https://squig.link/squigsites.json');
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
localStorage.setItem(CACHE_KEY, JSON.stringify({
timestamp: new Date().getTime(),
data: data
}));
return data;
} catch (error) {
return null;
}
}
function processSquigData(squigSites) {
const categories = { '5128': [], 'IEMs': [], 'Headphones': [], 'Earbuds': [] };
squigSites.forEach(site => {
const username = site.username;
const name = site.name;
const rootDomain = site.urlType === "root";
const subDomain = site.urlType === "subdomain";
const altDomain = site.urlType === "altDomain";
const baseUrl = rootDomain ? 'https://squig.link' :
altDomain ? site.altDomain :
subDomain ? 'https://' + username + '.squig.link' :
'https://squig.link/lab/' + username;
site.dbs.forEach(db => {
if (categories[db.type]) {
categories[db.type].push({ name: name, url: baseUrl + db.folder });
}
});
});
const result = [];
['5128', 'IEMs', 'Headphones', 'Earbuds'].forEach(type => {
if (categories[type].length > 0) result.push({ category: type, links: categories[type] });
});
return result;
}
async function addSquigLinksMenu(container) {
const menuContainer = document.createElement('div');
menuContainer.id = 'tm-squig-menu';
menuContainer.className = 'tm-squig-container';
menuContainer.innerHTML = `<span class="tm-squig-trigger">Squiglinks...</span>`;
const dropdown = document.createElement('div');
dropdown.className = 'tm-squig-dropdown';
const scrollPane = document.createElement('div');
scrollPane.className = 'tm-squig-scroll-pane';
scrollPane.innerHTML = `<div style="padding:15px;text-align:center;color:#909399;font-size:12px;">Loading...</div>`;
dropdown.appendChild(scrollPane);
menuContainer.appendChild(dropdown);
menuContainer.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('is-active');
});
document.addEventListener('click', (e) => {
if (!menuContainer.contains(e.target)) dropdown.classList.remove('is-active');
});
if (container.firstChild) container.insertBefore(menuContainer, container.firstChild);
else container.appendChild(menuContainer);
const rawData = await getSquigData();
if (rawData) {
const sortedData = processSquigData(rawData);
renderMenu(scrollPane, sortedData);
} else {
scrollPane.innerHTML = `<div style="padding:15px;text-align:center;color:#c8102e;font-size:12px;">Load Failed</div>`;
}
}
function renderMenu(container, data) {
container.innerHTML = '';
data.forEach(group => {
const catHeader = document.createElement('div');
catHeader.className = 'tm-menu-category';
catHeader.textContent = group.category;
container.appendChild(catHeader);
group.links.forEach(link => {
const item = document.createElement('a');
item.className = 'tm-menu-item';
item.href = link.url;
item.textContent = link.name;
item.target = '_blank';
container.appendChild(item);
});
});
}
function addButtons(container) {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.txt';
fileInput.style.display = 'none';
fileInput.id = 'import-file-input';
fileInput.addEventListener('change', handleFileSelect);
document.body.appendChild(fileInput);
const createBtn = (text, onClick, id = null) => {
const btn = document.createElement('button');
if (id) btn.id = id;
btn.className = 'el-button lighter-shadow';
btn.type = 'button';
btn.style.marginLeft = '10px';
btn.innerHTML = `<span>${text}</span>`;
btn.addEventListener('click', onClick);
return btn;
};
container.appendChild(createBtn(TEXT.exportBtn, exportData, 'tampermonkey-export-btn'));
container.appendChild(createBtn(TEXT.importBtn, () => fileInput.click()));
container.appendChild(createBtn(TEXT.urlBtn, showURLDialog));
}
function showOverlay(text) {
let overlay = document.getElementById('tm-loading-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'tm-loading-overlay';
overlay.className = 'el-loading-mask is-fullscreen tm-loading-fade';
overlay.style.zIndex = '9999';
overlay.style.display = 'none';
overlay.style.opacity = '0';
document.body.appendChild(overlay);
}
overlay.innerHTML = `
<div class="el-loading-spinner">
<svg class="circular" viewBox="0 0 50 50">
<circle class="path" cx="25" cy="25" r="20" fill="none"></circle>
</svg>
<p class="el-loading-text">${text || TEXT.statusInit}</p>
</div>
`;
overlay.style.display = 'block';
overlay.classList.remove('tm-fade-out');
void overlay.offsetWidth;
overlay.classList.add('tm-fade-in');
}
function updateOverlayText(text) {
const overlay = document.getElementById('tm-loading-overlay');
if (overlay) {
const textEl = overlay.querySelector('.el-loading-text');
if(textEl) textEl.innerText = text;
}
}
function hideOverlay() {
const overlay = document.getElementById('tm-loading-overlay');
if (overlay) {
overlay.classList.remove('tm-fade-in');
overlay.classList.add('tm-fade-out');
setTimeout(() => {
if (overlay.classList.contains('tm-fade-out')) {
overlay.style.display = 'none';
}
}, 300);
}
}
function showURLDialog() {
if (document.getElementById('tm-url-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'tm-url-overlay';
overlay.className = 'el-overlay el-modal-dialog';
overlay.style.zIndex = '2030';
overlay.style.backgroundColor = 'rgba(0,0,0,0)';
overlay.style.transition = 'background-color 0.3s';
overlay.innerHTML = `
<div role="dialog" aria-modal="true" aria-label="${TEXT.dialogTitle}" class="el-overlay-dialog">
<div class="el-dialog" tabindex="-1" style="--el-dialog-width: 400px; margin-top: 15vh; opacity: 0; transform: translateY(-20px); transition: opacity 0.3s, transform 0.3s;">
<header class="el-dialog__header show-close">
<span role="heading" aria-level="2" class="el-dialog__title">${TEXT.dialogTitle}</span>
<button class="el-dialog__headerbtn" type="button" id="tm-close-btn"><i class="el-icon el-dialog__close"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M764.288 214.592 512 466.88 259.712 214.592a31.936 31.936 0 0 0-45.12 45.12L466.752 512 214.528 764.224a31.936 31.936 0 1 0 45.12 45.184L512 557.184l252.288 252.288a31.936 31.936 0 0 0 45.12-45.12L557.12 512.064l252.288-252.352a31.936 31.936 0 1 0-45.12-45.184z"></path></svg></i></button>
</header>
<div class="el-dialog__body">
<div class="el-input"><div class="el-input__wrapper"><input class="el-input__inner" type="text" autocomplete="off" placeholder="${TEXT.dialogPlaceholder}" id="tm-url-input"></div></div>
</div>
<footer class="el-dialog__footer">
<button type="button" class="el-button" id="tm-cancel-btn"><span>${TEXT.cancel}</span></button>
<button type="button" class="el-button el-button--primary" id="tm-confirm-btn"><span>${TEXT.confirm}</span></button>
</footer>
</div>
</div>
`;
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
const dialog = overlay.querySelector('.el-dialog');
if (dialog) { dialog.style.opacity = '1'; dialog.style.transform = 'translateY(0)'; }
});
setTimeout(() => { const input = document.getElementById('tm-url-input'); if (input) input.focus(); }, 100);
const closeDialog = () => {
overlay.style.backgroundColor = 'rgba(0,0,0,0)';
const dialog = overlay.querySelector('.el-dialog');
if (dialog) { dialog.style.opacity = '0'; dialog.style.transform = 'translateY(-20px)'; }
setTimeout(() => { if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); }, 300);
};
document.getElementById('tm-close-btn').addEventListener('click', closeDialog);
document.getElementById('tm-cancel-btn').addEventListener('click', closeDialog);
document.getElementById('tm-url-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') document.getElementById('tm-confirm-btn').click(); });
document.getElementById('tm-confirm-btn').addEventListener('click', () => {
const urlVal = document.getElementById('tm-url-input').value.trim();
if (!urlVal) return;
try { const data = parseHangoutURL(urlVal); closeDialog(); applySettings(data); } catch (e) { alert(TEXT.errParse + e.message); }
});
overlay.addEventListener('click', (e) => { if (e.target.classList.contains('el-overlay-dialog')) closeDialog(); });
}
function parseHangoutURL(urlStr) {
try {
const url = new URL(urlStr);
const params = url.searchParams;
const data = { preamp: 0, filters: [] };
if (params.has('P')) data.preamp = parseFloat(params.get('P'));
for (let i = 1; i <= 20; i++) {
if (params.has(`T${i}`) && params.has(`F${i}`) && params.has(`G${i}`)) {
let typeCode = params.get(`T${i}`).toUpperCase();
if (typeCode === 'PK') typeCode = 'P';
if (typeCode === 'HSQ') typeCode = 'HS';
if (typeCode === 'LSQ') typeCode = 'LS';
data.filters.push({
index: i, on: true, type: typeCode,
freq: parseFloat(params.get(`F${i}`)),
gain: parseFloat(params.get(`G${i}`)),
q: parseFloat(params.get(`Q${i}`) || 0)
});
}
}
if (data.filters.length === 0 && data.preamp === 0) throw new Error(TEXT.errNoData);
data.filters.forEach((f, idx) => { f.index = idx + 1; });
return data;
} catch (e) {
throw new Error(TEXT.errInvalidURL);
}
}
function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try { const data = parseSquigLink(e.target.result); applySettings(data); } catch (err) { alert(TEXT.errParse + err.message); }
};
reader.readAsText(file);
event.target.value = '';
}
function parseSquigLink(text) {
const lines = text.split('\n');
const data = { preamp: 0, filters: [] };
lines.forEach(line => {
line = line.trim();
if (!line) return;
if (line.toLowerCase().startsWith('preamp:')) {
const match = line.match(/Preamp:\s*([\d\.-]+)\s*dB/i);
if (match) data.preamp = parseFloat(match[1]);
} else if (line.toLowerCase().startsWith('filter')) {
const regex = /Filter\s+(\d+):\s+(ON|OFF)\s+([A-Z]+)\s+Fc\s+([\d\.]+)\s+Hz\s+Gain\s+([\d\.\-]+)\s+dB\s+Q\s+([\d\.]+)/i;
const match = line.match(regex);
if (match) {
let type = match[3].toUpperCase();
if (type === 'LSC') type = 'LS';
if (type === 'HSC') type = 'HS';
if (type === 'LSQ') type = 'LS';
if (type === 'HSQ') type = 'HS';
if (type === 'PK') type = 'P';
data.filters.push({
index: parseInt(match[1]), on: match[2].toUpperCase() === 'ON', type: type,
freq: parseFloat(match[4]), gain: parseFloat(match[5]), q: parseFloat(match[6])
});
}
}
});
if (data.filters.length === 0 && data.preamp === 0) throw new Error(TEXT.errNoData);
return data;
}
function simulateClick(element) {
if (!element) return;
const eventOpts = { bubbles: true, cancelable: true, view: window };
element.dispatchEvent(new MouseEvent('mousedown', eventOpts));
element.dispatchEvent(new MouseEvent('mouseup', eventOpts));
element.dispatchEvent(new MouseEvent('click', eventOpts));
}
async function applySettings(data) {
showOverlay(TEXT.statusInit);
try {
const bands = document.querySelectorAll('.band-item');
const maxSlots = bands.length;
if (maxSlots === 0) throw new Error(TEXT.errNoSlots);
updateOverlayText(TEXT.detectSlots(maxSlots));
await delay(400);
const tasks = [];
tasks.push(async () => {
updateOverlayText(TEXT.setPreamp(data.preamp));
const globalGainLabel = document.querySelector('.global-gain');
if (globalGainLabel) {
const inputWrapper = globalGainLabel.nextElementSibling;
if (inputWrapper) {
const input = inputWrapper.querySelector('input');
if (input) {
input.scrollIntoView({behavior: "auto", block: "center"});
safeSetValue(input, data.preamp);
}
}
}
});
for (let i = 1; i <= maxSlots; i++) {
const importFilter = data.filters.find(f => f.index === i);
const band = bands[i - 1];
if (!band) continue;
const targetValues = importFilter ? {
type: importFilter.type, freq: importFilter.freq, gain: importFilter.gain, q: importFilter.q, isReset: false
} : {
type: 'P', freq: 20000, gain: 0, q: 0.71, isReset: true
};
if (targetValues.type === 'HSQ') targetValues.type = 'HS';
if (targetValues.type === 'LSQ') targetValues.type = 'LS';
tasks.push(async () => {
const msg = targetValues.isReset ? TEXT.resetBand(i, maxSlots) : TEXT.setBandType(i, maxSlots, targetValues.type);
updateOverlayText(msg);
band.scrollIntoView({behavior: "auto", block: "center"});
const btns = band.querySelectorAll('.btn-group button');
let targetBtn = null;
for (const btn of btns) {
const labelEl = btn.querySelector('.label');
const btnText = labelEl ? labelEl.textContent.trim() : btn.textContent.trim();
if (btnText === targetValues.type) {
targetBtn = btn;
break;
}
}
if (targetBtn) {
simulateClick(targetBtn);
const innerLabel = targetBtn.querySelector('.label');
if (innerLabel) {
simulateClick(innerLabel);
}
} else {
console.warn("Could not find button for type:", targetValues.type);
}
});
const inputs = band.querySelectorAll('.band-item-row-2 input.el-input__inner');
if (inputs.length >= 3) {
tasks.push(async () => { safeSetValue(inputs[0], targetValues.gain); });
tasks.push(async () => { safeSetValue(inputs[1], targetValues.freq); });
tasks.push(async () => { safeSetValue(inputs[2], targetValues.q); });
}
}
for (const task of tasks) {
await task();
await delay(ACTION_DELAY);
}
updateOverlayText(TEXT.finish);
await delay(1500);
} catch (error) {
console.error(error);
alert("Error: " + error.message);
} finally {
hideOverlay();
}
}
function safeSetValue(element, value) {
if (!element) return;
const descriptor = Object.getOwnPropertyDescriptor(element, 'value');
let setter = descriptor ? descriptor.set : null;
if (!setter) {
const prototype = Object.getPrototypeOf(element);
if (prototype) {
const protoDescriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
setter = protoDescriptor ? protoDescriptor.set : null;
}
}
if (setter) setter.call(element, value);
else element.value = value;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new Event('blur', { bubbles: true }));
}
function exportData() {
try {
let content = "";
const globalGainLabel = document.querySelector('.global-gain');
let preampValue = 0;
if (globalGainLabel) {
const inputWrapper = globalGainLabel.nextElementSibling;
if (inputWrapper) {
const input = inputWrapper.querySelector('input');
if (input) preampValue = input.value;
}
}
content += `Preamp: ${preampValue} dB\n`;
const bands = document.querySelectorAll('.band-item');
if (!bands || bands.length === 0) {
alert(TEXT.errExportNoData);
return;
}
let filterLines = [];
bands.forEach((band, index) => {
const bandIndex = index + 1;
const selectedTypeBtn = band.querySelector('.filter-button-selected .label');
if (!selectedTypeBtn) return;
let typeCode = selectedTypeBtn.textContent.trim();
if (typeCode === 'P') typeCode = 'PK';
else if (typeCode === 'LS') typeCode = 'LSC';
else if (typeCode === 'HS') typeCode = 'HSC';
const inputs = band.querySelectorAll('.band-item-row-2 input.el-input__inner');
if (inputs.length < 3) return;
const gain = inputs[0].value;
const freq = inputs[1].value;
const qVal = inputs[2].value;
filterLines.push(`Filter ${bandIndex}: ON ${typeCode} Fc ${freq} Hz Gain ${gain} dB Q ${qVal}`);
});
content += filterLines.join('\n');
const element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content));
element.setAttribute('download', TEXT.fileName);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
} catch (e) {
alert(TEXT.errExportFail + e.message);
}
}
function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
init();
})();