The ultimate X experience: Seamless Audio, Liquid Glass, Perfect Layouts, and Custom Branding.
// ==UserScript==
// @name FlowX
// @namespace https://greasyfork.org/users/ghosty
// @version 2.1.1
// @description The ultimate X experience: Seamless Audio, Liquid Glass, Perfect Layouts, and Custom Branding.
// @author ghosty
// @match https://x.com/*
// @match https://www.x.com/*
// @match https://twitter.com/*
// @match https://www.twitter.com/*
// @grant GM_addStyle
// @grant GM_download
// @grant GM_xmlhttpRequest
// @run-at document-idle
// @license MIT
// @icon https://abs.twimg.com/icons/apple-touch-icon-192x192.png
// ==/UserScript==
(function() {
'use strict';
// --- Config & State ---
const CONFIG = {
storageKeyVol: 'flowx_volume',
storageKeyTheme: 'flowx_theme',
storageKeyFont: 'flowx_font',
storageKeyPos: 'flowx_position',
storageKeyWide: 'flowx_widescreen',
storageKeyDl: 'flowx_download',
adKeywords: /^(Ad|Promoted|Sponsored|Sponsrad|Gesponsert|Publicité|Promocionado)$/i,
};
let state = {
vol: parseFloat(localStorage.getItem(CONFIG.storageKeyVol)) || 0.5,
theme: localStorage.getItem(CONFIG.storageKeyTheme) || 'liquid',
font: localStorage.getItem(CONFIG.storageKeyFont) || 'default',
wide: localStorage.getItem(CONFIG.storageKeyWide) === 'true',
download: true, // Hardcoded ON to guarantee local storage doesn't hide it
pos: JSON.parse(localStorage.getItem(CONFIG.storageKeyPos)) || { x: 20, y: window.innerHeight - 80 }
};
// --- THEME & TYPOGRAPHY ENGINE ---
const STYLES = `
:root {
--spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
--smooth-flow: cubic-bezier(0.25, 0.8, 0.25, 1);
--glide-ease: cubic-bezier(0.22, 1, 0.36, 1);
--fx-accent: #fff;
}
body.fx-font-cyber div[dir="auto"], body.fx-font-cyber span { font-family: "Courier New", Consolas, monospace !important; letter-spacing: -0.5px; }
body.fx-font-serif div[dir="auto"], body.fx-font-serif span { font-family: Georgia, "Times New Roman", serif !important; }
body.fx-font-rounded div[dir="auto"], body.fx-font-rounded span { font-family: ui-rounded, "Nunito", "Quicksand", sans-serif !important; }
/* --- TRUE WIDESCREEN / FOCUS MODE --- */
body.fx-mode-wide div[data-testid="sidebarColumn"] { display: none !important; width: 0 !important; }
body.fx-mode-wide header[role="banner"] {
position: absolute !important;
left: 0; top: 0;
transform: translateX(-95%) !important;
opacity: 0 !important;
transition: transform 0.3s var(--glide-ease), opacity 0.3s ease !important;
z-index: 9999 !important;
background: rgba(0,0,0,0.95) !important;
height: 100vh !important;
border-right: 1px solid rgba(255,255,255,0.1);
}
body.fx-mode-wide header[role="banner"]:hover {
transform: translateX(0) !important;
opacity: 1 !important;
}
body.fx-mode-wide header[role="banner"]::after {
content: ''; position: absolute; top: 0; right: -25px; width: 25px; height: 100%;
}
body.fx-mode-wide main[role="main"],
body.fx-mode-wide main[role="main"] > div,
body.fx-mode-wide main[role="main"] > div > div {
display: flex !important;
justify-content: center !important;
width: 100% !important;
max-width: 100% !important;
}
body.fx-mode-wide div[data-testid="primaryColumn"] {
margin: 0 auto !important;
max-width: 700px !important;
width: 100% !important;
border: none !important;
}
body.fx-mode-wide div:has(> a[href="/i/grok"]),
body.fx-mode-wide div:has(> div[data-testid="DMDrawer"]),
body.fx-mode-wide [data-testid="DMDrawer"],
body.fx-mode-wide a[href="/i/grok"] {
display: none !important;
opacity: 0 !important;
pointer-events: none !important;
}
/* --- UI ELEMENTS --- */
#flowx-btn {
position: fixed; width: 42px; height: 42px; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;
cursor: grab; user-select: none;
transition: transform 0.2s var(--smooth-flow), box-shadow 0.3s;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 2147483647 !important;
}
#flowx-btn:active { cursor: grabbing; transform: scale(0.92); }
#flowx-btn.fx-glide { transition: left 0.5s var(--glide-ease), top 0.5s var(--glide-ease), transform 0.3s; }
#flowx-btn:hover { transform: scale(1.05) !important; }
#flowx-panel {
position: fixed; width: 340px; border-radius: 20px; padding: 20px;
font-family: "Segoe UI", system-ui, sans-serif; display: none;
max-height: 85vh; overflow-y: auto; overflow-x: hidden;
z-index: 2147483647 !important;
}
.fx-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.fx-txt { font-size: 14px; font-weight: 500; opacity: 0.9; }
.fx-toggle { position: relative; width: 44px; height: 24px; border-radius: 99px; background: rgba(120,120,120,0.3); cursor: pointer; transition: 0.3s; border: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;}
.fx-toggle::after { content: ''; position: absolute; left: 2px; top: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform 0.3s var(--spring-bounce); box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
.fx-toggle.active { background: #34C759; }
.fx-toggle.active::after { transform: translateX(20px); }
.fx-slider-wrap { position: relative; width: 100%; height: 16px; display: flex; align-items: center; margin: 10px 0 24px 0; }
.fx-slider-bg { position: absolute; left: 0; right: 0; height: 4px; border-radius: 99px; background: rgba(255,255,255,0.2); }
.fx-slider-fill { position: absolute; left: 0; height: 4px; border-radius: 99px; pointer-events: none; }
.fx-slider { -webkit-appearance: none; width: 100%; height: 20px; background: transparent; position: absolute; margin: 0; outline: none; cursor: pointer; }
.fx-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.3); transition: transform 0.1s; margin-top: -7px; }
.fx-slider::-webkit-slider-runnable-track { height: 4px; background: transparent; }
.fx-select { width: 48%; padding: 10px; border-radius: 10px; font-size: 13px; outline: none; border:none; margin-bottom: 20px; cursor: pointer; background: rgba(0,0,0,0.2); color: inherit; font-weight: 600; }
.fx-select-full { width: 100%; padding: 10px; border-radius: 10px; font-size: 13px; outline: none; border:none; margin-bottom: 20px; cursor: pointer; background: rgba(0,0,0,0.2); color: inherit; font-weight: 600; }
.fx-select-wrap { display: flex; justify-content: space-between; }
.fx-anim-open { animation: panelOpen 0.4s var(--spring-bounce) forwards; }
.fx-anim-min { animation: panelMin 0.3s var(--smooth-flow) forwards; pointer-events: none; }
@keyframes panelOpen { 0% { opacity: 0; transform: scale(0.8); filter: blur(10px); } 100% { opacity: 1; transform: scale(1); filter: blur(0px); } }
@keyframes panelMin { 0% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(0.6); filter: blur(10px); } }
.fx-theme-liquid { background: rgba(20, 25, 35, 0.9); backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%); border: 1px solid rgba(255, 255, 255, 0.15); box-shadow: 0 20px 50px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.3); color: #fff; }
.fx-theme-liquid .fx-slider-fill { background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%); }
.fx-theme-rgb { background: rgba(0, 0, 0, 0.9); border: 1px solid #333; color: #fff; animation: rgbBorder 4s linear infinite; }
@keyframes rgbBorder { 0% { box-shadow: 0 0 10px #f00; border-color: #f00; } 50% { box-shadow: 0 0 10px #0f0; border-color: #0f0; } 100% { box-shadow: 0 0 10px #00f; border-color: #00f; } }
.fx-theme-rgb .fx-slider-fill { background: linear-gradient(90deg, red, yellow, lime, cyan, blue, magenta, red); }
.fx-theme-cyber { background: rgba(5, 10, 16, 0.95); border: 1px solid #0ff; color: #0ff; box-shadow: 0 0 15px rgba(0,255,255,0.2); }
.fx-theme-cyber .fx-slider-fill, .fx-theme-cyber .fx-toggle.active { background: #0ff; box-shadow: 0 0 10px #0ff; }
.fx-theme-cyber .fx-toggle.active::after { background: #000; }
.fx-theme-sunset { background: linear-gradient(135deg, rgba(255, 95, 109, 0.95), rgba(255, 195, 113, 0.95)); color: #fff; border: 1px solid rgba(255,255,255,0.4); }
.fx-theme-sunset .fx-slider-fill { background: #fff; }
.fx-theme-frost { background: rgba(255,255,255,0.85); backdrop-filter: blur(20px); color: #222; border: 1px solid rgba(0,0,0,0.1); }
.fx-theme-frost .fx-slider-fill { background: #007AFF; }
.fx-theme-frost .fx-toggle { background: rgba(0,0,0,0.1); }
/* --- CUSTOM BRANDING ENGINE --- */
.fx-logo-text {
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important;
font-size: 22px !important;
font-weight: 700 !important;
color: #fff !important;
margin-right: 10px !important;
margin-left: 0 !important;
vertical-align: middle !important;
display: inline-block !important;
letter-spacing: -0.5px !important;
line-height: 1 !important;
transition: opacity 0.3s ease;
}
/* Adjust the X SVG to align perfectly next to the text */
div[role="banner"] h1[role="heading"] a[href="/home"] svg {
display: inline-block !important;
margin-left: 0 !important;
position: relative;
top: 1px;
}
body.fx-mode-wide header[role="banner"] .fx-logo-text {
opacity: 0;
pointer-events: none;
}
body.fx-mode-wide header[role="banner"]:hover .fx-logo-text {
opacity: 1;
pointer-events: auto;
}
/* --- THEMED KO-FI DONATION BUTTON --- */
.fx-donate-wrapper {
display: flex;
justify-content: center;
margin-top: 25px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.fx-donate-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 99px;
color: #ffffff !important;
text-decoration: none;
font-weight: 700;
font-size: 11px;
letter-spacing: 0.5px;
transition: all 0.3s var(--smooth-flow);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.fx-donate-btn span {
font-size: 14px;
transition: transform 0.4s var(--spring-bounce);
}
.fx-donate-btn:hover {
transform: translateY(-2px) scale(1.02);
color: #ffffff !important;
}
.fx-donate-btn:hover span {
transform: scale(1.2) rotate(15deg);
}
.fx-donate-btn:active {
transform: translateY(0) scale(0.95);
}
.fx-theme-liquid .fx-donate-btn:hover {
background: rgba(0, 242, 254, 0.15);
border-color: rgba(0, 242, 254, 0.5);
box-shadow: 0 6px 15px rgba(0, 242, 254, 0.3);
}
.fx-theme-rgb .fx-donate-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #fff;
animation: rgbBorder 4s linear infinite;
}
.fx-theme-cyber .fx-donate-btn:hover {
background: rgba(0, 255, 255, 0.1);
border-color: #0ff;
color: #0ff !important;
box-shadow: 0 0 15px rgba(0, 255, 255, 0.4);
}
.fx-theme-sunset .fx-donate-btn {
border-color: rgba(255, 255, 255, 0.2);
color: #ffffff !important;
}
.fx-theme-sunset .fx-donate-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #fff;
box-shadow: 0 6px 15px rgba(255, 255, 255, 0.3);
}
.fx-theme-frost .fx-donate-wrapper {
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.fx-theme-frost .fx-donate-btn {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.1);
color: #333 !important;
text-shadow: none;
}
.fx-theme-frost .fx-donate-btn:hover {
background: #007AFF;
border-color: #007AFF;
color: #fff !important;
box-shadow: 0 6px 15px rgba(0, 122, 255, 0.3);
}
`;
const styleSheet = document.createElement("style");
styleSheet.innerText = STYLES;
document.head.appendChild(styleSheet);
// --- THE AUDIO ENGINE ---
document.addEventListener('pause', (e) => {
if (e.target && e.target.tagName === 'VIDEO') {
const rect = e.target.getBoundingClientRect();
const screenCenter = window.innerHeight / 2;
const vidCenterY = rect.top + (rect.height / 2);
if (Math.abs(screenCenter - vidCenterY) < (window.innerHeight * 0.4)) {
e.target.dataset.fxUserPaused = 'true';
}
}
}, true);
document.addEventListener('play', (e) => {
if (e.target && e.target.tagName === 'VIDEO') {
e.target.dataset.fxUserPaused = 'false';
}
}, true);
const syncAudio = () => {
const videos = document.querySelectorAll('video');
if (videos.length === 0) return;
const viewportHeight = window.innerHeight;
const screenCenter = viewportHeight / 2;
let closestVideo = null;
let minDistance = Infinity;
videos.forEach(v => {
const rect = v.getBoundingClientRect();
if (rect.height < 50) return;
const vidCenterY = rect.top + (rect.height / 2);
const distance = Math.abs(screenCenter - vidCenterY);
if (distance < minDistance) {
minDistance = distance;
closestVideo = v;
}
});
videos.forEach(v => {
if (v === closestVideo && minDistance < (viewportHeight * 0.45)) {
if (v.dataset.fxUserPaused === 'true') return;
if (v.paused) {
v.muted = true;
const playPromise = v.play();
if (playPromise !== undefined) {
playPromise.then(() => {
v.muted = false;
v.volume = state.vol;
}).catch(() => {
const container = v.closest('[data-testid="videoComponent"]');
if (container) {
const playBtn = container.querySelector('[data-testid="playButton"]');
if (playBtn) playBtn.click();
}
});
}
} else {
if (v.muted) v.muted = false;
if (Math.abs(v.volume - state.vol) > 0.05) v.volume = state.vol;
}
} else {
if (!v.paused) v.pause();
}
});
};
let scrollTimeout;
window.addEventListener('scroll', () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(syncAudio, 100);
}, { passive: true });
setInterval(syncAudio, 600);
// --- THE TARGETED, IMMUNE DOWNLOAD ENGINE ---
function getTweetUrl(btn) {
const article = btn.closest('article');
if (!article) {
if (window.location.pathname.includes('/status/')) return window.location.href;
return null;
}
const links = Array.from(article.querySelectorAll('a[href*="/status/"]'));
const timeLink = links.find(a => /\/status\/[0-9]+$/.test(a.href) && !a.href.includes('/photo/') && !a.href.includes('/video/'));
return timeLink ? timeLink.href : null;
}
function injectDownloadButtons() {
if (!state.download) return;
// Hunt down the Bookmark button directly. We completely ignore the Action Bar [role="group"] because X moved it!
const bookmarks = document.querySelectorAll('[data-testid="bookmark"], [data-testid="removeBookmark"]');
bookmarks.forEach(bookmark => {
try {
// Get the physical button circle
const btn = bookmark.closest('[role="button"]') || bookmark.closest('button') || bookmark;
// Get the wrapper that physically separates it from the Share button
const wrapper = btn.parentElement;
if (!wrapper) return;
// Get the master container that holds both Bookmark and Share
const container = wrapper.parentElement;
if (!container) return;
// Check purely based on sibling DOM elements. If our button is to the left, stop.
if (wrapper.previousElementSibling && wrapper.previousElementSibling.classList.contains('fx-dl-btn-raw')) return;
// Build a pure HTML button from scratch.
// We steal the exact layout CSS from the wrapper so X handles the Flexbox math perfectly.
const dlBtn = document.createElement('div');
dlBtn.className = wrapper.className + ' fx-dl-btn-raw';
dlBtn.setAttribute('title', 'Download High-Res');
// Hardcode X's native 34.75px circle geometry to absolutely prevent flexbox squashing
dlBtn.style.cssText = 'display: flex; align-items: center; justify-content: center; width: 34.75px; height: 34.75px; border-radius: 9999px; cursor: pointer; color: rgb(113, 118, 123); transition: background-color 0.2s, color 0.2s;';
// Pure vector SVG arrow matching X's 1.25em size spec
dlBtn.innerHTML = '<svg viewBox="0 0 24 24" style="width: 1.25em; height: 1.25em; fill: currentColor; pointer-events: none;"><path d="M12 18.5l-6.5-6.5 1.414-1.414 4.086 4.086V3h2v11.672l4.086-4.086 1.414 1.414L12 18.5zm7 3v-2H5v2h14z"></path></svg>';
// Native hover effects driven entirely by JS so React can't strip them
dlBtn.addEventListener('mouseenter', () => {
dlBtn.style.backgroundColor = 'rgba(29, 155, 240, 0.1)';
dlBtn.style.color = 'rgb(29, 155, 240)';
});
dlBtn.addEventListener('mouseleave', () => {
dlBtn.style.backgroundColor = 'transparent';
dlBtn.style.color = 'rgb(113, 118, 123)';
});
// Execution Logic
dlBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const url = getTweetUrl(dlBtn);
if (!url) {
alert("FlowX: Couldn't extract the Tweet URL. Try clicking the tweet to expand it first.");
return;
}
// Green Success Pulse
dlBtn.style.color = '#34C759';
dlBtn.style.transform = 'scale(1.15)';
setTimeout(() => {
dlBtn.style.color = 'rgb(113, 118, 123)';
dlBtn.style.transform = 'scale(1)';
}, 800);
const apiUrl = url.replace(/(twitter\.com|x\.com)/, 'api.vxtwitter.com');
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
if (data && data.mediaURLs && data.mediaURLs.length > 0) {
data.mediaURLs.forEach((mediaUrl, index) => {
let finalUrl = mediaUrl;
const ext = finalUrl.split('.').pop().split('?')[0] || 'mp4';
const filename = `FlowX_${data.tweetID}_${index}.${ext}`;
GM_download({
url: finalUrl,
name: filename,
onload: () => console.log('FlowX: Payload Secured ->', filename),
onerror: () => window.open(finalUrl, '_blank')
});
});
} else {
alert("FlowX: No media detected in this tweet.");
}
} catch(err) {
window.open(`https://x-downloader.net/?url=${encodeURIComponent(url)}`, '_blank');
}
},
onerror: function() {
window.open(`https://x-downloader.net/?url=${encodeURIComponent(url)}`, '_blank');
}
});
});
// Drop it perfectly inside the container, precisely to the left of the Bookmark button!
container.insertBefore(dlBtn, wrapper);
} catch (err) {
// Catch any rendering oddities silently
}
});
}
// Heavy interval polling guarantees the button stays alive during fast scrolling
setInterval(injectDownloadButtons, 500);
const downloadObserver = new MutationObserver(() => injectDownloadButtons());
downloadObserver.observe(document.body, { childList: true, subtree: true });
// --- LOGO ENGINE ---
function injectLogoText() {
const logoAnchor = document.querySelector('h1[role="heading"] a[href="/home"], div[role="banner"] a[href="/home"]');
if (!logoAnchor || logoAnchor.querySelector('.fx-logo-text')) return;
const flowText = document.createElement('span');
flowText.className = 'fx-logo-text';
flowText.textContent = 'Flow';
logoAnchor.style.display = 'flex';
logoAnchor.style.alignItems = 'center';
logoAnchor.style.flexDirection = 'row';
logoAnchor.prepend(flowText);
}
setInterval(injectLogoText, 2000);
// --- AD BLOCKER ---
const processedNodes = new WeakSet();
function processArticle(article, wrapper) {
if (processedNodes.has(article)) return;
processedNodes.add(article);
const spans = article.querySelectorAll('span');
let isAd = false;
for (const span of spans) {
const txt = span.textContent.trim();
if (CONFIG.adKeywords.test(txt) && span.closest('[data-testid="User-Name"]')) {
isAd = true; break;
}
}
if (isAd) {
if (wrapper) wrapper.style.display = 'none';
else article.style.display = 'none';
return;
}
}
const observer = new MutationObserver((mutations) => {
mutations.forEach(m => {
m.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.matches && node.matches('div[data-testid="cellInnerDiv"]')) {
const article = node.querySelector('article[data-testid="tweet"]');
if (article) processArticle(article, node);
} else if (node.querySelectorAll) {
const wrappers = node.querySelectorAll('div[data-testid="cellInnerDiv"]');
wrappers.forEach(w => {
const a = w.querySelector('article[data-testid="tweet"]');
if (a) processArticle(a, w);
});
}
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
// --- UI CONSTRUCTION ---
function applyModes() {
const b = document.body.classList;
state.wide ? b.add('fx-mode-wide') : b.remove('fx-mode-wide');
b.remove('fx-font-default', 'fx-font-cyber', 'fx-font-serif', 'fx-font-rounded');
b.add(`fx-font-${state.font}`);
}
function createUI() {
const btn = document.createElement('div');
btn.id = 'flowx-btn';
btn.textContent = 'FX';
btn.className = `fx-theme-${state.theme} fx-glide`;
btn.style.left = state.pos.x + 'px';
btn.style.top = state.pos.y + 'px';
const panel = document.createElement('div');
panel.id = 'flowx-panel';
panel.className = `fx-theme-${state.theme}`;
panel.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<div style="font-weight:700; font-size:18px;">FlowX <span style="font-size:11px; opacity:0.6; font-weight:400;">v2.1.1</span></div>
<div class="fx-close" style="cursor:pointer; font-size:12px; opacity:0.7;">✕</div>
</div>
<div class="fx-select-wrap">
<select class="fx-select" id="theme-select">
<option value="liquid">💧 Liquid</option>
<option value="rgb">🌈 RGB</option>
<option value="frost">❄️ Frost</option>
<option value="cyber">⚡ Cyber</option>
<option value="sunset">🌅 Sunset</option>
</select>
<select class="fx-select" id="font-select">
<option value="default">Aa System</option>
<option value="cyber">Aa Mono</option>
<option value="serif">Aa Serif</option>
<option value="rounded">Aa Smooth</option>
</select>
</div>
<div style="margin-bottom:20px;">
<div class="fx-row" style="margin-bottom:5px;">
<span class="fx-txt">Volume</span>
<span class="fx-vol-val" style="font-size:12px; font-weight:bold;">${Math.round(state.vol * 100)}%</span>
</div>
<div class="fx-slider-wrap">
<div class="fx-slider-bg"></div>
<div class="fx-slider-fill" style="width:${state.vol * 100}%"></div>
<input class="fx-slider" type="range" min="0" max="100" value="${state.vol * 100}">
</div>
</div>
<div class="fx-txt" style="opacity:0.5; font-size:11px; margin-bottom:10px; text-transform:uppercase; letter-spacing:1px;">Features</div>
<div id="feat-list"></div>
<div class="fx-donate-wrapper">
<a href="https://ko-fi.com/ghostyy69" target="_blank" class="fx-donate-btn">
<span>☕</span> support ghosty
</a>
</div>
`;
const features = [
{ id: 'wide', label: 'Widescreen', desc: 'Focus Mode' },
{ id: 'download', label: 'Download Button', desc: '1-Click API Rip' }
];
const featList = panel.querySelector('#feat-list');
features.forEach(f => {
const row = document.createElement('div');
row.className = 'fx-row';
row.innerHTML = `
<div>
<div class="fx-txt">${f.label}</div>
<div style="font-size:10px; opacity:0.5;">${f.desc}</div>
</div>
<div class="fx-toggle ${state[f.id] ? 'active' : ''}" id="tog-${f.id}"></div>
`;
featList.appendChild(row);
row.querySelector('.fx-toggle').addEventListener('click', (e) => {
state[f.id] = !state[f.id];
e.currentTarget.classList.toggle('active');
const map = {
'wide': CONFIG.storageKeyWide, 'download': CONFIG.storageKeyDl
};
localStorage.setItem(map[f.id], state[f.id]);
applyModes();
});
});
const slider = panel.querySelector('.fx-slider');
const sliderFill = panel.querySelector('.fx-slider-fill');
const volVal = panel.querySelector('.fx-vol-val');
slider.addEventListener('input', (e) => {
state.vol = e.target.value / 100;
localStorage.setItem(CONFIG.storageKeyVol, state.vol);
sliderFill.style.width = `${state.vol * 100}%`;
volVal.innerText = `${Math.round(state.vol * 100)}%`;
});
const themeSelect = panel.querySelector('#theme-select');
themeSelect.value = state.theme;
themeSelect.addEventListener('change', (e) => {
state.theme = e.target.value;
localStorage.setItem(CONFIG.storageKeyTheme, state.theme);
panel.className = `fx-theme-${state.theme} fx-anim-open`;
btn.className = `fx-theme-${state.theme} fx-glide`;
});
const fontSelect = panel.querySelector('#font-select');
fontSelect.value = state.font;
fontSelect.addEventListener('change', (e) => {
state.font = e.target.value;
localStorage.setItem(CONFIG.storageKeyFont, state.font);
applyModes();
});
panel.querySelector('.fx-close').addEventListener('click', () => togglePanel(false));
applyModes();
let isDragging = false, hasMoved = false, startX, startY, initX, initY;
btn.addEventListener('mousedown', (e) => {
isDragging = true; hasMoved = false;
btn.classList.remove('fx-glide');
startX = e.clientX; startY = e.clientY;
initX = btn.offsetLeft; initY = btn.offsetTop;
});
document.documentElement.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX; const dy = e.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasMoved = true;
btn.style.left = `${initX + dx}px`; btn.style.top = `${initY + dy}px`;
}, true);
document.documentElement.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
if (hasMoved) {
btn.classList.add('fx-glide');
const rect = btn.getBoundingClientRect();
const winW = window.innerWidth;
const snapX = (rect.left + rect.width/2 < winW/2) ? 20 : winW - rect.width - 20;
let snapY = rect.top;
if(snapY < 20) snapY = 20;
if(snapY > window.innerHeight - 80) snapY = window.innerHeight - 80;
btn.style.left = snapX + 'px'; btn.style.top = snapY + 'px';
state.pos = { x: snapX, y: snapY };
localStorage.setItem(CONFIG.storageKeyPos, JSON.stringify(state.pos));
}
}
}, true);
btn.addEventListener('click', () => {
if (!hasMoved) togglePanel(panel.style.display !== 'block');
});
function togglePanel(show) {
if (show) {
panel.style.display = 'block';
panel.classList.remove('fx-anim-min');
panel.classList.add('fx-anim-open');
const bRect = btn.getBoundingClientRect();
let left = bRect.left + 60;
if (left + 360 > window.innerWidth) left = bRect.left - 360;
panel.style.left = Math.max(10, left) + 'px';
if (bRect.top > window.innerHeight / 2) {
panel.style.top = 'auto';
panel.style.bottom = Math.max(20, window.innerHeight - bRect.bottom) + 'px';
} else {
panel.style.bottom = 'auto';
panel.style.top = Math.max(20, bRect.top) + 'px';
}
} else {
panel.classList.remove('fx-anim-open');
panel.classList.add('fx-anim-min');
setTimeout(() => { panel.style.display = 'none'; }, 280);
}
}
document.body.appendChild(btn);
document.body.appendChild(panel);
}
createUI();
})();