Iwara NeoUI

Enhanced UI for Iwara with tabbed layout, theater mode, customizable sections, and improved video page experience.

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==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,
    };
}