// ==UserScript==
// @name Iwara NeoUI
// @namespace neoUI-iwara
// @version 0.2.2
// @description Enhanced UI for Iwara with tabbed layout, theater mode, customizable sections, and improved video page experience.
// @author Piperun
// @license LGPL-3.0-or-later
// @match https://www.iwara.tv/*
// @match https://iwara.tv/*
// @grant GM.getValue
// @grant GM.setValue
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(() => {
'use strict';
if (window.__IWARA_NEOUI_ACTIVE) return;
window.__IWARA_NEOUI_ACTIVE = true;
// ---- Constants ----
const TIMEOUTS = {
WAIT_FOR: 20000,
POLL_INTERVAL: 250,
SLEEP_SHORT: 500,
ASYNC_WAIT: 1000
};
const SELECTORS = {
VIDEO_COL: '.col-12.col-md-9',
PAGE_VIDEO_CONTENT: '.page-video__content',
COL_MD_9: '[class*="col-"][class*="md-9"]',
SIDEBAR: '.page-video__sidebar',
LIKES_LIST: '.likesList',
LIKED_BY: '.itw-liked-by',
RECS: '.itw-recs',
PLAYER_WRAP: '.itw-player-wrap',
TABBAR: '.itw-tabbar',
PANELS: '.itw-panels',
COIN_INDICATOR: '.navbar__coin'
};
const CSS_CLASSES = {
TABS_ACTIVE: 'itw-tabs-active',
THEATER: 'itw-theater',
ACTIVE: 'active'
};
// ---- DOM Cache ----
const domCache = {
cache: new Map(),
get(selector, root = document) {
const key = `${selector}:${root === document ? 'doc' : 'custom'}`;
if (this.cache.has(key)) {
const cached = this.cache.get(key);
// Verify element is still in DOM
if (cached && cached.isConnected) {
return cached;
}
this.cache.delete(key);
}
const element = root.querySelector(selector);
if (element) {
this.cache.set(key, element);
}
return element;
},
clear() {
this.cache.clear();
},
invalidate(selector) {
const keysToDelete = [];
for (const key of this.cache.keys()) {
if (key.startsWith(selector + ':')) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => this.cache.delete(key));
}
};
// ---- Observer Manager ----
const observerManager = {
observers: new Map(),
create(name, callback, options = { childList: true, subtree: true }) {
if (this.observers.has(name)) {
this.disconnect(name);
}
const observer = new MutationObserver(callback);
this.observers.set(name, observer);
return observer;
},
observe(name, target = document.documentElement, options = { childList: true, subtree: true }) {
const observer = this.observers.get(name);
if (observer) {
observer.observe(target, options);
}
},
disconnect(name) {
const observer = this.observers.get(name);
if (observer) {
observer.disconnect();
this.observers.delete(name);
}
},
disconnectAll() {
for (const [name, observer] of this.observers) {
observer.disconnect();
}
this.observers.clear();
}
};
// ---- Utilities ----
const dom = {
el(html) {
const t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstElementChild;
},
on(el, evt, cb) { el && el.addEventListener(evt, cb, { passive: true }); },
qs(sel, root = document) { return root.querySelector(sel); },
qsa(sel, root = document) { return [...root.querySelectorAll(sel)]; },
};
const sleep = (ms = TIMEOUTS.SLEEP_SHORT) => new Promise(r => setTimeout(r, ms));
const waitFor = async (selector, { root = document, timeout = TIMEOUTS.WAIT_FOR, interval = TIMEOUTS.POLL_INTERVAL } = {}) => {
const end = Date.now() + timeout;
while (Date.now() < end) {
const node = root.querySelector(selector);
if (node) return node;
await sleep(interval);
}
return null;
};
const addStyle = (css) => {
const s = document.createElement('style');
s.textContent = css;
document.documentElement.appendChild(s);
return s;
};
// Persistent observer for Likes relocation (initialized only when new UI is active)
let __itwLikesObserver = null;
// ---- Storage (GM or localStorage fallback) ----
const store = {
async get(key, def) {
try { if (typeof GM?.getValue === 'function') return await GM.getValue(key, def); } catch {}
try { const v = localStorage.getItem(`itw:${key}`); return v == null ? def : JSON.parse(v); } catch { return def; }
},
async set(key, val) {
try { if (typeof GM?.setValue === 'function') return await GM.setValue(key, val); } catch {}
try { localStorage.setItem(`itw:${key}`, JSON.stringify(val)); } catch {}
}
};
// ---- Defaults ----
const DEFAULTS = Object.freeze({
hideLikedBy: true,
theaterMode: false,
newUI: false,
showLikesTab: true,
showRecsTab: true,
});
let settings = { ...DEFAULTS };
const loadSettings = async () => {
try {
const saved = await store.get('settings', {});
// Validate saved settings
if (typeof saved !== 'object' || saved === null) {
console.warn('[Iwara NeoUI] Invalid settings format, using defaults');
settings = { ...DEFAULTS };
return;
}
// Validate individual setting types
const validatedSettings = { ...DEFAULTS };
for (const [key, value] of Object.entries(saved)) {
if (key in DEFAULTS) {
const expectedType = typeof DEFAULTS[key];
if (typeof value === expectedType) {
validatedSettings[key] = value;
} else {
console.warn(`[Iwara NeoUI] Invalid type for setting '${key}', expected ${expectedType}, got ${typeof value}`);
}
}
}
settings = validatedSettings;
// Migration: if legacy hideLikedBy was true and showLikesTab not explicitly set, hide the Likes tab by default
if (saved && 'hideLikedBy' in saved && !('showLikesTab' in saved)) {
settings.showLikesTab = !saved.hideLikedBy;
}
} catch (e) {
console.warn('[Iwara NeoUI] loadSettings failed:', e);
settings = { ...DEFAULTS };
}
};
const saveSettings = async () => {
try {
await store.set('settings', settings);
} catch (e) {
console.warn('[Iwara NeoUI] saveSettings failed:', e);
}
};
// ---- CSS for features ----
addStyle(`
/* Header button */
.itw-btn { display:inline-flex; align-items:center; gap:.35rem; padding:.35rem .55rem; border-radius:.5rem; border:1px solid rgba(255,255,255,.12); color:inherit; background:rgba(255,255,255,.06); cursor:pointer; font:600 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.itw-btn:hover { background:rgba(255,255,255,.12); }
.itw-gear { width:16px; height:16px; fill:currentColor; }
.itw-btn.itw-float { position: fixed; top: 12px; right: 12px; z-index: 2147483646; }
.itw-btn.itw-in-header { margin-left: 8px; }
/* Modal */
.itw-modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.5); display:none; z-index: 99999; }
.itw-modal { position:fixed; inset:auto auto 0 0; left:50%; top:50%; transform:translate(-50%,-50%); width:min(520px, calc(100vw - 24px)); background:#16181d; color:#e6e6e6; border:1px solid #2a2f36; border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,.45); padding:16px; display:none; z-index: 100000; }
.itw-modal h3 { margin:0 0 12px; font:600 16px/1.3 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.itw-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 0; border-top:1px solid #232831; }
.itw-row:first-of-type { border-top:none; }
.itw-actions { display:flex; gap:8px; justify-content:flex-end; padding-top:12px; }
.itw-switch { position:relative; width:44px; height:26px; border-radius:999px; background:#3a404b; transition:.2s; flex:0 0 auto; }
.itw-switch input { position:absolute; inset:0; opacity:0; }
.itw-knob { position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%; background:#c9c9c9; transition:.2s; }
.itw-switch input:checked + .itw-knob { left:21px; background:#64d36d; }
/* Feature styles */
.itw-hide-liked-by .itw-liked-by, .itw-hide-liked-by section:has(> h2.itw-liked-by-title) { display:none !important; }
/* Hide the likes block used by iwara.tv preview/site as well */
.itw-hide-liked-by .block:has(.likesList), .itw-hide-liked-by .likesList { display:none !important; }
/* Hide Recommended (More like this) when its tab is disabled in new UI */
.itw-hide-recs .itw-recs, .itw-hide-recs .moreLikeThis { display:none !important; }
/* When tabs UI is active, ensure any stray Recommended blocks are hidden outside the Recommended panel */
body.itw-tabs-active .moreLikeThis { display:none !important; }
body.itw-tabs-active .itw-panel-recs .moreLikeThis { display:block !important; }
/* Theater Mode */
.itw-theater body, body.itw-theater { overflow-y:auto; }
.itw-theater .itw-player-wrap, body.itw-theater .itw-player-wrap { width: 100% !important; max-width: 100% !important; margin: 0 auto !important; }
.itw-theater video, body.itw-theater video, .itw-theater .plyr, .itw-theater .jwplayer, .itw-theater .vjs-tech { width: 100% !important; height: 75vh !important; max-height: 86vh !important; }
.itw-theater aside, body.itw-theater aside { display: none !important; }
.itw-theater main, body.itw-theater main, .itw-theater .container, body.itw-theater .container { max-width: 100% !important; width: 100% !important; }
/* Tabs layout for video page */
body.itw-tabs-active .col-12.col-md-9 { max-width: 100% !important; flex: 0 0 100% !important; }
body.itw-tabs-active .page-video__sidebar { display: none !important; }
body.itw-tabs-active .page-video__player, body.itw-tabs-active .video-js, body.itw-tabs-active .vjs_video_3-dimensions { width: 100% !important; }
.itw-tabbar { display:flex; align-items:center; gap:8px; border-bottom:1px solid #2a2f36; margin-top:12px; }
.itw-tabbar .itw-tab { appearance:none; background:none; border:none; color:#e6e6e6; cursor:pointer; padding:10px 12px; font:600 13px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; border-bottom:2px solid transparent; opacity:.85; }
.itw-tabbar .itw-tab:hover { opacity:1; }
.itw-tabbar .itw-tab[aria-selected="true"] { color:#ff4b4b; border-color:#ff4b4b; opacity:1; }
.itw-panels { padding-top:12px; }
.itw-panel { display:none; }
.itw-panel.active { display:block; }
/* Safety: if for any reason tabs markup lingers but layout class is absent, hide the UI */
body:not(.itw-tabs-active) .itw-tabbar, body:not(.itw-tabs-active) .itw-panels { display:none !important; }
/* Force visibility for Likes content inside the Likes panel */
body.itw-tabs-active .itw-panel-likes .itw-liked-by,
body.itw-tabs-active .itw-panel-likes .block,
body.itw-tabs-active .itw-panel-likes .block__content,
body.itw-tabs-active .itw-panel-likes .likesList { display:block !important; visibility:visible !important; opacity:1 !important; height:auto !important; max-height:none !important; min-height:auto !important; width:auto !important; min-width:0 !important; }
/* Two-row layout: users row + pagination row */
body.itw-tabs-active .itw-panel-likes .block { display:flex !important; flex-direction:column !important; gap:16px !important; }
/* Override Bootstrap row layout for horizontal scrolling */
body.itw-tabs-active .itw-panel-likes .likesList .row { display:flex !important; flex-direction:row !important; flex-wrap:nowrap !important; gap:16px !important; overflow-x:auto !important; padding:8px 0 !important; margin:0 !important; }
/* Override Bootstrap columns to become flex items */
body.itw-tabs-active .itw-panel-likes .likesList .row > [class*="col-"] { flex:0 0 auto !important; width:160px !important; max-width:160px !important; min-width:160px !important; padding:0 !important; }
/* Individual user items - compact horizontal cards */
body.itw-tabs-active .itw-panel-likes .likesList__item { display:block !important; width:100% !important; height:100% !important; padding:8px 12px !important; background:rgba(255,255,255,0.05) !important; border-radius:8px !important; text-decoration:none !important; box-sizing:border-box !important; }
/* Inner content layout for anchor elements */
body.itw-tabs-active .itw-panel-likes .likesList__item > * { display:flex !important; }
/* Pagination row - centered below users */
body.itw-tabs-active .itw-panel-likes .pagination { display:flex !important; justify-content:center !important; margin-top:8px !important; }
/* Ensure user avatars are properly sized */
body.itw-tabs-active .itw-panel-likes .likesList img { width:60px !important; height:60px !important; border-radius:50% !important; object-fit:cover !important; }
/* Username styling for horizontal scrolling layout */
body.itw-tabs-active .itw-panel-likes .likesList .username,
body.itw-tabs-active .itw-panel-likes .likesList a,
body.itw-tabs-active .itw-panel-likes .likesList [class*="name"] { }
`);
// ---- Modal creation ----
let modalBackdrop, modalEl;
const createSwitch = (id, checked, label) => dom.el(`
<div class="itw-row">
<div>${label}</div>
<label class="itw-switch" for="${id}">
<input id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
<span class="itw-knob"></span>
</label>
</div>
`);
const ensureModal = () => {
try {
if (modalEl) return modalEl;
modalBackdrop = dom.el('<div class="itw-modal-backdrop"></div>');
modalEl = dom.el('<div class="itw-modal" role="dialog" aria-modal="true"></div>');
const content = dom.el('<div></div>');
content.appendChild(dom.el('<h3>Iwara NeoUI — Settings</h3>'));
// Tabs visibility
const likesTabRow = createSwitch('itw-show-likes', settings.showLikesTab, 'Show "Likes" tab');
const recsTabRow = createSwitch('itw-show-recs', settings.showRecsTab, 'Show "Recommended" tab');
const theaterRow = createSwitch('itw-theater', settings.theaterMode, 'Enable Theater Mode');
const newUiRow = createSwitch('itw-new-ui', settings.newUI, 'Enable new UI (tabs + full-width video)');
const actions = dom.el('<div class="itw-actions"></div>');
const closeBtn = dom.el('<button class="itw-btn" type="button">Close</button>');
const saveBtn = dom.el('<button class="itw-btn" type="button">Save</button>');
actions.append(closeBtn, saveBtn);
content.append(likesTabRow, recsTabRow, theaterRow, newUiRow, actions);
modalEl.append(content);
document.body.append(modalBackdrop, modalEl);
dom.on(closeBtn, 'click', () => toggleModal(false));
dom.on(modalBackdrop, 'click', () => toggleModal(false));
dom.on(saveBtn, 'click', async () => {
const prev = { newUI: settings.newUI, showLikesTab: settings.showLikesTab, showRecsTab: settings.showRecsTab };
settings.showLikesTab = modalEl.querySelector('#itw-show-likes')?.checked ?? settings.showLikesTab;
settings.showRecsTab = modalEl.querySelector('#itw-show-recs')?.checked ?? settings.showRecsTab;
settings.theaterMode = modalEl.querySelector('#itw-theater')?.checked ?? settings.theaterMode;
settings.newUI = modalEl.querySelector('#itw-new-ui')?.checked ?? settings.newUI;
const hadTabs = !!document.querySelector('.itw-tabbar');
const newUiChanged = prev.newUI !== settings.newUI;
await saveSettings();
applySettings();
// If staying in New UI and tab composition changed, rebuild tabs
const tabsChanged = prev.showLikesTab !== settings.showLikesTab || prev.showRecsTab !== settings.showRecsTab;
if (!newUiChanged && hadTabs && settings.newUI && tabsChanged) {
teardownVideoTabs();
}
if (newUiChanged) {
// Force a single reload to guarantee full revert/apply of layout across SPA hydration
location.reload();
return;
}
applyUiMode();
toggleModal(false);
});
return modalEl;
} catch (e) {
console.warn('[Iwara NeoUI] ensureModal failed:', e);
return null;
}
};
const toggleModal = (show) => {
ensureModal();
modalBackdrop.style.display = show ? 'block' : 'none';
modalEl.style.display = show ? 'block' : 'none';
};
// ---- Insert header button (before coin indicator when possible) ----
const GEAR_SVG = '<svg class="itw-gear" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.14,12.94a7.43,7.43,0,0,0,.05-.94,7.43,7.43,0,0,0-.05-.94l2-1.56a.5.5,0,0,0,.12-.64l-1.9-3.29a.5.5,0,0,0-.6-.22l-2.36,1a7.39,7.39,0,0,0-1.63-.94l-.36-2.5A.5.5,0,0,0,12.47,2H9.53a.5.5,0,0,0-.5.42l-.36,2.5a7.39,7.39,0,0,0-1.63.94l-2.36-1a.5.5,0,0,0-.6.22L2.22,8.88a.5.5,0,0,0,.12.64l2,1.56a7.43,7.43,0,0,0-.05.94,7.43,7.43,0,0,0,.05.94l-2,1.56a.5.5,0,0,0-.12.64l1.9,3.29a.5.5,0,0,0,.6.22l2.36-1a7.39,7.39,0,0,0,1.63.94l.36,2.5a.5.5,0,0,0,.5.42h2.94a.5.5,0,0,0,.5-.42l.36-2.5a7.39,7.39,0,0,0,1.63-.94l2.36,1a.5.5,0,0,0,.6-.22l1.9-3.29a.5.5,0,0,0-.12-.64ZM11,15.5A3.5,3.5,0,1,1,14.5,12,3.5,3.5,0,0,1,11,15.5Z"/></svg>';
const makeHeaderBtn = () => dom.el(`<button id="itw-settings-btn" class="itw-btn" type="button" title="Iwara NeoUI">${GEAR_SVG}<span>Options</span></button>`);
const insertNextToSearch = (btn) => {
const header = document.querySelector('header, nav[role="navigation"], .header, [class*="header"]');
if (!header) return { placed: false, anchorEl: null };
// Prefer the container that wraps the search UI
const searchContainer = header.querySelector('.header__content__items__search, [class*="items__search" i], .header__search, [role="search"]');
if (searchContainer?.parentElement) {
// Already placed correctly?
if (btn.previousElementSibling === searchContainer) {
return { placed: true, anchorEl: searchContainer };
}
searchContainer.insertAdjacentElement('afterend', btn);
return { placed: true, anchorEl: searchContainer };
}
// Fallback: try to locate a search form and place after it
const searchForm = header.querySelector('form.header__search, form[action*="search" i]') || header.querySelector('input[type="search"]')?.closest('form');
if (searchForm?.parentElement) {
searchForm.insertAdjacentElement('afterend', btn);
return { placed: true, anchorEl: searchForm };
}
// Final fallback: append into a central items container or header
const items = header.querySelector('.header__content__items, [class*="content__items" i]') || header;
if (btn.parentElement !== items) items.appendChild(btn);
return { placed: false, anchorEl: items };
};
const ensureHeaderButton = async () => {
try {
let btn = document.getElementById('itw-settings-btn');
if (!btn) {
btn = makeHeaderBtn();
dom.on(btn, 'click', () => { ensureModal(); toggleModal(true); });
btn.classList.add('itw-float');
document.body.appendChild(btn);
}
let debounce = null;
const reanchor = () => {
// Ensure the button exists in the DOM
if (!btn.isConnected) document.body.appendChild(btn);
const { placed } = insertNextToSearch(btn);
if (placed) {
btn.classList.remove('itw-float');
btn.classList.add('itw-in-header');
} else {
btn.classList.add('itw-float');
btn.classList.remove('itw-in-header');
}
};
// Initial attempt
reanchor();
// Observe DOM continuously (SPA hydration or header rerenders)
const mo = new MutationObserver(() => {
if (debounce) return;
debounce = setTimeout(() => { debounce = null; reanchor(); }, 300);
});
mo.observe(document.documentElement, { childList: true, subtree: true });
// Handle SPA route changes (URL swap without full reload)
let lastUrl = location.href;
setInterval(() => {
const now = location.href;
if (now !== lastUrl) {
lastUrl = now;
domCache.clear(); // Clear cache on navigation
observerManager.disconnect('likes'); // Clean up page-specific observers
reanchor();
// Ensure UI mode matches current settings and page type on route change
applyUiMode();
}
}, 700);
// Additional safety hooks
window.addEventListener('load', reanchor, { once: true });
document.addEventListener('visibilitychange', () => { if (!document.hidden) reanchor(); });
} catch (e) {
console.warn('[Iwara NeoUI] ensureHeaderButton failed:', e);
}
};
// ---- Liked by detection and toggle ----
const LIKED_BY_TEXTS = [
'liked by','likes by','liked-by','liked',
'喜欢', '赞过',
'いいね',
'좋아요',
];
const findLikedBySection = () => {
try {
// Prefer a structural hook used on iwara: the likes list grid
const likesList = dom.qs('.likesList');
if (likesList) {
const container = likesList.closest('.block, .contentBlock, .block--padding, .card, .panel') || likesList.parentElement;
if (container) {
container.classList.add('itw-liked-by');
// If tabs are active and Likes tab is shown, move into the Likes panel
const likesPanel = settings.newUI && document.querySelector('.itw-panel-likes');
if (likesPanel && settings.showLikesTab && !likesPanel.contains(container)) {
likesPanel.append(container);
}
return container;
}
}
// Generic fallback: scan typical containers and look for a heading-like element
const candidates = dom.qsa('section, .section, .block, .card, .panel, div');
for (const el of candidates) {
const title = el.querySelector('h2, h3, header, .title, [class*="title"], .text--h3, .text.text--h3');
const text = (title?.textContent || '').trim().toLowerCase();
if (text && LIKED_BY_TEXTS.some(t => text.includes(t))) {
el.classList.add('itw-liked-by');
const likesPanel = settings.newUI && document.querySelector('.itw-panel-likes');
if (likesPanel && settings.showLikesTab && !likesPanel.contains(el)) {
likesPanel.append(el);
}
return el;
}
const avatars = el.querySelectorAll('img[alt*="avatar" i], img[alt*="user" i], img[referrerpolicy], .avatar');
if (avatars.length >= 6 && el.querySelectorAll('button, a').length < 6) {
el.classList.add('itw-liked-by');
const likesPanel = settings.newUI && document.querySelector('.itw-panel-likes');
if (likesPanel && settings.showLikesTab && !likesPanel.contains(el)) {
likesPanel.append(el);
}
return el;
}
}
return null;
} catch (e) {
console.warn('[Iwara NeoUI] findLikedBySection failed:', e);
return null;
}
};
const applyHideLikedBy = () => {
const root = document.documentElement;
// Vanilla mode should be vanilla: never hide on newUI=false
const hide = settings.newUI ? !settings.showLikesTab : false;
root.classList.toggle('itw-hide-liked-by', hide);
};
// Tag and hide Recommended (More like this)
const findRecsSection = () => {
try {
const el = dom.qs('.moreLikeThis') || [...document.querySelectorAll('.text--h3, h2, h3')].find(h => /more like this|recommended|related/i.test(h.textContent || ''))?.closest('.block, .contentBlock, .panel, .card, .section, .moreLikeThis');
if (el) { el.classList.add('itw-recs'); return el; }
return null;
} catch (e) {
console.warn('[Iwara NeoUI] findRecsSection failed:', e);
return null;
}
};
const applyHideRecs = () => {
const root = document.documentElement;
const hide = settings.newUI ? !settings.showRecsTab : false;
root.classList.toggle('itw-hide-recs', hide);
};
// ---- Theater Mode ----
const applyTheater = () => {
try {
const root = document.body || document.documentElement;
root.classList.toggle('itw-theater', !!settings.theaterMode);
const knownPlayerWrap = dom.qs('.plyr__video-wrapper, .jwplayer, .video-js, .vjs, #player, .video-player, [class*="player"]');
if (knownPlayerWrap) knownPlayerWrap.classList.add('itw-player-wrap');
else {
const video = dom.qs('video');
if (video) video.closest('div')?.classList.add('itw-player-wrap');
}
} catch (e) {
console.warn('[Iwara NeoUI] applyTheater failed:', e);
}
};
const toggleTheater = () => { settings.theaterMode = !settings.theaterMode; applyTheater(); saveSettings(); };
// ---- Keyboard shortcut (T) for Theater Mode ----
dom.on(document, 'keydown', (e) => {
if (e.key.toLowerCase?.() === 't' && !/input|textarea|select/i.test(e.target.tagName)) {
toggleTheater();
}
});
// ---- Apply settings helpers ----
const applySettings = () => {
applyHideLikedBy();
applyHideRecs();
applyTheater();
};
// ---- Page type detection ----
const isVideoPage = () => {
// Quick route-based hint
const path = location.pathname;
if (/^\/(video|videos)\//i.test(path)) return true;
if (/^\/(search|users|image|images|posts|forums|messages|notifications|settings|login|register)\b/i.test(path)) return false;
// Structural markers: presence of a player and typical video-page sections
const hasPlayer = !!document.querySelector('.page-video__player, .plyr__video-wrapper, .video-js, .jwplayer, [data-plyr], video');
const hasMarkers = !!document.querySelector('.page-video__details, .moreFromUser, .moreLikeThis, .page-video__bottom, .page-video__tags, #comments, .comments');
return hasPlayer && hasMarkers;
};
// ---- Tabs: About / Uploads / Recommended / Likes / Comments ----
const setupVideoTabs = () => {
try {
// Do not build tabs in vanilla mode
if (!settings.newUI) return;
// Safety: if previous class lingered but no UI is present, clear it
if (!document.querySelector('.itw-tabbar') && !document.querySelector('.itw-panels')) {
document.body.classList.remove(CSS_CLASSES.TABS_ACTIVE);
}
if (document.querySelector('.itw-tabbar')) return; // already set up
if (!isVideoPage()) return; // only on actual video pages
const mainCol = domCache.get(SELECTORS.VIDEO_COL) || domCache.get(SELECTORS.PAGE_VIDEO_CONTENT) || domCache.get(SELECTORS.COL_MD_9);
if (!mainCol) return;
// Require a real player host; do not fall back to arbitrary first child
const playerHost = mainCol.querySelector('.page-video__player') ||
mainCol.querySelector('.video-js, .plyr__video-wrapper, .jwplayer, [data-plyr]')?.closest('.page-video__player') ||
mainCol.querySelector('.video-js, .plyr__video-wrapper, .jwplayer, [data-plyr]');
if (!playerHost) return;
// Panels
const panels = dom.el('<div class="itw-panels"></div>');
const aboutPanel = dom.el('<section class="itw-panel itw-panel-about" role="tabpanel" aria-labelledby="itw-tab-about"></section>');
const aboutBody = dom.el('<div class="itw-panel-body itw-about-body"></div>');
aboutPanel.append(aboutBody);
const uploadsPanel = dom.el('<section class="itw-panel itw-panel-uploads" role="tabpanel" aria-labelledby="itw-tab-uploads"></section>');
const recsPanel = dom.el('<section class="itw-panel itw-panel-recs" role="tabpanel" aria-labelledby="itw-tab-recs"></section>');
const likesPanel = dom.el('<section class="itw-panel itw-panel-likes" role="tabpanel" aria-labelledby="itw-tab-likes"></section>');
const commentsPanel = dom.el('<section class="itw-panel itw-panel-comments" role="tabpanel" aria-labelledby="itw-tab-comments"></section>');
panels.append(aboutPanel, uploadsPanel, recsPanel, likesPanel, commentsPanel);
// Tab bar
const tabbar = dom.el('<div class="itw-tabbar" role="tablist" aria-label="Iwara NeoUI Tabs"></div>');
const makeTab = (id, text, selected=false) => dom.el(`<button class="itw-tab" role="tab" id="itw-tab-${id}" aria-selected="${selected}" aria-controls="itw-panel-${id}">${text}</button>`);
const aboutTab = makeTab('about', 'About', true);
const uploadsTab = makeTab('uploads', 'Uploads');
const recsTab = makeTab('recs', 'Recommended');
const likesTab = makeTab('likes', 'Likes');
const commentsTab = makeTab('comments', 'Comments');
tabbar.append(aboutTab);
tabbar.append(uploadsTab);
if (settings.showRecsTab) tabbar.append(recsTab);
if (settings.showLikesTab) tabbar.append(likesTab);
tabbar.append(commentsTab);
// Mount tab UI before moving content to avoid ancestor insertion errors
if (playerHost && playerHost.parentElement === mainCol) {
mainCol.insertBefore(tabbar, playerHost.nextSibling);
} else {
mainCol.appendChild(tabbar);
}
mainCol.insertBefore(panels, tabbar.nextSibling);
// Move content into About panel in the desired order:
// 1) the main details div, 2) description, 3) tags, 4) bottom
const details = mainCol.querySelector('.page-video__details');
if (details) aboutBody.append(details);
// Description (prefer the full wrapper within details)
const descEl = (details?.querySelector('.showMore, .page-video__description, .description, .markdown')) ||
([...mainCol.querySelectorAll('.showMore, .page-video__description, .description, .markdown')].find(el => !el.closest('.comments')));
if (descEl) aboutBody.append(descEl.closest('.contentBlock') || descEl);
// Tags (prefer the wrapper within details)
const tagsWrap = (details?.querySelector('.page-video__tags')?.closest('.mt-4')) ||
mainCol.querySelector('.page-video__tags')?.closest('.mt-4') ||
mainCol.querySelector('.page-video__tags');
if (tagsWrap) aboutBody.append(tagsWrap);
// Bottom actions/stats row
const bottom = mainCol.querySelector('.page-video__bottom');
if (bottom) aboutBody.append(bottom);
const comments = mainCol.querySelector('.comments');
if (comments) commentsPanel.append(comments);
// Uploads from sidebar
const moreFrom = document.querySelector('.page-video__sidebar .moreFromUser');
if (moreFrom) {
const block = moreFrom.closest('.block, .contentBlock, .panel, .card') || moreFrom;
uploadsPanel.append(block);
}
// Recommended
const moreLike = dom.qs('.itw-recs') || dom.qs('.moreLikeThis') || [...document.querySelectorAll('.text--h3, h2, h3')].find(h => /more like this|recommended|related/i.test(h.textContent || ''))?.closest('.block, .contentBlock, .panel, .card, .section, .moreLikeThis');
if (moreLike && settings.showRecsTab) {
const block = moreLike.closest('.block, .contentBlock, .panel, .card, .section') || moreLike;
recsPanel.append(block);
}
// Likes: robust relocation (title + list) with persistent watcher
const nearestCommonAncestor = (a, b) => {
if (!a || !b) return null;
const aChain = new Set();
for (let n = a; n; n = n.parentElement) aChain.add(n);
for (let n = b; n; n = n.parentElement) if (aChain.has(n)) return n;
return null;
};
const moveLikesIntoPanel = () => {
if (!settings.showLikesTab) return;
const likesPanelNow = document.querySelector('.itw-panel-likes');
if (!likesPanelNow) return;
// Prefer structural hook
const list = document.querySelector('.likesList');
const title = [...document.querySelectorAll('.text--h3, .text.text--h3, h2, h3, .text.mb-2.text--h3.text--bold')]
.find(el => /liked by/i.test(el.textContent || ''));
let block = null;
// 1) If title and list share a .block__content ancestor, move that
const listBC = list?.closest('.block__content') || null;
const titleBC = title?.closest('.block__content') || null;
if (listBC && titleBC && listBC === titleBC) block = listBC;
// 2) Else, use nearest common ancestor if reasonable
if (!block && list && title) {
const nca = nearestCommonAncestor(list, title);
if (nca && !nca.classList.contains('itw-panels') && nca !== document.body) block = nca;
}
// 3) Else, prefer .block__content of either node
if (!block && listBC) block = listBC;
if (!block && titleBC) block = titleBC;
// 4) Else, fall back to typical blocks
if (!block && list) {
block = list.closest('.block, .contentBlock, .block--padding, .card, .panel')
|| (title ? title.parentElement : null)
|| list.parentElement
|| list;
}
if (!block && title) {
block = title.closest('.block, .contentBlock, .block--padding, .card, .panel') || title.parentElement || title;
}
// If we ended up at an inner .block__content, prefer its outer block/card container
if (block && block.matches('.block__content') && block.parentElement && block.parentElement.matches('.block, .contentBlock, .block--padding, .card, .panel, .section')) {
block = block.parentElement;
}
// If list exists but is empty, defer move until populated (site may lazy-render)
const hasListContent = !!list && (list.childElementCount > 0 || list.querySelector('*'));
if (!likesPanelNow.contains(block || document.createElement('div')) && list && !hasListContent) return;
if (block) {
try { block.classList.add('itw-liked-by'); } catch {}
const unhideDeep = (rootEl) => {
const stack = [rootEl];
while (stack.length) {
const el = stack.pop();
if (!el || el.nodeType !== 1) continue;
if (el.hasAttribute('hidden')) el.removeAttribute('hidden');
if (el.style) {
if (el.style.display === 'none') el.style.display = '';
if (el.style.visibility === 'hidden') el.style.visibility = '';
if (el.style.opacity === '0') el.style.opacity = '';
if (el.style.height === '0px') el.style.height = '';
if (el.style.maxHeight === '0px') el.style.maxHeight = '';
}
stack.push(...el.children);
}
};
unhideDeep(block);
if (!likesPanelNow.contains(block)) likesPanelNow.append(block);
}
};
// Initial attempts
moveLikesIntoPanel();
// Keep trying as site re-renders/paginates the Likes block
if (__itwLikesObserver) { try { __itwLikesObserver.disconnect(); } catch {} }
__itwLikesObserver = new MutationObserver(() => moveLikesIntoPanel());
__itwLikesObserver.observe(document.body, { childList: true, subtree: true });
// Ensure late-loaded Likes populate into the panel as soon as they appear
if (settings.showLikesTab) {
(async () => {
const node = await waitFor(`${SELECTORS.LIKES_LIST}, ${SELECTORS.LIKED_BY}`, { timeout: TIMEOUTS.WAIT_FOR, interval: TIMEOUTS.POLL_INTERVAL });
if (!node) return;
moveLikesIntoPanel();
})();
}
// Mount (already mounted above)
// playerHost.insertAdjacentElement('afterend', tabbar);
// tabbar.insertAdjacentElement('afterend', panels);
// Activate About by default
const activate = (which) => {
const btns = [aboutTab, uploadsTab];
if (settings.showRecsTab) btns.push(recsTab);
if (settings.showLikesTab) btns.push(likesTab);
btns.push(commentsTab);
for (const btn of btns) btn.setAttribute('aria-selected', String(btn === which));
const allPanels = [aboutPanel, uploadsPanel, recsPanel, likesPanel, commentsPanel];
for (const p of allPanels) p.classList.remove('active');
if (which === aboutTab) aboutPanel.classList.add('active');
else if (which === uploadsTab) uploadsPanel.classList.add('active');
else if (which === recsTab) recsPanel.classList.add('active');
else if (which === likesTab) likesPanel.classList.add('active');
else if (which === commentsTab) commentsPanel.classList.add('active');
};
activate(aboutTab);
dom.on(aboutTab, 'click', () => activate(aboutTab));
dom.on(uploadsTab, 'click', () => activate(uploadsTab));
if (settings.showRecsTab) dom.on(recsTab, 'click', () => activate(recsTab));
if (settings.showLikesTab) dom.on(likesTab, 'click', () => { moveLikesIntoPanel(); activate(likesTab); });
dom.on(commentsTab, 'click', () => activate(commentsTab));
document.body.classList.add(CSS_CLASSES.TABS_ACTIVE);
} catch (e) {
console.warn('[Iwara NeoUI] Tabs setup skipped:', e);
}
};
const teardownVideoTabs = () => {
try {
// Clean up observers
observerManager.disconnect('likes');
const tabbar = document.querySelector(SELECTORS.TABBAR);
const panels = document.querySelector(SELECTORS.PANELS);
// Do NOT return early — always ensure we clear layout class below
const mainCol = domCache.get(SELECTORS.VIDEO_COL) || domCache.get(SELECTORS.PAGE_VIDEO_CONTENT) || domCache.get(SELECTORS.COL_MD_9) || document.body;
const sidebar = domCache.get(SELECTORS.SIDEBAR);
if (panels) {
const aboutBody = panels.querySelector('.itw-about-body');
if (aboutBody) {
[...aboutBody.children].forEach(node => mainCol.appendChild(node));
}
const uploadsPanel = panels.querySelector('.itw-panel-uploads');
if (uploadsPanel && uploadsPanel.children.length) {
[...uploadsPanel.children].forEach(node => mainCol.appendChild(node));
}
const recsPanel = panels.querySelector('.itw-panel-recs');
if (recsPanel && recsPanel.children.length) {
const block = recsPanel.querySelector('.itw-recs');
if (block && sidebar) sidebar.appendChild(block);
else if (recsPanel.children.length) {
[...recsPanel.children].forEach(node => (sidebar || mainCol).appendChild(node));
}
}
const likesPanel = panels.querySelector('.itw-panel-likes');
if (likesPanel && likesPanel.children.length) {
const block = likesPanel.querySelector('.itw-liked-by');
if (block && sidebar) sidebar.appendChild(block);
else if (likesPanel.children.length) {
[...likesPanel.children].forEach(node => (sidebar || mainCol).appendChild(node));
}
}
const commentsPanel = panels.querySelector('.itw-panel-comments');
if (commentsPanel && commentsPanel.children.length) {
[...commentsPanel.children].forEach(node => mainCol.appendChild(node));
}
panels.remove();
}
if (tabbar) tabbar.remove();
// Always clear the layout class to avoid full-width/hidden-sidebar in vanilla mode
document.body.classList.remove(CSS_CLASSES.TABS_ACTIVE);
} catch (e) {
console.warn('[Iwara NeoUI] Tabs teardown skipped:', e);
// Still ensure layout class is not left behind on error paths
document.body.classList.remove('itw-tabs-active');
}
};
const applyUiMode = () => {
if (settings.newUI && isVideoPage()) {
setupVideoTabs();
} else {
teardownVideoTabs();
// Safety: explicitly ensure classes are removed when new UI is disabled
document.body.classList.remove('itw-tabs-active');
document.body.classList.remove('itw-theater');
// Ensure Likes observer is stopped in vanilla mode
if (__itwLikesObserver) { try { __itwLikesObserver.disconnect(); } catch {} __itwLikesObserver = null; }
}
};
// ---- Initialize ----
(async () => {
await loadSettings();
ensureModal();
await ensureHeaderButton();
findLikedBySection();
findRecsSection();
applySettings();
applyUiMode();
observerManager.create('main', () => {
// Clear cache periodically to avoid stale references
domCache.clear();
// Always correct stray class if UI not present
if (!document.querySelector(SELECTORS.TABBAR) && !document.querySelector(SELECTORS.PANELS)) {
document.body.classList.remove(CSS_CLASSES.TABS_ACTIVE);
}
// If we navigated away from a video page, ensure tabs are removed
if (!isVideoPage() && document.querySelector('.itw-tabbar')) teardownVideoTabs();
if (isVideoPage()) {
if (!document.querySelector(SELECTORS.LIKED_BY)) findLikedBySection();
if (!document.querySelector(SELECTORS.RECS)) findRecsSection();
if (!document.querySelector(SELECTORS.PLAYER_WRAP)) applyTheater();
if (settings.newUI && !document.querySelector(SELECTORS.TABBAR)) setupVideoTabs();
// Late-arriving Likes: move it into panel when it appears
if (settings.newUI && settings.showLikesTab) {
const likesPanel = domCache.get('.itw-panel-likes');
const likedNode = domCache.get(SELECTORS.LIKED_BY) || domCache.get(SELECTORS.LIKES_LIST);
if (likesPanel && likedNode) {
const block = likedNode.classList?.contains('itw-liked-by') ? likedNode : (likedNode.closest('.block, .contentBlock, .block--padding, .card, .panel') || likedNode);
if (!likesPanel.contains(block)) likesPanel.append(block);
}
}
}
// In vanilla mode, ensure no tabs or theater layout persist
if (!settings.newUI) {
if (document.querySelector('.itw-tabbar')) teardownVideoTabs();
document.body.classList.remove(CSS_CLASSES.THEATER);
}
});
observerManager.observe('main');
})();
})();