NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Anime Vault - Smart Anime List Filter for Streaming Sites
// @namespace https://github.com/hamzaharoon1314/Anime-Vault/blob/main/script.js
// @version 3.1.4
// @description Filter anime on by status. Choose exactly which statuses (Watching, Planning, Completed, Dropped, On Hold) to show or hide.
// @author Hamza Haroon
// @license MIT
// @match https://yugenanime.*/*
// @match https://yugenanime.tv/*
// @match https://yugenanime.sx/*
// @match https://anitaku.*/*
// @match https://anitaku.pe/*
// @match https://gogoanime.*/*
// @match https://gogoanime.tv/*
// @match https://gogoanime3.*/*
// @match https://gogoanime3.co/*
// @match https://animepahe.*/
// @match https://animepahe.si/
// @match https://animesuge.to/*
// @match https://animesuge.*/*
// @match https://*animesuge.cc/*
// @match https://www.miruro.*/*
// @match https://www.miruro.tv/*
// @match https://miruro.to/*
// @match https://miruro.online/*
// @match https://animekai.to/*
// @match https://animekai.*/*
// @match https://anigo.to/*
// @match https://anikai.to/*
// @match https://anikai.*/*
// @match https://animetsu.cc/*
// @match https://kaido.to/*
// @match https://kuudere.to/*
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_notification
// @grant GM.xmlHttpRequest
// @connect graphql.anilist.co
// @connect api.myanimelist.net
// @downloadURL https://github.com/hamzaharoon1314/Anime-Vault/raw/refs/heads/main/script.js
// ==/UserScript==
'use strict';
/***************************************************************
* CONSTANTS & KEYS
***************************************************************/
const SETTINGS_KEY = 'anifilter_settings';
const LIBRARY_KEY = 'anifilter_library';
const SERVICE_KEY = 'anifilter_service';
const REFRESH_KEY = 'anifilter_lastRefresh';
const ENABLED_KEY = 'anifilter_enabled';
// Canonical status IDs used internally (service-agnostic)
const STATUS = {
WATCHING: 'watching',
PLANNING: 'planning',
COMPLETED: 'completed',
DROPPED: 'dropped',
ON_HOLD: 'on_hold',
};
const STATUS_LABELS = {
[STATUS.WATCHING]: 'Watching',
[STATUS.PLANNING]: 'Plan to Watch',
[STATUS.COMPLETED]: 'Completed',
[STATUS.DROPPED]: 'Dropped',
[STATUS.ON_HOLD]: 'On Hold',
};
// Default filter config: which statuses are SHOWN (true) vs HIDDEN (false/dimmed)
const DEFAULT_FILTER = {
[STATUS.WATCHING]: true, // show
[STATUS.PLANNING]: true, // show
[STATUS.COMPLETED]: false, // hide
[STATUS.DROPPED]: false, // hide
[STATUS.ON_HOLD]: true, // show
};
/***************************************************************
* ANIME SITES
***************************************************************/
const ANIME_SITES = [{
name: 'yugenanime',
url: ['yugenanime.tv', 'yugenanime.sx', 'yugenanime'],
item: '.ep-grid > li',
title: '.ep-origin-name',
thumbnail: '.ep-thumbnail > img'
},
{
name: 'gogoanime',
url: ['gogoanime3', 'gogoanimehd', 'gogoanime', 'anitaku'],
item: '.items > li',
title: '.name > a',
thumbnail: '.img > a > img'
},
{
name: 'animepahe',
url: ['animepahe.si', 'animepahe.ru', 'animepahe.com', 'animepahe'],
item: '.episode-wrap > .episode',
title: '.episode-title > a',
thumbnail: '.episode-snapshot > img',
observe: '.episode-list-wrapper',
timeout: 100
},
{
name: 'animesuge',
url: ['animesuge.to'],
item: '.item',
title: '.name > a',
thumbnail: '.poster img'
},
{
name: 'animesuge',
url: ['animesuge.cc'],
item: '.itemlist > li',
title: '.name a',
thumbnail: '.poster > img'
},
{
name: 'animesuge',
url: ['animesuge.su'],
item: '.bs',
title: '.tt',
thumbnail: 'img'
},
{
name: 'miruro',
url: ['miruro'],
item: 'a[color][title][href^="/watch"]',
title: 'h5[title^="Title: "]',
thumbnail: 'img[alt^="Play "]',
observe: 'section[aria-labelledby*="continueWatching"] + div',
timeout: 1200
},
{
name: 'animekai',
url: ['animekai.to', 'animekai', 'anikai.to', 'anikai'],
item: '.aitem',
title: '.title',
thumbnail: 'img',
observe: '.tab-body',
},
{
name: 'gojo',
url: ['animetsu.cc'],
item: '.mx-auto > [title], .swiper-slide[title]',
title: 'a.font-medium, div.text-xs.tracking-wide',
thumbnail: 'img',
observe: 'main',
timeout: 700
},
{
name: 'kaido',
url: ['kaido.to'],
item: '.flw-item',
title: '.film-name > a',
thumbnail: '.film-poster > img',
},
{
name: 'kuudere',
url: ['kuudere.to'],
item: '.anime-card-wrapper',
title: '.title',
thumbnail: 'img',
timeout: 700
}
];
/***************************************************************
* NETWORK HELPERS
* Always use GM.xmlHttpRequest directly — never axios or GM_fetch.
* This bypasses page-context CORS restrictions entirely and works
* reliably on every re-import.
***************************************************************/
/**
* Wrap GM.xmlHttpRequest in a Promise.
* @param {string} method - 'GET' | 'POST'
* @param {string} url
* @param {object} headers - key/value pairs
* @param {string|null} body - serialized body for POST
* @param {number} retries - retries on transient failure
*/
function gmRequest(method, url, headers = {}, body = null, retries = 3) {
return new Promise((resolve, reject) => {
const attempt = (attemptsLeft) => {
GM.xmlHttpRequest({
method,
url,
headers: {
'Content-Type': 'application/json',
...headers
},
data: body,
timeout: 20000,
onload(resp) {
if (resp.status >= 200 && resp.status < 300) {
try {
resolve(JSON.parse(resp.responseText));
}
catch (e) {
reject(new Error(`JSON parse error: ${e.message}\nPreview: ${resp.responseText.slice(0, 200)}`));
}
}
else if (resp.status === 429 && attemptsLeft > 0) {
// Rate-limited: back off 2s then retry
setTimeout(() => attempt(attemptsLeft - 1), 2000);
}
else {
reject(new Error(`HTTP ${resp.status} — ${url}`));
}
},
onerror(e) {
if (attemptsLeft > 0) setTimeout(() => attempt(attemptsLeft - 1), 1500);
else reject(new Error(`Network error — ${url}: ${e.error || 'unknown'}`));
},
ontimeout() {
if (attemptsLeft > 0) setTimeout(() => attempt(attemptsLeft - 1), 1500);
else reject(new Error(`Timeout — ${url}`));
}
});
};
attempt(retries);
});
}
const gmGet = (url, headers = {}) => gmRequest('GET', url, headers, null);
const gmPost = (url, body, headers = {}) => gmRequest('POST', url, headers, JSON.stringify(body));
/** Run async tasks one-by-one (sequential) to respect rate limits */
async function sequential(tasks) {
const results = [];
for (const task of tasks) results.push(await task());
return results;
}
/***************************************************************
* SERVICES
* Each service maps its own status strings to canonical STATUS IDs
* and fetches the full library sequentially to avoid rate limits.
***************************************************************/
const SERVICES = [{
name: 'AniList',
icon: 'https://anilist.co/img/icons/android-chrome-512x512.png',
// Map canonical → AniList status string
statusMap: {
[STATUS.WATCHING]: 'CURRENT',
[STATUS.PLANNING]: 'PLANNING',
[STATUS.COMPLETED]: 'COMPLETED',
[STATUS.DROPPED]: 'DROPPED',
[STATUS.ON_HOLD]: 'PAUSED',
},
apiBaseUrl: 'https://graphql.anilist.co',
async fetchStatus(username, anilistStatus, canonicalStatus) {
let page = 1,
entries = [],
hasNextPage = true;
while (hasNextPage) {
const query = `
query {
Page(page:${page}, perPage:50) {
pageInfo { hasNextPage currentPage }
mediaList(userName:"${username}", type:ANIME, status:${anilistStatus}) {
media { title { romaji english native userPreferred } }
}
}
}
`;
const data = await gmPost(this.apiBaseUrl, {
query
});
if (data.errors) throw new Error(`AniList error: ${data.errors[0].message}`);
const page_data = data.data.Page;
entries = entries.concat(page_data.mediaList);
hasNextPage = page_data.pageInfo.hasNextPage;
page = page_data.pageInfo.currentPage + 1;
}
return entries.map(entry => {
const titles = Object.values(entry.media.title).filter(Boolean);
return new AnimeEntry(titles, canonicalStatus);
});
},
async getFullLibrary(username) {
const tasks = Object.entries(this.statusMap).map(
([canonical, alStatus]) => () => this.fetchStatus(username, alStatus, canonical)
);
const results = await sequential(tasks);
return results.flat();
}
},
{
name: 'MyAnimeList',
icon: 'https://image.myanimelist.net/ui/OK6W_koKDTOqqqLDbIoPAiC8a86sHufn_jOI-JGtoCQ',
_clientId: '0fa773846da212822f7bed55971f9645',
statusMap: {
[STATUS.WATCHING]: 'watching',
[STATUS.PLANNING]: 'plan_to_watch',
[STATUS.COMPLETED]: 'completed',
[STATUS.DROPPED]: 'dropped',
[STATUS.ON_HOLD]: 'on_hold',
},
apiBaseUrl: 'https://api.myanimelist.net/v2/users',
async fetchStatus(username, malStatus, canonicalStatus) {
const url = `${this.apiBaseUrl}/${username}/animelist?fields=alternative_titles&status=${malStatus}&limit=1000`;
const data = await gmGet(url, {
'X-MAL-CLIENT-ID': this._clientId
});
return (data.data || []).map(entry => {
const altTitles = entry.node.alternative_titles || {};
const titles = [
entry.node.title,
...Object.values(altTitles).flat()
].filter(Boolean);
return new AnimeEntry(titles, canonicalStatus);
});
},
async getFullLibrary(username) {
const tasks = Object.entries(this.statusMap).map(
([canonical, malStatus]) => () => this.fetchStatus(username, malStatus, canonical)
);
const results = await sequential(tasks);
return results.flat();
}
}
];
/***************************************************************
* DATA CLASSES
***************************************************************/
class AnimeEntry {
constructor(titles, status) {
this.titles = titles; // array of title strings (inc. alt titles)
this.status = status; // canonical STATUS key
}
}
class AnimeLibrary {
constructor() {
// entries: AnimeEntry[]
this.entries = GM_getValue(LIBRARY_KEY, []);
}
save() {
GM_setValue(LIBRARY_KEY, this.entries);
}
clear() {
this.entries = [];
}
addEntries(entries) {
this.entries = this.entries.concat(entries);
}
// Get the canonical status of a title (fuzzy match), or null
getStatus(title) {
const match = this._fuzzyFind(title);
return match ? match.status : null;
}
_fuzzyFind(title) {
const threshold = 0.8;
const a = title.toLowerCase();
return this.entries.find(e =>
e.titles.some(t => jaroWinkler(a, t.toLowerCase()) >= threshold)
);
}
}
class Settings {
constructor() {
const saved = GM_getValue(SETTINGS_KEY, {});
this.filter = Object.assign({}, DEFAULT_FILTER, saved.filter || {});
this.usernames = saved.usernames || {};
this.dimOpacity = saved.dimOpacity !== undefined ? saved.dimOpacity : 0.12;
}
save() {
GM_setValue(SETTINGS_KEY, {
filter: this.filter,
usernames: this.usernames,
dimOpacity: this.dimOpacity,
});
}
}
/***************************************************************
* JARO-WINKLER SIMILARITY
***************************************************************/
function jaroWinkler(a, b) {
const m = a.length,
n = b.length;
if (m === 0 && n === 0) return 1;
if (m === 0 || n === 0) return 0;
const max = Math.floor(Math.max(m, n) / 2) - 1;
const ma = Array(m).fill(false),
mb = Array(n).fill(false);
let mtc = 0;
for (let i = 0; i < m; i++) {
const s = Math.max(0, i - max),
e = Math.min(n, i + max + 1);
for (let j = s; j < e; j++) {
if (!mb[j] && a[i] === b[j]) {
ma[i] = true;
mb[j] = true;
mtc++;
break;
}
}
}
if (mtc === 0) return 0;
let tr = 0,
k = 0;
for (let i = 0; i < m; i++) {
if (ma[i]) {
while (!mb[k]) k++;
if (a[i] !== b[k]) tr++;
k++;
}
}
return (mtc / m + mtc / n + (mtc - tr / 2) / mtc) / 3;
}
/***************************************************************
* WEBSITE CLASS
***************************************************************/
class Website {
constructor(site) {
this.site = site;
GM_addStyle(`
${site.item} ${site.thumbnail}:hover {
opacity: 1 !important;
filter: brightness(1) !important;
transition: .15s ease-in-out !important;
}
`);
}
getAnimeItems() {
return document.querySelectorAll(this.site.item);
}
getAnimeTitle(animeItem) {
const titleEl = animeItem.querySelector(this.site.title);
return titleEl ?
Array.from(titleEl.childNodes)
.filter(n => n.nodeType === Node.TEXT_NODE)
.map(n => n.textContent.trim())
.join('').trim() :
'';
}
// Restore all items to full visibility (used when filter is disabled)
clearFilter() {
this.getAnimeItems().forEach(item => {
const thumbnail = item.querySelector(this.site.thumbnail);
if (thumbnail) thumbnail.style.cssText = 'opacity:1; filter:brightness(1); transition:.3s ease-in-out';
item.removeAttribute('data-anifilter-status');
});
}
applyFilter(library, settings) {
if (!enabled) return this.clearFilter();
this.getAnimeItems().forEach(item => {
const thumbnail = item.querySelector(this.site.thumbnail);
if (!thumbnail) return;
const title = this.getAnimeTitle(item);
if (!title) return;
// Look up status from library
const effectiveStatus = library.getStatus(title);
let shouldShow;
if (effectiveStatus === null) {
// Not in list at all — show by default
shouldShow = true;
}
else {
shouldShow = settings.filter[effectiveStatus] !== false;
}
const op = settings.dimOpacity;
thumbnail.style.cssText = shouldShow ?
'opacity:1; filter:brightness(1); transition:.2s ease-in-out' :
`opacity:${op}; filter:brightness(${op * 1.5}) grayscale(0.9); transition:.4s ease-in-out`;
// Add a data attribute so the UI tooltip can show status
item.setAttribute('data-anifilter-status', effectiveStatus || 'unknown');
});
}
}
/***************************************************************
* GLOBAL STATE
***************************************************************/
let settings = new Settings();
let library = new AnimeLibrary();
let service = SERVICES[parseInt(GM_getValue(SERVICE_KEY, 0))]; // default: AniList (index 0)
let enabled = GM_getValue(ENABLED_KEY, true); // master on/off switch
/***************************************************************
* SETTINGS PANEL UI
***************************************************************/
function buildSettingsPanel() {
// Remove existing panel
document.getElementById('anifilter-panel')?.remove();
const STATUS_COLORS = {
[STATUS.WATCHING]: '#4fc3f7',
[STATUS.PLANNING]: '#a5d6a7',
[STATUS.COMPLETED]: '#ce93d8',
[STATUS.DROPPED]: '#ef9a9a',
[STATUS.ON_HOLD]: '#ffcc80',
};
// Count library entries per status
const counts = {};
Object.values(STATUS).forEach(s => counts[s] = 0);
library.entries.forEach(e => {
if (counts[e.status] !== undefined) counts[e.status]++;
});
const panel = document.createElement('div');
panel.id = 'anifilter-panel';
panel.innerHTML = `
<div id="af-header">
<span id="af-logo">⬡</span>
<span id="af-title">AniFilter</span>
<button id="af-power" class="${enabled ? 'on' : 'off'}" title="${enabled ? 'Disable filter' : 'Enable filter'}">
${enabled ? '⏻' : '⏻'}
</button>
<button id="af-close">✕</button>
</div>
<div id="af-section">
<div class="af-section-label">SERVICE</div>
<div id="af-services">
${SERVICES.map((s, i) => `
<button class="af-service-btn ${service.name === s.name ? 'active' : ''}" data-idx="${i}">
<img src="${s.icon}" width="14" height="14" style="border-radius:50%">
${s.name}
</button>
`).join('')}
</div>
</div>
<div id="af-section">
<div class="af-section-label">USERNAME</div>
<div id="af-username-row">
<input id="af-username-input" type="text" placeholder="Your ${service.name} username"
value="${settings.usernames[service.name] || ''}" spellcheck="false" />
<button id="af-refresh-btn" title="Import full library">⟳ Import</button>
</div>
<div id="af-lib-status"></div>
</div>
<div id="af-section">
<div class="af-section-label">SHOW / HIDE BY STATUS</div>
<div id="af-toggles">
${Object.values(STATUS).map(s => `
<label class="af-toggle-row" data-status="${s}">
<span class="af-status-dot" style="background:${STATUS_COLORS[s]}"></span>
<span class="af-status-name">${STATUS_LABELS[s]}</span>
<span class="af-status-count">${counts[s]}</span>
<div class="af-switch ${settings.filter[s] !== false ? 'on' : ''}">
<div class="af-switch-knob"></div>
</div>
</label>
`).join('')}
</div>
</div>
<div id="af-section">
<div class="af-section-label">DIM OPACITY <span id="af-opacity-val">${Math.round(settings.dimOpacity * 100)}%</span></div>
<input id="af-opacity-slider" type="range" min="0" max="40" value="${Math.round(settings.dimOpacity * 100)}" />
</div>
<div id="af-footer">
Last import: <span id="af-last-refresh">${formatLastRefresh()}</span>
</div>
`;
document.body.appendChild(panel);
injectPanelStyles();
bindPanelEvents(panel);
updateLibStatus();
}
function injectPanelStyles() {
const existing = document.getElementById('anifilter-styles');
if (existing) existing.remove();
const style = document.createElement('style');
style.id = 'anifilter-styles';
style.textContent = `
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;800&display=swap');
#anifilter-fab {
position: fixed;
bottom: 24px;
left: 24px;
width: 48px;
height: 48px;
border-radius: 14px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1.5px solid rgba(79, 195, 247, 0.4);
color: #4fc3f7;
font-size: 22px;
cursor: pointer;
z-index: 999998;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 24px rgba(79,195,247,0.18), 0 2px 8px rgba(0,0,0,0.5);
transition: all .2s ease;
}
#anifilter-fab:hover {
transform: scale(1.08) rotate(12deg);
border-color: #4fc3f7;
box-shadow: 0 4px 32px rgba(79,195,247,0.35);
}
#anifilter-fab.disabled {
border-color: rgba(239,154,154,0.4);
color: #ef9a9a;
box-shadow: 0 4px 24px rgba(239,154,154,0.12), 0 2px 8px rgba(0,0,0,0.5);
}
#anifilter-fab.disabled:hover {
border-color: #ef9a9a;
box-shadow: 0 4px 32px rgba(239,154,154,0.3);
}
#anifilter-panel {
position: fixed;
bottom: 84px;
left: 24px;
width: 320px;
max-height: 88vh;
overflow-y: auto;
background: #0d1117;
border: 1.5px solid rgba(79, 195, 247, 0.18);
border-radius: 18px;
z-index: 999999;
font-family: 'Syne', sans-serif;
color: #e0e0e0;
box-shadow: 0 8px 48px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04) inset;
scrollbar-width: thin;
scrollbar-color: #1e2d3d transparent;
}
#anifilter-panel::-webkit-scrollbar { width: 4px; }
#anifilter-panel::-webkit-scrollbar-thumb { background: #1e3a5f; border-radius: 4px; }
#af-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 18px 14px;
border-bottom: 1px solid rgba(255,255,255,0.06);
position: sticky;
top: 0;
background: #0d1117;
z-index: 1;
}
#af-logo {
font-size: 20px;
color: #4fc3f7;
line-height: 1;
}
#af-title {
font-weight: 800;
font-size: 15px;
letter-spacing: 0.08em;
color: #fff;
flex: 1;
}
#af-close {
background: none;
border: none;
color: #555;
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
transition: color .15s;
}
#af-close:hover { color: #ef9a9a; }
#af-power {
background: none;
border: 1.5px solid rgba(255,255,255,0.12);
border-radius: 8px;
font-size: 15px;
cursor: pointer;
padding: 2px 7px;
transition: all .15s;
line-height: 1;
}
#af-power.on { color: #4fc3f7; border-color: rgba(79,195,247,0.4); }
#af-power.off { color: #ef9a9a; border-color: rgba(239,154,154,0.4); }
#af-section {
padding: 14px 18px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.af-section-label {
font-family: 'Space Mono', monospace;
font-size: 9px;
letter-spacing: 0.18em;
color: #4fc3f7;
margin-bottom: 10px;
font-weight: 700;
}
#af-services {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.af-service-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border-radius: 8px;
border: 1.5px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.04);
color: #aaa;
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all .15s;
}
.af-service-btn:hover { border-color: #4fc3f7; color: #fff; }
.af-service-btn.active { border-color: #4fc3f7; background: rgba(79,195,247,0.12); color: #4fc3f7; }
#af-username-row {
display: flex;
gap: 6px;
}
#af-username-input {
flex: 1;
background: rgba(255,255,255,0.05);
border: 1.5px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #fff;
font-family: 'Space Mono', monospace;
font-size: 11px;
padding: 7px 10px;
outline: none;
transition: border-color .15s;
}
#af-username-input:focus { border-color: #4fc3f7; }
#af-refresh-btn {
padding: 7px 11px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #1565c0, #1e88e5);
color: #fff;
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
transition: opacity .15s, transform .1s;
}
#af-refresh-btn:hover { opacity: 0.9; transform: scale(1.03); }
#af-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
#af-lib-status {
font-family: 'Space Mono', monospace;
font-size: 10px;
color: #4fc3f7;
margin-top: 7px;
min-height: 14px;
}
#af-toggles {
display: flex;
flex-direction: column;
gap: 6px;
}
.af-toggle-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 8px;
border-radius: 8px;
transition: background .12s;
}
.af-toggle-row:hover { background: rgba(255,255,255,0.04); }
.af-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.af-status-name {
flex: 1;
font-size: 12px;
font-weight: 600;
}
.af-status-count {
font-family: 'Space Mono', monospace;
font-size: 10px;
color: #555;
min-width: 24px;
text-align: right;
}
.af-switch {
width: 34px;
height: 18px;
border-radius: 9px;
background: rgba(255,255,255,0.1);
border: 1.5px solid rgba(255,255,255,0.15);
position: relative;
transition: background .2s, border-color .2s;
flex-shrink: 0;
}
.af-switch.on { background: rgba(79,195,247,0.25); border-color: #4fc3f7; }
.af-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #555;
transition: transform .2s, background .2s;
}
.af-switch.on .af-switch-knob { transform: translateX(16px); background: #4fc3f7; }
#af-opacity-slider {
width: 100%;
accent-color: #4fc3f7;
cursor: pointer;
}
#af-opacity-val {
font-family: 'Space Mono', monospace;
font-size: 10px;
color: #aaa;
float: right;
}
#af-footer {
padding: 10px 18px;
font-family: 'Space Mono', monospace;
font-size: 9px;
color: #333;
}
/* Inline status badge on hover */
[data-anifilter-status]:hover::before {
content: attr(data-anifilter-status);
position: absolute;
top: 4px;
left: 4px;
background: rgba(0,0,0,0.8);
color: #4fc3f7;
font-size: 9px;
font-family: 'Space Mono', monospace;
padding: 2px 5px;
border-radius: 4px;
z-index: 10;
pointer-events: none;
text-transform: uppercase;
letter-spacing: 0.1em;
}
[data-anifilter-status] { position: relative; }
`;
document.head.appendChild(style);
}
function bindPanelEvents(panel) {
// Close button
panel.querySelector('#af-close').addEventListener('click', () => panel.remove());
// Power toggle
panel.querySelector('#af-power').addEventListener('click', () => {
enabled = !enabled;
GM_setValue(ENABLED_KEY, enabled);
// Update button appearance
const btn = panel.querySelector('#af-power');
btn.className = enabled ? 'on' : 'off';
btn.title = enabled ? 'Disable filter' : 'Enable filter';
// Update FAB appearance
syncFAB();
// Apply or clear filter immediately
executeAnimeFiltering();
});
// Service switcher
panel.querySelectorAll('.af-service-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
service = SERVICES[idx];
GM_setValue(SERVICE_KEY, idx);
panel.querySelector('#af-username-input').placeholder = `Your ${service.name} username`;
panel.querySelector('#af-username-input').value = settings.usernames[service.name] || '';
panel.querySelectorAll('.af-service-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateLibStatus();
});
});
// Refresh / Import button
panel.querySelector('#af-refresh-btn').addEventListener('click', async () => {
const username = panel.querySelector('#af-username-input').value.trim();
if (!username) {
setLibStatus('⚠ Enter a username first', '#ef9a9a');
return;
}
settings.usernames[service.name] = username;
settings.save();
await importFullLibrary();
buildSettingsPanel(); // rebuild to update counts
});
// Toggle rows
panel.querySelectorAll('.af-toggle-row').forEach(row => {
row.addEventListener('click', () => {
const status = row.dataset.status;
settings.filter[status] = !settings.filter[status];
settings.save();
const sw = row.querySelector('.af-switch');
sw.classList.toggle('on', settings.filter[status]);
executeAnimeFiltering();
});
});
// Opacity slider
const slider = panel.querySelector('#af-opacity-slider');
slider.addEventListener('input', () => {
const val = parseInt(slider.value) / 100;
settings.dimOpacity = val;
settings.save();
panel.querySelector('#af-opacity-val').textContent = `${slider.value}%`;
executeAnimeFiltering();
});
}
function setLibStatus(msg, color = '#4fc3f7') {
const el = document.getElementById('af-lib-status');
if (el) {
el.textContent = msg;
el.style.color = color;
}
}
function updateLibStatus() {
const total = library.entries.length;
if (total === 0) {
setLibStatus('No library imported yet.', '#555');
}
else {
const counts = {};
library.entries.forEach(e => {
counts[e.status] = (counts[e.status] || 0) + 1;
});
const summary = Object.entries(counts)
.map(([s, c]) => `${c} ${STATUS_LABELS[s] || s}`)
.join(' · ');
setLibStatus(`✓ ${total} total · ${summary}`);
}
}
function formatLastRefresh() {
const t = GM_getValue(REFRESH_KEY, 0);
if (!t) return 'Never';
const diff = Date.now() - t;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
/***************************************************************
* FLOATING ACTION BUTTON
***************************************************************/
// Sync FAB appearance to current enabled state
function syncFAB() {
const fab = document.getElementById('anifilter-fab');
if (!fab) return;
fab.classList.toggle('disabled', !enabled);
fab.title = enabled ? 'AniFilter Settings' : 'AniFilter (disabled) — click to open';
}
function buildFAB() {
if (document.getElementById('anifilter-fab')) return;
const fab = document.createElement('button');
fab.id = 'anifilter-fab';
fab.title = 'AniFilter Settings';
fab.style.display = 'none';
fab.textContent = '⬡';
fab.addEventListener('click', () => {
if (document.getElementById('anifilter-panel')) {
document.getElementById('anifilter-panel').remove();
}
else {
buildSettingsPanel();
}
});
document.body.appendChild(fab);
injectPanelStyles();
syncFAB();
}
/***************************************************************
* IMPORT FULL LIBRARY
***************************************************************/
async function importFullLibrary() {
const username = settings.usernames[service.name];
if (!username) {
setLibStatus('⚠ Set username first.', '#ef9a9a');
return;
}
setLibStatus('⟳ Importing full library...', '#ffcc80');
GM_notification(`Importing full ${service.name} library…`, 'AniFilter', service.icon);
try {
const entries = await service.getFullLibrary(username);
library.clear();
library.addEntries(entries);
library.save();
GM_setValue(REFRESH_KEY, Date.now());
const total = entries.length;
const counts = {};
entries.forEach(e => {
counts[e.status] = (counts[e.status] || 0) + 1;
});
const summary = Object.entries(counts)
.map(([s, c]) => `${c} ${STATUS_LABELS[s] || s}`)
.join(', ');
setLibStatus(`✓ ${total} anime imported`, '#a5d6a7');
GM_notification(`Imported ${total} anime: ${summary}`, 'AniFilter', service.icon);
console.log('[AniFilter] Library:', library.entries);
executeAnimeFiltering();
}
catch (err) {
console.error('[AniFilter] Import error:', err);
setLibStatus(`✗ Import failed: ${err.message}`, '#ef9a9a');
GM_notification(`Import failed: ${err.message}`, 'AniFilter', service.icon);
}
}
/***************************************************************
* FILTERING ENGINE
***************************************************************/
function executeAnimeFiltering() {
const animeSite = getCurrentSite();
if (!animeSite) return;
const thisSite = new Website(animeSite);
setTimeout(() => {
if (animeSite.observe) {
const observeTarget = document.querySelector(animeSite.observe);
if (observeTarget) {
let debounce;
new MutationObserver(() => {
clearTimeout(debounce);
debounce = setTimeout(() => thisSite.applyFilter(library, settings), 100);
}).observe(observeTarget, {
childList: true,
subtree: true,
attributeFilter: ['src']
});
}
}
thisSite.applyFilter(library, settings);
}, animeSite.timeout || 0);
}
function getCurrentSite() {
const url = window.location.href.toLowerCase();
return ANIME_SITES.find(site => site.url.some(u => url.includes(u)));
}
/***************************************************************
* MENU COMMAND (fallback for no FAB)
***************************************************************/
GM_registerMenuCommand('⬡ AniFilter Settings', () => {
const fab = document.getElementById('anifilter-fab');
if (fab) {
fab.style.display = fab.style.display === 'none' ? 'flex' : 'none';
}
buildSettingsPanel(); // also open panel immediately on menu click
});
GM_registerMenuCommand('⟳ AniFilter: Import Library', () => importFullLibrary());
/***************************************************************
* INIT
***************************************************************/
function init() {
// Handle SPA navigation
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
executeAnimeFiltering();
}
}).observe(document.body, {
subtree: true,
childList: true
});
window.addEventListener('popstate', executeAnimeFiltering);
window.addEventListener('pushstate', executeAnimeFiltering);
window.addEventListener('replacestate', executeAnimeFiltering);
buildFAB();
executeAnimeFiltering();
// Auto-refresh library every 1 day
const last = GM_getValue(REFRESH_KEY, 0);
if (Date.now() - last > 1 * 24 * 60 * 60 * 1000) {
console.log('[AniFilter] Auto-refreshing library (>1 days old)…');
importFullLibrary();
}
}
init();