Enhanced UI for Iwara with tabbed layout, theater mode, customizable sections, and improved video page experience.
// ==UserScript==
// @name Iwara NeoUI
// @name:zh-CN Iwara NeoUI
// @name:zh-TW Iwara NeoUI
// @name:ja Iwara NeoUI
// @name:ko Iwara NeoUI
// @namespace codeberg.org/0xdev/neoUI-iwara
// @version 0.7.0
// @description Enhanced UI for Iwara with tabbed layout, theater mode, customizable sections, and improved video page experience.
// @description:zh-CN 为 Iwara 提供增强 UI:标签布局、影院模式、可自定义区块和改进的视频页面体验。
// @description:zh-TW 為 Iwara 提供增強 UI:分頁版面、劇院模式、可自訂區塊與改良的影片頁體驗。
// @description:ja Iwara にタブレイアウト、シネマモード、カスタマイズ可能なセクション、改善された動画ページ体験を追加します。
// @description:ko Iwara에 탭 레이아웃, 시네마 모드, 사용자 지정 섹션, 개선된 동영상 페이지 경험을 더하는 향상된 UI입니다.
// @author 0xdev
// @license LGPL-3.0-or-later
// @match https://www.iwara.tv/*
// @match https://iwara.tv/*
// @grant GM.getValue
// @grant GM.setValue
// @run-at document-start
// ==/UserScript==
// THIS FILE IS AUTO-GENERATED — DO NOT MANUALLY EDIT.
// Source: neoui/iwara-neoui.user.js
'use strict';
const SUPPORTED_LOCALES = ['en', 'zh-CN', 'zh-TW', 'ja', 'ko'];
const DEFAULTS = Object.freeze({
hideLikedBy: true,
theaterMode: false,
cinemaHideNav: false,
newUI: false,
showLikesTab: true,
showRecsTab: true,
language: 'auto',
});
const MESSAGES = {
en: {
'settings.button': 'Options',
'settings.title': 'Iwara NeoUI - Settings',
'settings.language': 'Language',
'settings.language.auto': 'Auto',
'settings.showLikes': 'Show "Likes" tab',
'settings.showRecs': 'Show "Recommended" tab',
'settings.hideNav': 'Hide navigation in cinema mode',
'settings.newUi': 'Enable new UI (tabs + full-width video)',
'actions.close': 'Close',
'actions.save': 'Save',
'tabs.about': 'About',
'tabs.uploads': 'Uploads',
'tabs.recs': 'Recommended',
'tabs.likes': 'Likes',
'tabs.comments': 'Comments',
'tabs.aria': 'Iwara NeoUI Tabs',
'cinema.button': 'Cinema',
'cinema.title': 'Toggle cinema mode (T)',
},
'zh-CN': {
'settings.button': '选项',
'settings.title': 'Iwara NeoUI - 设置',
'settings.language': '语言',
'settings.language.auto': '自动',
'settings.showLikes': '显示“喜欢”标签',
'settings.showRecs': '显示“推荐”标签',
'settings.hideNav': '影院模式下隐藏导航',
'settings.newUi': '启用新界面(标签页 + 全宽视频)',
'actions.close': '关闭',
'actions.save': '保存',
'tabs.about': '简介',
'tabs.uploads': '上传',
'tabs.recs': '推荐',
'tabs.likes': '喜欢',
'tabs.comments': '评论',
'tabs.aria': 'Iwara NeoUI 标签页',
'cinema.button': '影院',
'cinema.title': '切换影院模式 (T)',
},
'zh-TW': {
'settings.button': '選項',
'settings.title': 'Iwara NeoUI - 設定',
'settings.language': '語言',
'settings.language.auto': '自動',
'settings.showLikes': '顯示「喜歡」分頁',
'settings.showRecs': '顯示「推薦」分頁',
'settings.hideNav': '劇院模式下隱藏導覽列',
'settings.newUi': '啟用新介面(分頁 + 全寬影片)',
'actions.close': '關閉',
'actions.save': '儲存',
'tabs.about': '簡介',
'tabs.uploads': '上傳',
'tabs.recs': '推薦',
'tabs.likes': '喜歡',
'tabs.comments': '留言',
'tabs.aria': 'Iwara NeoUI 分頁',
'cinema.button': '劇院',
'cinema.title': '切換劇院模式 (T)',
},
ja: {
'settings.button': 'オプション',
'settings.title': 'Iwara NeoUI - 設定',
'settings.language': '言語',
'settings.language.auto': '自動',
'settings.showLikes': '「いいね」タブを表示',
'settings.showRecs': '「おすすめ」タブを表示',
'settings.hideNav': 'シネマモードでナビゲーションを隠す',
'settings.newUi': '新UIを有効化(タブ + 全幅動画)',
'actions.close': '閉じる',
'actions.save': '保存',
'tabs.about': '概要',
'tabs.uploads': '投稿',
'tabs.recs': 'おすすめ',
'tabs.likes': 'いいね',
'tabs.comments': 'コメント',
'tabs.aria': 'Iwara NeoUI タブ',
'cinema.button': 'シネマ',
'cinema.title': 'シネマモードを切り替え (T)',
},
ko: {
'settings.button': '옵션',
'settings.title': 'Iwara NeoUI - 설정',
'settings.language': '언어',
'settings.language.auto': '자동',
'settings.showLikes': '"좋아요" 탭 표시',
'settings.showRecs': '"추천" 탭 표시',
'settings.hideNav': '시네마 모드에서 내비게이션 숨기기',
'settings.newUi': '새 UI 사용(탭 + 전체 폭 동영상)',
'actions.close': '닫기',
'actions.save': '저장',
'tabs.about': '정보',
'tabs.uploads': '업로드',
'tabs.recs': '추천',
'tabs.likes': '좋아요',
'tabs.comments': '댓글',
'tabs.aria': 'Iwara NeoUI 탭',
'cinema.button': '시네마',
'cinema.title': '시네마 모드 전환 (T)',
},
};
let currentLocale = 'en';
const LANGUAGE_OPTIONS = [
{ value: 'auto', nativeLabel: '' },
{ value: 'en', nativeLabel: 'English' },
{ value: 'zh-CN', nativeLabel: '简体中文' },
{ value: 'zh-TW', nativeLabel: '繁體中文' },
{ value: 'ja', nativeLabel: '日本語' },
{ value: 'ko', nativeLabel: '한국어' },
];
function isLanguageSetting(value) {
return value === 'auto' || SUPPORTED_LOCALES.includes(value);
}
function normalizeLanguageTag(tag) {
return tag.trim().replace('_', '-').toLowerCase();
}
function localeFromLanguageTag(tag) {
const n = normalizeLanguageTag(tag);
if (n === 'zh-cn' || n === 'zh-sg' || n === 'zh-hans' || n.startsWith('zh-hans-'))
return 'zh-CN';
if (n === 'zh-tw' || n === 'zh-hk' || n === 'zh-mo' || n === 'zh-hant' || n.startsWith('zh-hant-'))
return 'zh-TW';
if (n === 'zh')
return 'zh-CN';
if (n.startsWith('ja'))
return 'ja';
if (n.startsWith('ko'))
return 'ko';
if (n.startsWith('en'))
return 'en';
return null;
}
function resolveLocale(setting, browserLanguages = []) {
if (setting !== 'auto')
return setting;
for (const tag of browserLanguages) {
const locale = localeFromLanguageTag(tag);
if (locale)
return locale;
}
return 'en';
}
function t(key, locale = currentLocale) {
return MESSAGES[locale][key] || MESSAGES.en[key] || key;
}
function getBrowserLanguages(nav = typeof navigator !== 'undefined' ? navigator : undefined) {
if (!nav)
return [];
const languages = Array.isArray(nav.languages) ? nav.languages : [];
if (languages.length)
return [...languages];
return nav.language ? [nav.language] : [];
}
function createLanguageSelect(doc, value, locale) {
const select = doc.createElement('select');
select.id = 'itw-language';
select.className = 'itw-select';
for (const opt of LANGUAGE_OPTIONS) {
const option = doc.createElement('option');
option.value = opt.value;
option.textContent = opt.value === 'auto' ? t('settings.language.auto', locale) : opt.nativeLabel;
option.selected = opt.value === value;
select.appendChild(option);
}
return select;
}
function normalizeSettings(saved) {
if (!saved || typeof saved !== 'object')
return { ...DEFAULTS };
const raw = saved;
const next = { ...DEFAULTS };
for (const key of Object.keys(DEFAULTS)) {
const value = raw[key];
if (key === 'language') {
if (isLanguageSetting(value))
next.language = value;
continue;
}
if (typeof value === 'boolean')
next[key] = value;
}
if (typeof raw.hideLikedBy === 'boolean' && !('showLikesTab' in raw)) {
next.showLikesTab = !raw.hideLikedBy;
}
return next;
}
// Move `block` into `panel` unless it is already inside, or is the panel
// itself / one of its ancestors. Blocks come from closest()/common-ancestor
// walks that can overshoot the panel after a site re-render, and appending
// an ancestor into its own descendant throws HierarchyRequestError.
// Module scope so Node can require() it for unit tests (see test/); the
// browser code below is gated on `typeof window`.
function adoptIntoPanel(panel, block) {
if (!panel || !block)
return false;
if (panel.contains(block))
return false;
if (block.contains(panel))
return false;
panel.append(block);
return true;
}
const EMPTY_BLACKLIST = { version: 1, authors: [], videos: [] };
const BLACKLIST_ITEMS = '.videoTeaser, .imageTeaser, .comment';
// Author IDs are profile slugs. iwara's links and API usernames are
// lowercase, so author IDs are lowercased where they enter NeoUI and
// compared exactly everywhere else. \p{M} keeps lowercased letters that
// gain a combining mark (İ becomes i + U+0307) valid.
const BLACKLIST_ID = /^[\p{L}\p{M}\p{N}_-]+$/u;
function parseBlacklist(value) {
const raw = Array.isArray(value)
? { version: 1, authors: value.map(id => ({ id, label: id })), videos: [] }
: value;
if (!raw || raw.version !== 1 || !Array.isArray(raw.authors) || !Array.isArray(raw.videos)) {
throw new Error('Invalid blacklist format');
}
const entries = (items, lowercase) => {
const found = new Map();
for (const item of items) {
const entry = item;
if (!entry || typeof entry.id !== 'string' || !BLACKLIST_ID.test(entry.id) ||
typeof entry.label !== 'string')
throw new Error('Invalid blacklist entry');
const id = lowercase ? entry.id.toLowerCase() : entry.id;
found.set(id, { id, label: entry.label || entry.id });
}
return [...found.values()];
};
return { version: 1, authors: entries(raw.authors, true), videos: entries(raw.videos, false) };
}
function mergeBlacklist(saved, incoming) {
const merge = (a, b) => [...a, ...b.filter(entry => !a.some(existing => existing.id === entry.id))];
return { version: 1, authors: merge(saved.authors, incoming.authors), videos: merge(saved.videos, incoming.videos) };
}
function blacklistId(href, kind) {
try {
const url = new URL(href, 'https://www.iwara.tv');
if (!['www.iwara.tv', 'iwara.tv'].includes(url.hostname))
return null;
const parts = url.pathname.split('/');
if (parts[1] !== kind || !parts[2])
return null;
const id = decodeURIComponent(parts[2]);
if (!BLACKLIST_ID.test(id))
return null;
return kind === 'profile' ? id.toLowerCase() : id;
}
catch {
return null;
}
}
function blacklistItemLink(item, selector) {
// A reply's author must never be mistaken for its parent comment's author.
return [...item.querySelectorAll(selector)]
.find(link => link.closest(BLACKLIST_ITEMS) === item) || null;
}
// Hiding only the card leaves its grid cell, and the cell's slot in the
// grid, behind. The walk stops at list containers and blocks even when a
// render briefly leaves them holding a single child.
const BLACKLIST_CELL_STOP = '.row, [class*="__items"], .block, .block__content, section, .page, #app, body';
function holdsOnly(parent, child) {
for (const node of parent.childNodes) {
if (node === child)
continue;
if (node.nodeType === node.ELEMENT_NODE)
return false;
if (node.nodeType === node.TEXT_NODE && node.textContent?.trim())
return false;
}
return true;
}
// The outermost wrapper that contains nothing except `item`. Everything it
// returns holds only the blocked card, so neighbouring cards and the player
// column can never be hidden with it.
function blacklistCell(item) {
let cell = item;
for (let parent = cell.parentElement; parent && !parent.matches(BLACKLIST_CELL_STOP) && holdsOnly(parent, cell); parent = parent.parentElement) {
cell = parent;
}
return cell;
}
// The links a card is matched by. Hiding and the card menu read the same
// links, so an ID the menu saves always matches the card it came from.
function cardLinks(item) {
return {
author: blacklistItemLink(item, 'a.username'),
video: item.matches('.videoTeaser') ? blacklistItemLink(item, 'a[href*="/video/"]') : null,
};
}
function applyBlacklist(doc, data) {
const authors = new Set(data.authors.map(entry => entry.id));
const videos = new Set(data.videos.map(entry => entry.id));
const cells = new Set();
for (const item of doc.querySelectorAll(BLACKLIST_ITEMS)) {
const { author, video } = cardLinks(item);
const blocked = (author && authors.has(blacklistId(author.href, 'profile') || '')) ||
(video && videos.has(blacklistId(video.href, 'video') || ''));
item.classList.toggle('itw-blacklisted', !!blocked);
if (blocked)
cells.add(blacklistCell(item));
}
// React reuses cells for other items; clear cells that no longer qualify.
for (const cell of doc.querySelectorAll('.itw-blacklisted-cell')) {
if (!cells.has(cell))
cell.classList.remove('itw-blacklisted-cell');
}
for (const cell of cells)
cell.classList.add('itw-blacklisted-cell');
}
const BLACKLIST_TEXT = {
en: {
title: "Blacklist",
loadFailed: "Could not load blacklist",
authors: "Authors",
videos: "Videos",
blockAuthor: "Block author",
unblockAuthor: "Unblock author",
hideVideo: "Hide video",
unhideVideo: "Unhide video",
remove: "Remove",
export: "Export",
import: "Import and merge",
migration: "Paste a NeoUI export or the old author blacklist JSON. Import here, then disable the old blacklist script.",
empty: "No entries",
saved: "Saved",
saveFailed: "Could not save",
importFailed: "Import failed",
ioLabel: "Blacklist import / export",
moreActions: "More actions",
blockNamed: "Block {name}",
videoHidden: "Video hidden",
authorBlocked: "Author blocked",
undo: "Undo",
},
'zh-CN': {
title: "黑名单",
loadFailed: "无法加载黑名单",
authors: "作者",
videos: "视频",
blockAuthor: "屏蔽作者",
unblockAuthor: "取消屏蔽作者",
hideVideo: "隐藏此视频",
unhideVideo: "取消隐藏视频",
remove: "移除",
export: "导出",
import: "导入并合并",
migration: "粘贴 NeoUI 导出数据或旧版作者黑名单 JSON。导入后请停用旧黑名单脚本。",
empty: "暂无条目",
saved: "已保存",
saveFailed: "保存失败",
importFailed: "导入失败",
ioLabel: "黑名单导入 / 导出",
moreActions: "更多操作",
blockNamed: "屏蔽 {name}",
videoHidden: "视频已隐藏",
authorBlocked: "作者已屏蔽",
undo: "撤销",
},
'zh-TW': {
title: "黑名單",
loadFailed: "無法載入黑名單",
authors: "作者",
videos: "影片",
blockAuthor: "封鎖作者",
unblockAuthor: "解除封鎖作者",
hideVideo: "隱藏此影片",
unhideVideo: "取消隱藏影片",
remove: "移除",
export: "匯出",
import: "匯入並合併",
migration: "貼上 NeoUI 匯出資料或舊版作者黑名單 JSON。匯入後請停用舊黑名單腳本。",
empty: "尚無項目",
saved: "已儲存",
saveFailed: "儲存失敗",
importFailed: "匯入失敗",
ioLabel: "黑名單匯入 / 匯出",
moreActions: "更多操作",
blockNamed: "封鎖 {name}",
videoHidden: "影片已隱藏",
authorBlocked: "作者已封鎖",
undo: "復原",
},
ja: {
title: "ブロックリスト",
loadFailed: "ブロックリストを読み込めませんでした",
authors: "投稿者",
videos: "動画",
blockAuthor: "投稿者をブロック",
unblockAuthor: "ブロックを解除",
hideVideo: "この動画を非表示",
unhideVideo: "動画を再表示",
remove: "削除",
export: "エクスポート",
import: "インポートして統合",
migration: "NeoUI または旧投稿者ブロックリストの JSON を貼り付けてください。インポート後、旧スクリプトを無効にしてください。",
empty: "項目なし",
saved: "保存しました",
saveFailed: "保存できませんでした",
importFailed: "インポート失敗",
ioLabel: "ブロックリストのインポート / エクスポート",
moreActions: "その他の操作",
blockNamed: "{name} をブロック",
videoHidden: "動画を非表示にしました",
authorBlocked: "投稿者をブロックしました",
undo: "元に戻す",
},
ko: {
title: "차단 목록",
loadFailed: "차단 목록을 불러오지 못했습니다",
authors: "작성자",
videos: "동영상",
blockAuthor: "작성자 차단",
unblockAuthor: "작성자 차단 해제",
hideVideo: "이 동영상 숨기기",
unhideVideo: "동영상 숨기기 해제",
remove: "삭제",
export: "내보내기",
import: "가져오기 및 병합",
migration: "NeoUI 또는 기존 작성자 차단 목록 JSON을 붙여 넣으세요. 가져온 후 기존 차단 스크립트를 비활성화하세요.",
empty: "항목 없음",
saved: "저장됨",
saveFailed: "저장 실패",
importFailed: "가져오기 실패",
ioLabel: "차단 목록 가져오기 / 내보내기",
moreActions: "더 보기",
blockNamed: "{name} 차단",
videoHidden: "동영상을 숨겼습니다",
authorBlocked: "작성자를 차단했습니다",
undo: "실행 취소",
},
};
// Constant markup only; labels are appended as text nodes.
const ICON_MORE = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
const ICON_HIDE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/><path d="M3 3l18 18"/></svg>';
const ICON_BLOCK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/><path d="M3 3l18 18"/></svg>';
async function createBlacklistFeature(doc, persistence, publish = () => { }) {
let data = parseBlacklist(await persistence.get('blacklist', EMPTY_BLACKLIST));
publish(data);
const win = doc.defaultView;
const text = (key) => BLACKLIST_TEXT[currentLocale][key];
const element = (tag, label = '') => {
const el = doc.createElement(tag);
el.textContent = label;
return el;
};
const button = (label) => {
const el = element('button', label);
el.type = 'button';
el.className = 'itw-btn';
return el;
};
const status = element('div');
status.className = 'itw-blacklist-status';
status.setAttribute('role', 'status');
doc.body.append(status);
const managers = new Map();
let disposed = false;
let statusTimer = null;
let writes = Promise.resolve();
const hideStatusLater = (ms) => {
if (statusTimer)
clearTimeout(statusTimer);
statusTimer = setTimeout(() => {
statusTimer = null;
// A notice whose Undo has focus stays until focus moves on.
if (status.contains(doc.activeElement)) {
hideStatusLater(2000);
return;
}
status.replaceChildren();
}, ms);
};
const notify = (message, undo, focusUndo = false) => {
status.replaceChildren(element('span', message));
if (undo) {
const undoButton = button(text('undo'));
undoButton.addEventListener('click', () => { status.replaceChildren(); undo(); });
status.append(undoButton);
// The card the menu belonged to is gone; Undo is the next thing to reach.
if (focusUndo)
undoButton.focus({ preventScroll: true });
}
hideStatusLater(undo ? 6000 : 4000);
};
// Resolves true once the change is stored and applied. Failures are
// reported here, so no caller can announce a save that did not happen.
const edit = (change) => {
const result = writes.then(async () => {
const next = parseBlacklist(change(data));
await persistence.set('blacklist', next);
data = next;
publish(data);
if (disposed)
return false;
refresh();
for (const [manager, render] of managers) {
if (manager.isConnected)
render();
else
managers.delete(manager);
}
return true;
}).catch(error => {
if (!disposed)
notify(`${text('saveFailed')}: ${error instanceof Error ? error.message : String(error)}`);
return false;
});
writes = result;
return result;
};
const save = (change) => {
void edit(change).then(saved => { if (saved)
notify(text('saved')); });
};
const has = (kind, id) => data[kind].some(item => item.id === id);
const removeEntry = (kind, id) => save(previous => ({ ...previous, [kind]: previous[kind].filter(item => item.id !== id) }));
const addEntry = (kind, entry, focusUndo = false) => {
if (has(kind, entry.id))
return;
void edit(previous => ({ ...previous, [kind]: [...previous[kind], entry] })).then(saved => {
if (saved)
notify(text(kind === 'authors' ? 'authorBlocked' : 'videoHidden'), () => removeEntry(kind, entry.id), focusUndo);
});
};
const action = (host, kind, entry) => {
let control = host.querySelector(`:scope > [data-itw-block="${kind}"]`);
if (!entry) {
control?.remove();
return;
}
if (!control) {
control = button('');
control.dataset.itwBlock = kind;
host.append(control);
}
control.dataset.id = entry.id;
control.dataset.label = entry.label;
const blocked = has(kind, entry.id);
const label = text(kind === 'authors' ? (blocked ? 'unblockAuthor' : 'blockAuthor') : (blocked ? 'unhideVideo' : 'hideVideo'));
if (control.textContent !== label)
control.textContent = label;
};
// Entries are read when the menu opens, not when the button is added:
// React reuses cards for other videos without replacing their nodes.
const itemEntries = (item) => {
const entries = {};
const { author, video: link } = cardLinks(item);
const videoId = link && blacklistId(link.href, 'video');
if (videoId) {
const title = item.querySelector('.videoTeaser__title')?.textContent?.trim() ||
link?.textContent?.trim() || link?.getAttribute('title');
entries.videos = { id: videoId, label: title || videoId };
}
const authorId = author && blacklistId(author.href, 'profile');
if (authorId)
entries.authors = { id: authorId, label: author?.textContent?.trim() || authorId };
return entries;
};
const moreHost = (item) => {
if (item.matches('.comment')) {
return [...item.querySelectorAll('.comment__actions')].find(el => el.closest(BLACKLIST_ITEMS) === item) || item;
}
return item.querySelector('.videoTeaser__bottom, .imageTeaser__bottom') || item;
};
const ensureMore = (item) => {
const existing = [...item.querySelectorAll('[data-itw-more]')]
.find(el => el.closest(BLACKLIST_ITEMS) === item);
const { author, video } = cardLinks(item);
const actionable = !!(author && blacklistId(author.href, 'profile')) || !!(video && blacklistId(video.href, 'video'));
if (!actionable) {
existing?.remove();
return;
}
const label = text('moreActions');
if (existing) {
if (existing.getAttribute('aria-label') !== label)
existing.setAttribute('aria-label', label);
return;
}
const control = doc.createElement('button');
control.type = 'button';
control.className = 'itw-more';
control.dataset.itwMore = '';
control.setAttribute('aria-label', label);
control.setAttribute('aria-expanded', 'false');
control.innerHTML = ICON_MORE;
moreHost(item).append(control);
};
let menu = null;
let menuAnchor = null;
let menuHref = '';
const closeMenu = (restoreFocus = false) => {
if (!menu || menu.hidden)
return;
menu.hidden = true;
menu.replaceChildren();
const anchor = menuAnchor;
menuAnchor = null;
anchor?.setAttribute('aria-expanded', 'false');
if (restoreFocus)
anchor?.focus();
};
const menuChoice = (kind, entry, label, icon) => {
const choice = doc.createElement('button');
choice.type = 'button';
choice.dataset.itwChoice = kind;
choice.dataset.id = entry.id;
choice.dataset.label = entry.label;
choice.innerHTML = icon;
choice.append(label);
return choice;
};
const openMenu = (anchor) => {
closeMenu();
const item = anchor.closest(BLACKLIST_ITEMS);
if (!item)
return;
const entries = itemEntries(item);
if (!menu) {
menu = element('div');
menu.id = 'itw-blacklist-menu';
menu.className = 'itw-menu';
menu.hidden = true;
doc.body.append(menu);
}
if (entries.videos)
menu.append(menuChoice('videos', entries.videos, text('hideVideo'), ICON_HIDE));
if (entries.authors) {
// A replacer function: a string replacement would expand $& and $' in names.
const name = entries.authors.label;
menu.append(menuChoice('authors', entries.authors, text('blockNamed').replace('{name}', () => name), ICON_BLOCK));
}
if (!menu.childElementCount)
return;
menu.hidden = false;
menuAnchor = anchor;
menuHref = win.location.href;
anchor.setAttribute('aria-expanded', 'true');
anchor.setAttribute('aria-controls', menu.id);
placeMenu();
menu.querySelector('button')?.focus({ preventScroll: true });
};
// Below the button, or above it when there is no room; kept on screen.
const placeMenu = () => {
if (!menu || menu.hidden || !menuAnchor)
return;
const rect = menuAnchor.getBoundingClientRect();
const { offsetWidth: width, offsetHeight: height } = menu;
const below = rect.bottom + 4;
const top = below + height > win.innerHeight ? rect.top - 4 - height : below;
menu.style.top = `${Math.max(8, top)}px`;
menu.style.left = `${Math.max(8, Math.min(rect.right - width, win.innerWidth - width - 8))}px`;
};
const refresh = () => {
if (disposed)
return;
applyBlacklist(doc, data);
for (const item of doc.querySelectorAll(BLACKLIST_ITEMS))
ensureMore(item);
const profileId = blacklistId(win.location.href, 'profile');
const profile = doc.querySelector('.page-profile__header__middle');
if (profile) {
const row = profile.querySelector(':scope > .d-flex.align-items-center') || profile;
for (const stale of profile.querySelectorAll('[data-itw-block]'))
if (stale.parentElement !== row)
stale.remove();
const name = profile.querySelector('.username')?.textContent?.trim();
action(row, 'authors', profileId ? { id: profileId, label: name || profileId } : null);
}
const videoId = blacklistId(win.location.href, 'video');
const host = doc.querySelector('.page-video__actions') || doc.querySelector('.page-video__details');
for (const stale of doc.querySelectorAll('.page-video__actions [data-itw-block], .page-video__details [data-itw-block]')) {
if (stale.parentElement !== host)
stale.remove();
}
if (host) {
const title = doc.querySelector('.page-video__details .text--h1, .page-video h1')?.textContent?.trim();
const author = doc.querySelector('.page-video__byline a.username');
const authorId = author && blacklistId(author.href, 'profile');
action(host, 'videos', videoId ? { id: videoId, label: title || videoId } : null);
action(host, 'authors', videoId && authorId ? { id: authorId, label: author?.textContent?.trim() || authorId } : null);
}
if (menuAnchor && (!menuAnchor.isConnected || menuHref !== win.location.href))
closeMenu();
};
const handleClick = (event) => {
const target = event.target;
const more = target?.closest?.('[data-itw-more]');
if (more) {
event.preventDefault();
event.stopPropagation();
if (menuAnchor === more)
closeMenu();
else
openMenu(more);
return;
}
const choice = target?.closest?.('[data-itw-choice]');
if (choice) {
event.preventDefault();
event.stopPropagation();
const kind = choice.dataset.itwChoice;
const id = choice.dataset.id;
closeMenu();
if (id && (kind === 'authors' || kind === 'videos'))
addEntry(kind, { id, label: choice.dataset.label || id }, true);
return;
}
if (menu && !menu.hidden && !menu.contains(target))
closeMenu();
const control = target?.closest?.('[data-itw-block]');
if (!control)
return;
event.preventDefault();
event.stopPropagation();
// Site navigation can reuse a control before the observer's debounce fires.
refresh();
if (!control.isConnected)
return;
const kind = control.dataset.itwBlock;
const id = control.dataset.id;
if (!id || (kind !== 'authors' && kind !== 'videos'))
return;
if (has(kind, id))
removeEntry(kind, id);
else
addEntry(kind, { id, label: control.dataset.label || id });
};
const handleKey = (event) => {
if (!menu || menu.hidden)
return;
if (event.key === 'Escape') {
event.stopPropagation();
closeMenu(true);
return;
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
return;
const choices = [...menu.querySelectorAll('[data-itw-choice]')];
const index = choices.findIndex(choice => choice === doc.activeElement);
if (index < 0)
return;
// The arrow moves focus instead of scrolling the page or seeking the player.
event.preventDefault();
event.stopPropagation();
const step = event.key === 'ArrowDown' ? 1 : -1;
choices[(index + step + choices.length) % choices.length].focus({ preventScroll: true });
};
doc.addEventListener('click', handleClick, true);
doc.addEventListener('keydown', handleKey, true);
// Scroll events arrive at most once per frame, so placing directly is cheap.
win.addEventListener('scroll', placeMenu, true);
win.addEventListener('resize', placeMenu);
let timer = null;
const observer = new win.MutationObserver(() => {
if (!timer)
timer = setTimeout(() => { timer = null; refresh(); }, 150);
});
observer.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'], characterData: true });
refresh();
return {
refresh,
createManager() {
const root = element('details');
root.className = 'itw-blacklist-manager';
root.append(element('summary', text('title')));
const lists = element('div');
const render = () => {
lists.replaceChildren();
for (const kind of ['authors', 'videos']) {
lists.append(element('h4', `${text(kind)} (${data[kind].length})`));
if (!data[kind].length)
lists.append(element('p', text('empty')));
for (const entry of data[kind]) {
const row = element('div');
row.className = 'itw-blacklist-entry';
const link = element('a', entry.label === entry.id ? entry.id : `${entry.label} (${entry.id})`);
link.href = `/${kind === 'authors' ? 'profile' : 'video'}/${encodeURIComponent(entry.id)}`;
const removeButton = button(text('remove'));
removeButton.dataset.itwRemove = kind;
removeButton.addEventListener('click', () => removeEntry(kind, entry.id));
row.append(link, removeButton);
lists.append(row);
}
}
};
managers.set(root, render);
render();
const io = element('textarea');
io.rows = 4;
io.setAttribute('aria-label', text('ioLabel'));
const exportButton = button(text('export'));
exportButton.addEventListener('click', () => { io.value = JSON.stringify(data, null, 2); io.select(); });
const importButton = button(text('import'));
const feedback = element('p');
feedback.setAttribute('role', 'status');
importButton.addEventListener('click', () => {
try {
const incoming = parseBlacklist(JSON.parse(io.value));
feedback.textContent = '';
save(previous => mergeBlacklist(previous, incoming));
}
catch (error) {
feedback.textContent = `${text('importFailed')}: ${error instanceof Error ? error.message : String(error)}`;
}
});
root.append(lists, element('p', text('migration')), io, exportButton, importButton, feedback);
return root;
},
dispose() {
disposed = true;
observer.disconnect();
if (timer)
clearTimeout(timer);
if (statusTimer)
clearTimeout(statusTimer);
doc.removeEventListener('click', handleClick, true);
doc.removeEventListener('keydown', handleKey, true);
win.removeEventListener('scroll', placeMenu, true);
win.removeEventListener('resize', placeMenu);
menu?.remove();
status.remove();
},
};
}
// The API lists the filter may rewrite, and whether a page that lost items
// may be topped up from the page after it.
function listRoute(url) {
if (url.hostname !== 'api.iwara.tv')
return null;
const path = url.pathname.replace(/\/+$/, '');
if (path === '/videos')
return { kind: 'videos', refill: true };
if (path === '/images')
return { kind: 'images', refill: true };
if (path === '/search') {
const type = url.searchParams.get('type');
return type === 'videos' || type === 'images' ? { kind: type, refill: true } : null;
}
let match = /^\/trending\/(video|image)$/.exec(path);
if (match)
return { kind: match[1] === 'video' ? 'videos' : 'images', refill: true };
match = /^\/favorites\/(videos|images)$/.exec(path);
if (match)
return { kind: match[1] === 'videos' ? 'videos' : 'images', refill: true };
if (/^\/playlist\/[^/]+$/.test(path))
return { kind: 'videos', refill: true };
// Related lists ignore page and limit and always hold the same 12 items.
match = /^\/(video|image)\/[^/]+\/related$/.exec(path);
if (match)
return { kind: match[1] === 'video' ? 'videos' : 'images', refill: false };
if (/^\/(video|image|profile)\/[^/]+\/comments$/.test(path))
return { kind: 'comments', refill: false };
return null;
}
function listItemBlocked(item, kind, lists) {
const raw = item;
const username = raw?.user?.username ?? raw?.video?.user?.username ?? raw?.image?.user?.username;
if (typeof username === 'string' && lists.authors.has(username.toLowerCase()))
return true;
if (kind !== 'videos')
return false;
const id = raw?.video?.id ?? raw?.id;
return typeof id === 'string' && lists.videos.has(id);
}
function parseFilterDetail(detail) {
try {
const data = JSON.parse(String(detail));
const strings = (value) => Array.isArray(value) && value.every(entry => typeof entry === 'string') ? value : null;
const authors = strings(data?.authors);
const videos = strings(data?.videos);
return authors && videos
? { authors: new Set(authors.map(id => id.toLowerCase())), videos: new Set(videos) }
: null;
}
catch {
return null;
}
}
function rebuildResponse(original, json) {
const headers = new Headers(original.headers);
headers.delete('content-length');
headers.delete('content-encoding');
const rebuilt = new Response(JSON.stringify(json), { status: original.status, statusText: original.statusText, headers });
// A constructed Response has an empty url; keep the original's.
Object.defineProperty(rebuilt, 'url', { value: original.url });
return rebuilt;
}
function listItemId(item) {
const raw = item;
const id = raw?.video?.id ?? raw?.image?.id ?? raw?.id;
return typeof id === 'string' ? id : null;
}
// One key per list: path and query without `page`, parameters sorted.
function listKey(url) {
const params = [...url.searchParams]
.filter(([name]) => name !== 'page')
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `${url.pathname}?${new URLSearchParams(params)}`;
}
function listPage(url) {
const page = Number(url.searchParams.get('page') ?? 0);
return Number.isInteger(page) && page >= 0 ? page : 0;
}
// IDs each page of a list served in this tab, so a video used to fill one
// page is left out of the list's other pages. Least recently used lists
// are dropped beyond `limit`.
function createListMemory(limit = 20) {
const lists = new Map();
return {
seenElsewhere(key, page) {
const seen = new Set();
for (const [other, ids] of lists.get(key) ?? []) {
if (other !== page)
for (const id of ids)
seen.add(id);
}
return seen;
},
remember(key, page, ids) {
const pages = lists.get(key) ?? new Map();
lists.delete(key);
lists.set(key, pages.set(page, ids));
for (const oldest of lists.keys()) {
if (lists.size <= limit)
break;
lists.delete(oldest);
}
},
};
}
function createListFilter(next, lists, memory = createListMemory()) {
return (input, init) => {
let url = null;
try {
if (typeof input === 'string' || input instanceof URL)
url = new URL(String(input), 'https://www.iwara.tv/');
}
catch {
url = null;
}
const route = url && (init?.method ?? 'GET').toUpperCase() === 'GET' ? listRoute(url) : null;
if (!url || !route)
return next(input, init);
const listUrl = url;
// Once the site aborts, answer as fetch would: reject with the reason.
const signal = init?.signal;
const abortReason = () => signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError');
return next(input, init).then(async (response) => {
// Any failure other than an abort hands iwara the untouched response.
try {
const current = await lists();
if (signal?.aborted)
throw abortReason();
if (!current || (!current.authors.size && !current.videos.size) || !response.ok)
return response;
const json = await response.clone().json();
if (!json || !Array.isArray(json.results))
return response;
const original = json.results;
const key = listKey(listUrl);
const page = listPage(listUrl);
const seen = route.refill ? memory.seenElsewhere(key, page) : new Set();
const keep = (item) => !listItemBlocked(item, route.kind, current) && !seen.has(listItemId(item) ?? '');
const results = original.filter(keep);
const size = typeof json.limit === 'number' ? json.limit : Number(listUrl.searchParams.get('limit')) || 0;
// Only a full page can have more after it.
if (route.refill && size > 0 && original.length === size && results.length < size) {
const firstIds = new Set(original.map(listItemId));
const taken = new Set(results.map(listItemId));
for (let extra = 1; extra <= 2 && results.length < size; extra += 1) {
const nextUrl = new URL(listUrl);
nextUrl.searchParams.set('page', String(page + extra));
let batch;
try {
const nextResponse = await next(nextUrl.href, init);
if (!nextResponse.ok)
break;
batch = (await nextResponse.json())?.results;
}
catch (error) {
if (signal?.aborted)
throw error;
break;
}
// An endpoint that ignores `page` answers with the first page again.
if (!Array.isArray(batch) || batch.every(item => firstIds.has(listItemId(item))))
break;
for (const item of batch) {
if (results.length >= size)
break;
const id = listItemId(item);
if (id === null || taken.has(id) || !keep(item))
continue;
taken.add(id);
results.push(item);
}
if (batch.length < size)
break;
}
}
if (route.refill)
memory.remember(key, page, results.map(listItemId).filter((id) => id !== null));
if (results.length === original.length && results.every((item, index) => item === original[index]))
return response;
return rebuildResponse(response, { ...json, results });
}
catch (error) {
if (signal?.aborted)
throw error;
return response;
}
});
};
}
// Holds list responses until NeoUI sends the first list (at most waitMs),
// then filters with the latest list it received.
function installListFilter(win, eventName, waitMs = 3000) {
const flags = win;
if (flags.__itwNeoFilter || typeof win.fetch !== 'function')
return;
flags.__itwNeoFilter = true;
let current = null;
let release = () => { };
const received = new Promise(resolve => { release = resolve; });
const timer = win.setTimeout(() => release(), waitMs);
win.document.addEventListener(eventName, event => {
current = parseFilterDetail(event.detail);
win.clearTimeout(timer);
release();
});
win.fetch = createListFilter(win.fetch.bind(win), async () => { await received; return current; });
}
const PAGE_FILTER_EVENT = 'itw-neoui:blacklist';
const PAGE_FILTER_PARTS = [
listRoute, listItemId, listItemBlocked, listKey, listPage, createListMemory,
parseFilterDetail, rebuildResponse, createListFilter, installListFilter,
];
function pageFilterSource(eventName) {
const parts = PAGE_FILTER_PARTS.map(part => part.toString()).join('\n');
return `(() => {\n'use strict';\n${parts}\ninstallListFilter(window, ${JSON.stringify(eventName)});\n})();`;
}
// A <script> element reaches the page's own context in every userscript
// manager, and iwara's CSP allows inline scripts. At document-start the
// <html> element may not exist yet; insertion then waits for it.
function injectPageFilter(doc, eventName, onInstalled) {
const insert = () => {
const script = doc.createElement('script');
script.textContent = pageFilterSource(eventName);
doc.documentElement.append(script);
script.remove();
onInstalled();
};
if (doc.documentElement) {
insert();
return;
}
const view = doc.defaultView;
if (!view)
return;
const observer = new view.MutationObserver(() => {
if (!doc.documentElement)
return;
observer.disconnect();
insert();
});
observer.observe(doc, { childList: true });
}
function filterDetail(data) {
return JSON.stringify(data
? { authors: data.authors.map(entry => entry.id), videos: data.videos.map(entry => entry.id) }
: { unavailable: true });
}
// The payload is a string: objects need cloneInto() to reach page code
// from a Firefox content script, strings do not.
function dispatchFilterDetail(doc, eventName, detail) {
const view = doc.defaultView;
if (view)
doc.dispatchEvent(new view.CustomEvent(eventName, { detail }));
}
if (typeof window !== 'undefined')
(() => {
'use strict';
const neoWindow = window;
if (neoWindow.__IWARA_NEOUI_ACTIVE)
return;
neoWindow.__IWARA_NEOUI_ACTIVE = true;
// ---- Page filter: in place before iwara's first API request ----
// One storage backend. Falling back after a GM failure would fork the
// list into page storage and lose those edits when GM starts working.
// Failures propagate so an unsuccessful write cannot claim "Saved".
const blacklistStore = {
get: async (key, fallback) => {
if (typeof GM !== 'undefined' && GM.getValue)
return GM.getValue(key, fallback);
const saved = localStorage.getItem(`itw:${key}`);
return saved === null ? fallback : JSON.parse(saved);
},
set: async (key, value) => {
if (typeof GM !== 'undefined' && GM.setValue)
return GM.setValue(key, value);
localStorage.setItem(`itw:${key}`, JSON.stringify(value));
},
};
let filterInstalled = false;
let filterPayload = null;
let sentPayload = null;
// A payload sent before the filter script exists would be lost, so the
// latest one is kept and sent once the script is in place. The feature
// reloads the same list after DOMContentLoaded; that copy is not resent.
const sendFilterPayload = () => {
if (!filterInstalled || filterPayload === null || filterPayload === sentPayload)
return;
sentPayload = filterPayload;
dispatchFilterDetail(document, PAGE_FILTER_EVENT, filterPayload);
};
const publishBlacklist = (data) => {
filterPayload = filterDetail(data);
sendFilterPayload();
};
injectPageFilter(document, PAGE_FILTER_EVENT, () => { filterInstalled = true; sendFilterPayload(); });
blacklistStore.get('blacklist', EMPTY_BLACKLIST)
.then(parseBlacklist)
.then(publishBlacklist, () => publishBlacklist(null));
// ---- Constants ----
const TIMEOUTS = {
WAIT_FOR: 20000,
POLL_INTERVAL: 250,
SLEEP_SHORT: 500,
OBSERVER_DEBOUNCE: 150
};
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',
TABBAR: '.itw-tabbar',
PANELS: '.itw-panels'
};
const CSS_CLASSES = {
TABS_ACTIVE: 'itw-tabs-active',
THEATER: 'itw-theater'
};
// ---- 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();
}
};
// ---- Observer Manager ----
const observerManager = {
observers: new Map(),
create(name, callback) {
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);
}
}
};
// ---- Utilities ----
const dom = {
el(html) {
const t = document.createElement('template');
t.innerHTML = html.trim();
const element = t.content.firstElementChild;
if (!element)
throw new Error('empty HTML template');
return element;
},
on(el, evt, cb) {
if (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 domReady = () => new Promise(resolve => {
if (document.readyState !== 'loading')
resolve();
else
document.addEventListener('DOMContentLoaded', () => resolve(), { once: true });
});
// At document-start the <html> element may not exist yet.
const addStyle = (css) => {
const s = document.createElement('style');
s.textContent = css;
const mount = () => { document.documentElement.appendChild(s); };
if (document.documentElement)
mount();
else
void domReady().then(mount);
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 { }
}
};
let settings = { ...DEFAULTS };
let blacklistFeature = null;
let blacklistError = null;
// Runtime-only cinema state (not persisted when cinemaHideNav is true)
let cinemaActive = false;
const loadSettings = async () => {
try {
const saved = await store.get('settings', {});
settings = normalizeSettings(saved);
currentLocale = resolveLocale(settings.language, getBrowserLanguages());
}
catch (e) {
console.warn('[Iwara NeoUI] loadSettings failed:', e);
settings = { ...DEFAULTS };
currentLocale = resolveLocale(settings.language, getBrowserLanguages());
}
};
const saveSettings = async () => {
try {
await store.set('settings', settings);
}
catch (e) {
console.warn('[Iwara NeoUI] saveSettings failed:', e);
}
};
// ---- CSS for features ----
addStyle(`
.videoTeaser.itw-blacklisted, .imageTeaser.itw-blacklisted, .comment.itw-blacklisted { display:none !important; }
.itw-blacklisted-cell { display:none !important; }
[data-itw-block] { margin:4px; }
.itw-blacklist-manager { border-top:1px solid #3a404b; padding:12px 0; }
.itw-blacklist-manager summary { cursor:pointer; font-weight:600; }
.itw-blacklist-manager textarea { box-sizing:border-box; width:100%; background:#20242b; color:#e6e6e6; }
.itw-blacklist-entry { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:6px 0; }
.itw-blacklist-entry a { overflow-wrap:anywhere; min-width:0; }
.itw-blacklist-status:not(:empty) { position:fixed; bottom:16px; left:16px; z-index:100001; display:flex; align-items:center; gap:12px; padding:10px; background:#20242b; color:#e6e6e6; border-radius:6px; }
.itw-more { appearance:none; flex:none; margin-left:auto; width:28px; height:28px; padding:0; border:0; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; color:inherit; background:transparent; cursor:pointer; opacity:0; transition:opacity .15s; }
.itw-more:hover, .itw-more:focus-visible, .itw-more[aria-expanded="true"] { background:rgba(127,127,127,.22); }
.itw-more svg { width:16px; height:16px; fill:currentColor; }
.videoTeaser:hover .itw-more, .imageTeaser:hover .itw-more, .comment:hover .itw-more, .itw-more:focus-visible, .itw-more[aria-expanded="true"] { opacity:1; }
@media (hover: none) { .itw-more { opacity:.85; } }
.itw-menu { position:fixed; z-index:100002; display:flex; flex-direction:column; min-width:180px; max-width:min(320px, calc(100vw - 16px)); box-sizing:border-box; padding:4px; background:#1a1d22; border:1px solid #3a404b; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.45); }
.itw-menu[hidden] { display:none; }
.itw-menu button { appearance:none; display:flex; align-items:center; gap:8px; padding:8px 10px; border:0; border-radius:6px; background:none; color:#e6e6e6; font:500 13px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; text-align:left; overflow-wrap:anywhere; cursor:pointer; }
.itw-menu button:hover, .itw-menu button:focus-visible { background:rgba(255,255,255,.08); outline:none; }
.itw-menu svg { width:16px; height:16px; flex:none; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
.itw-modal { max-height:85vh; overflow:auto; }
/* 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; left:50%; top:50%; transform:translate(-50%,-50%); width:min(520px, calc(100vw - 24px)); box-sizing:border-box; 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-select { min-width:160px; max-width:220px; color:#e6e6e6; background:#20242b; border:1px solid #3a404b; border-radius:6px; padding:6px 8px; font:600 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.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; }
/* Cinema Mode — base (always applied when cinema is active) */
body.itw-theater { overflow-y: auto; }
body.itw-theater .page-video__sidebar,
body.itw-theater aside { display: none !important; }
/* Expand video content area — scoped to .page so header is untouched */
body.itw-theater .page .container-fluid { max-width: 100% !important; width: 100% !important; padding-left: 0 !important; padding-right: 0 !important; }
body.itw-theater .page .row { margin-left: 0 !important; margin-right: 0 !important; }
body.itw-theater .col-12.col-md-9 { max-width: 100% !important; flex: 0 0 100% !important; padding-left: 0 !important; padding-right: 0 !important; }
/* Player: black backdrop, fill width */
body.itw-theater .page-video__player { background: #000 !important; }
body.itw-theater .video-js { width: 100% !important; aspect-ratio: 16/9 !important; max-height: 85vh !important; margin: 0 auto !important; }
body.itw-theater .vjs-tech { width: 100% !important; height: 100% !important; object-fit: contain !important; }
/* Restore padding below the player for tabs/content */
body.itw-theater .itw-tabbar,
body.itw-theater .itw-panels { padding-left: 16px !important; padding-right: 16px !important; }
/* Cinema Mode — full immersive (hide nav when setting is on) */
body.itw-theater-hide-nav header.header,
body.itw-theater-hide-nav div.menu { display: none !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; }
/* Cinema toggle button in tab bar */
.itw-cinema-btn { appearance:none; background:none; border:1px solid rgba(255,255,255,.15); color:#e6e6e6; cursor:pointer; padding:6px 10px; font:600 12px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; border-radius:6px; margin-left:auto; opacity:.7; transition: opacity .2s, background .2s; }
.itw-cinema-btn:hover { opacity:1; background:rgba(255,255,255,.08); }
.itw-cinema-btn.active { color:#ff4b4b; border-color:#ff4b4b; opacity:1; }
/* 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; }
`);
// ---- Modal creation ----
let modalBackdrop = null;
let modalEl = null;
const createSwitch = (id, checked, label) => {
const row = dom.el('<div class="itw-row"></div>');
const labelText = dom.el('<div></div>');
labelText.textContent = label;
const switchLabel = dom.el(`
<label class="itw-switch" for="${id}">
<input id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
<span class="itw-knob"></span>
</label>
`);
row.append(labelText, switchLabel);
return row;
};
const createLanguageRow = () => {
const row = dom.el('<div class="itw-row"></div>');
const labelText = dom.el('<div></div>');
labelText.textContent = t('settings.language');
row.append(labelText, createLanguageSelect(document, settings.language, currentLocale));
return row;
};
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>');
const title = dom.el('<h3></h3>');
title.textContent = t('settings.title');
content.appendChild(title);
// Tabs visibility
const languageRow = createLanguageRow();
const likesTabRow = createSwitch('itw-show-likes', settings.showLikesTab, t('settings.showLikes'));
const recsTabRow = createSwitch('itw-show-recs', settings.showRecsTab, t('settings.showRecs'));
const cinemaNavRow = createSwitch('itw-cinema-hide-nav', settings.cinemaHideNav, t('settings.hideNav'));
const newUiRow = createSwitch('itw-new-ui', settings.newUI, t('settings.newUi'));
const actions = dom.el('<div class="itw-actions"></div>');
const closeBtn = dom.el('<button class="itw-btn" type="button"></button>');
const saveBtn = dom.el('<button class="itw-btn" type="button"></button>');
closeBtn.textContent = t('actions.close');
saveBtn.textContent = t('actions.save');
actions.append(closeBtn, saveBtn);
content.append(languageRow, likesTabRow, recsTabRow, cinemaNavRow, newUiRow);
if (blacklistFeature)
content.append(blacklistFeature.createManager());
if (blacklistError) {
const notice = document.createElement('p');
notice.setAttribute('role', 'alert');
notice.textContent = `${BLACKLIST_TEXT[currentLocale].loadFailed}: ${blacklistError}`;
content.append(notice);
}
content.append(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,
language: settings.language,
};
if (!modalEl)
return;
settings.showLikesTab = modalEl.querySelector('#itw-show-likes')?.checked ?? settings.showLikesTab;
settings.showRecsTab = modalEl.querySelector('#itw-show-recs')?.checked ?? settings.showRecsTab;
settings.cinemaHideNav = modalEl.querySelector('#itw-cinema-hide-nav')?.checked ?? settings.cinemaHideNav;
settings.newUI = modalEl.querySelector('#itw-new-ui')?.checked ?? settings.newUI;
const languageValue = modalEl.querySelector('#itw-language')?.value;
if (isLanguageSetting(languageValue))
settings.language = languageValue;
currentLocale = resolveLocale(settings.language, getBrowserLanguages());
const settingsBtn = document.getElementById('itw-settings-btn');
if (settingsBtn)
syncHeaderBtnText(settingsBtn);
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 ||
prev.language !== settings.language;
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();
blacklistFeature?.refresh();
toggleModal(false);
if (prev.language !== settings.language) {
modalBackdrop?.remove();
modalEl?.remove();
modalBackdrop = null;
modalEl = null;
}
});
return modalEl;
}
catch (e) {
console.warn('[Iwara NeoUI] ensureModal failed:', e);
return null;
}
};
const toggleModal = (show) => {
ensureModal();
if (!modalBackdrop || !modalEl)
return;
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>${t('settings.button')}</span></button>`);
const syncHeaderBtnText = (btn) => {
btn.title = 'Iwara NeoUI';
const label = btn.querySelector('span');
if (label)
label.textContent = t('settings.button');
};
const findHeaderRoot = (btn) => {
const candidates = document.querySelectorAll('header, nav[role="navigation"], .header, [class*="header"]');
return [...candidates].find(candidate => candidate !== btn && !btn.contains(candidate)) ?? null;
};
const isSafeReanchorParent = (btn, parent) => !!parent && !btn.contains(parent);
const insertNextToSearch = (btn) => {
const header = findHeaderRoot(btn);
if (!header)
return { placed: false, anchorEl: null };
const searchContainer = header.querySelector('.header__content__items__search, [class*="items__search" i], .header__search, [role="search"]');
if (searchContainer) {
const searchParent = searchContainer.parentElement;
if (!isSafeReanchorParent(btn, searchParent)) {
return { placed: false, anchorEl: null };
}
if (btn.previousElementSibling === searchContainer) {
return { placed: true, anchorEl: searchContainer };
}
searchContainer.insertAdjacentElement('afterend', btn);
return { placed: true, anchorEl: searchContainer };
}
const searchForm = header.querySelector('form.header__search, form[action*="search" i]') || header.querySelector('input[type="search"]')?.closest('form');
if (searchForm) {
const searchFormParent = searchForm.parentElement;
if (!isSafeReanchorParent(btn, searchFormParent)) {
return { placed: false, anchorEl: null };
}
searchForm.insertAdjacentElement('afterend', btn);
return { placed: true, anchorEl: searchForm };
}
const items = header.querySelector('.header__content__items, [class*="content__items" i]') || header;
if (!isSafeReanchorParent(btn, items)) {
return { placed: false, anchorEl: null };
}
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 via pushState/popstate interception
const onRouteChange = () => {
domCache.clear();
observerManager.disconnect('likes');
if (cinemaActive && settings.cinemaHideNav) {
cinemaActive = false;
applyCinema();
}
reanchor();
applyUiMode();
blacklistFeature?.refresh();
};
const origPushState = history.pushState;
history.pushState = function (...args) {
origPushState.apply(this, args);
onRouteChange();
};
const origReplaceState = history.replaceState;
history.replaceState = function (...args) {
origReplaceState.apply(this, args);
onRouteChange();
};
window.addEventListener('popstate', onRouteChange);
// 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 tagAndMoveLiked = (el) => {
if (!el)
return null;
// Detection also runs before tabs exist and while the likes list is still
// loading. A page/player container can never be the likes block.
if (el.matches('.page, .content, .container-fluid, .col-12.col-md-9, .page-video__content, .page-video__sidebar') ||
el.querySelector('.page-video__player, .video-js'))
return null;
const likesPanel = settings.newUI && document.querySelector('.itw-panel-likes');
// A scan/closest() hit can be the panel itself or an ancestor of it;
// tagging those mis-marks page-level containers (hiding .itw-liked-by
// would then hide the whole column), so bail out entirely.
if (likesPanel && el.contains(likesPanel))
return null;
el.classList.add('itw-liked-by');
if (likesPanel && settings.showLikesTab)
adoptIntoPanel(likesPanel, el);
return el;
};
const findLikedBySection = () => {
try {
const likesList = dom.qs('.likesList');
if (likesList) {
const container = likesList.closest('.block, .contentBlock, .block--padding, .card, .panel') || likesList.parentElement;
if (container)
return tagAndMoveLiked(container);
}
// Start at the heading and choose its nearest block. Scanning outer
// containers first mistakes the entire content section for the likes
// block when a nested heading arrives before the list.
for (const title of dom.qsa('h2, h3, .text--h3')) {
const text = (title?.textContent || '').trim().toLowerCase();
if (text && LIKED_BY_TEXTS.some(t => text.includes(t))) {
const container = title.closest('.block, .contentBlock, .card, .panel, section');
const tagged = tagAndMoveLiked(container);
if (tagged)
return tagged;
}
}
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);
};
// ---- Cinema Mode ----
const applyCinema = () => {
try {
const root = document.body || document.documentElement;
root.classList.toggle('itw-theater', cinemaActive);
root.classList.toggle('itw-theater-hide-nav', cinemaActive && settings.cinemaHideNav);
// Move settings button to body when cinema is active so it's always reachable
const settingsBtn = document.getElementById('itw-settings-btn');
if (settingsBtn) {
if (cinemaActive) {
settingsBtn.classList.remove('itw-in-header');
settingsBtn.classList.add('itw-float');
document.body.appendChild(settingsBtn);
}
// Button will be re-anchored by reanchor() when cinema turns off
}
// Scroll to top of player when entering cinema mode
if (cinemaActive) {
const player = dom.qs('.page-video__player, .video-js');
if (player)
player.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
catch (e) {
console.warn('[Iwara NeoUI] applyCinema failed:', e);
}
};
const toggleCinema = () => {
cinemaActive = !cinemaActive;
applyCinema();
// Persist only when nav is NOT hidden (light cinema mode)
if (!settings.cinemaHideNav) {
settings.theaterMode = cinemaActive;
saveSettings();
}
};
// ---- Keyboard shortcut (T) for Cinema Mode ----
dom.on(document, 'keydown', (e) => {
const event = e;
const target = event.target instanceof Element ? event.target : null;
if (event.key.toLowerCase?.() === 't' && !/input|textarea|select/i.test(target?.tagName || '')) {
toggleCinema();
// Sync cinema button state if it exists
const btn = dom.qs('.itw-cinema-btn');
if (btn)
btn.classList.toggle('active', cinemaActive);
}
});
// ---- Apply settings helpers ----
const applySettings = () => {
applyHideLikedBy();
applyHideRecs();
applyCinema();
};
// ---- 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;
};
// ---- Likes relocation helpers (module-level to avoid recreation) ----
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 unhideDeep = (rootEl) => {
const stack = [rootEl];
while (stack.length) {
const el = stack.pop();
if (!el)
continue;
if (el.hasAttribute('hidden'))
el.removeAttribute('hidden');
if (el instanceof HTMLElement) {
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(...Array.from(el.children));
}
};
const moveLikesIntoPanel = () => {
if (!settings.showLikesTab)
return;
const likesPanel = document.querySelector('.itw-panel-likes');
if (!likesPanel)
return;
const list = document.querySelector('.likesList');
const title = [...document.querySelectorAll('.text--h3, h2, h3')]
.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) 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) .block__content of either
if (!block)
block = listBC || titleBC;
// 4) Fall back to typical blocks
if (!block && list) {
block = list.closest('.block, .contentBlock, .block--padding, .card, .panel')
|| (title ? title.parentElement : null)
|| list.parentElement;
}
if (!block && title) {
block = title.closest('.block, .contentBlock, .block--padding, .card, .panel') || title.parentElement;
}
// Prefer outer block/card over inner .block__content
if (block?.matches('.block__content') && block.parentElement?.matches('.block, .contentBlock, .block--padding, .card, .panel, .section')) {
block = block.parentElement;
}
// Defer if list exists but is empty (site may lazy-render)
if (!likesPanel.contains(block || document.body) && list && list.childElementCount === 0)
return;
// Not redundant with adoptIntoPanel's internal check: this also gates the
// tag + unhide side effects, which must never hit an ancestor of the panel.
if (block && !block.contains(likesPanel)) {
block.classList.add('itw-liked-by');
unhideDeep(block);
adoptIntoPanel(likesPanel, block);
}
};
// ---- 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="${t('tabs.aria')}"></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', t('tabs.about'), true);
const uploadsTab = makeTab('uploads', t('tabs.uploads'));
const recsTab = makeTab('recs', t('tabs.recs'));
const likesTab = makeTab('likes', t('tabs.likes'));
const commentsTab = makeTab('comments', t('tabs.comments'));
tabbar.append(aboutTab);
tabbar.append(uploadsTab);
if (settings.showRecsTab)
tabbar.append(recsTab);
if (settings.showLikesTab)
tabbar.append(likesTab);
tabbar.append(commentsTab);
// Cinema mode toggle (pushed to the right via margin-left:auto)
const cinemaBtn = dom.el(`<button class="itw-cinema-btn${cinemaActive ? ' active' : ''}" type="button" title="${t('cinema.title')}">${t('cinema.button')}</button>`);
dom.on(cinemaBtn, 'click', () => {
toggleCinema();
cinemaBtn.classList.toggle('active', cinemaActive);
});
tabbar.append(cinemaBtn);
// 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). These two are
// ordering hoists: the node usually already sits inside details/aboutBody
// and must be re-appended to the panel tail, so adoptIntoPanel (which
// skips already-inside nodes) doesn't fit — guard only against the
// closest() walk overshooting to a container of the panel.
const descEl = (details?.querySelector('.showMore, .page-video__description, .description, .markdown')) ||
([...mainCol.querySelectorAll('.showMore, .page-video__description, .description, .markdown')].find(el => !el.closest('.comments')));
const descBlock = descEl ? (descEl.closest('.contentBlock') || descEl) : null;
if (descBlock && !descBlock.contains(aboutBody))
aboutBody.append(descBlock);
// 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 && !tagsWrap.contains(aboutBody))
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;
adoptIntoPanel(uploadsPanel, 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;
adoptIntoPanel(recsPanel, block);
}
// Initial Likes relocation
moveLikesIntoPanel();
// Keep trying as site re-renders/paginates the Likes block (debounced)
if (__itwLikesObserver) {
try {
__itwLikesObserver.disconnect();
}
catch { }
}
let likesDebounce = null;
__itwLikesObserver = new MutationObserver(() => {
if (likesDebounce)
return;
likesDebounce = setTimeout(() => { likesDebounce = null; moveLikesIntoPanel(); }, TIMEOUTS.OBSERVER_DEBOUNCE);
});
__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();
})();
}
// 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();
// Settings load at document-start; the page UI needs the parsed DOM.
await domReady();
try {
blacklistFeature = await createBlacklistFeature(document, blacklistStore, publishBlacklist);
}
catch (error) {
blacklistError = error instanceof Error ? error.message : String(error);
console.warn('[Iwara NeoUI] Blacklist could not load:', error);
}
// Restore cinema state from persisted theaterMode (only for light cinema)
if (settings.theaterMode && !settings.cinemaHideNav) {
cinemaActive = true;
}
ensureModal();
await ensureHeaderButton();
findLikedBySection();
findRecsSection();
applySettings();
applyUiMode();
let mainDebounce = null;
const onDomChange = () => {
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 (!isVideoPage() && document.querySelector('.itw-tabbar'))
teardownVideoTabs();
if (isVideoPage()) {
if (!document.querySelector(SELECTORS.LIKED_BY))
findLikedBySection();
if (!document.querySelector(SELECTORS.RECS))
findRecsSection();
if (cinemaActive && !document.body.classList.contains(CSS_CLASSES.THEATER))
applyCinema();
if (settings.newUI && !document.querySelector(SELECTORS.TABBAR))
setupVideoTabs();
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);
adoptIntoPanel(likesPanel, block);
}
}
}
if (!settings.newUI) {
if (document.querySelector('.itw-tabbar'))
teardownVideoTabs();
document.body.classList.remove(CSS_CLASSES.THEATER);
}
};
observerManager.create('main', () => {
if (mainDebounce)
return;
mainDebounce = setTimeout(() => { mainDebounce = null; onDomChange(); }, TIMEOUTS.OBSERVER_DEBOUNCE);
});
observerManager.observe('main');
})();
})();
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
module.exports = {
DEFAULTS,
MESSAGES,
createLanguageSelect,
getBrowserLanguages,
localeFromLanguageTag,
normalizeSettings,
resolveLocale,
t,
adoptIntoPanel,
parseBlacklist,
mergeBlacklist,
applyBlacklist,
createBlacklistFeature,
blacklistId,
listRoute,
listItemId,
listItemBlocked,
listKey,
listPage,
createListMemory,
parseFilterDetail,
rebuildResponse,
createListFilter,
installListFilter,
pageFilterSource,
injectPageFilter,
filterDetail,
};
}