☰

Iwara NeoUI

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

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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.8.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,
    neoPlayer: false,
    neoPlayerAutoplay: false,
    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)',
        'settings.neoPlayer': 'Use NeoUI player',
        'settings.neoPlayerAutoplay': 'Autoplay videos in the NeoUI player',
        '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)',
        'player.play': 'Play',
        'player.pause': 'Pause',
        'player.mute': 'Mute',
        'player.unmute': 'Unmute',
        'player.volume': 'Volume',
        'player.seek': 'Seek bar',
        'player.quality': 'Quality',
        'player.pip': 'Picture in picture',
        'player.cinema': 'Cinema mode',
        'player.fullscreen': 'Full screen',
        'player.exitFullscreen': 'Exit full screen',
    },
    'zh-CN': {
        'settings.button': '选项',
        'settings.title': 'Iwara NeoUI - 设置',
        'settings.language': '语言',
        'settings.language.auto': '自动',
        'settings.showLikes': '显示“喜欢”标签',
        'settings.showRecs': '显示“推荐”标签',
        'settings.hideNav': '影院模式下隐藏导航',
        'settings.newUi': '启用新界面(标签页 + 全宽视频)',
        'settings.neoPlayer': '使用 NeoUI 播放器',
        'settings.neoPlayerAutoplay': 'NeoUI 播放器自动播放视频',
        'actions.close': '关闭',
        'actions.save': '保存',
        'tabs.about': '简介',
        'tabs.uploads': '上传',
        'tabs.recs': '推荐',
        'tabs.likes': '喜欢',
        'tabs.comments': '评论',
        'tabs.aria': 'Iwara NeoUI 标签页',
        'cinema.button': '影院',
        'cinema.title': '切换影院模式 (T)',
        'player.play': '播放',
        'player.pause': '暂停',
        'player.mute': '静音',
        'player.unmute': '取消静音',
        'player.volume': '音量',
        'player.seek': '进度条',
        'player.quality': '画质',
        'player.pip': '画中画',
        'player.cinema': '影院模式',
        'player.fullscreen': '全屏',
        'player.exitFullscreen': '退出全屏',
    },
    'zh-TW': {
        'settings.button': '選項',
        'settings.title': 'Iwara NeoUI - 設定',
        'settings.language': '語言',
        'settings.language.auto': '自動',
        'settings.showLikes': '顯示「喜歡」分頁',
        'settings.showRecs': '顯示「推薦」分頁',
        'settings.hideNav': '劇院模式下隱藏導覽列',
        'settings.newUi': '啟用新介面(分頁 + 全寬影片)',
        'settings.neoPlayer': '使用 NeoUI 播放器',
        'settings.neoPlayerAutoplay': 'NeoUI 播放器自動播放影片',
        'actions.close': '關閉',
        'actions.save': '儲存',
        'tabs.about': '簡介',
        'tabs.uploads': '上傳',
        'tabs.recs': '推薦',
        'tabs.likes': '喜歡',
        'tabs.comments': '留言',
        'tabs.aria': 'Iwara NeoUI 分頁',
        'cinema.button': '劇院',
        'cinema.title': '切換劇院模式 (T)',
        'player.play': '播放',
        'player.pause': '暫停',
        'player.mute': '靜音',
        'player.unmute': '取消靜音',
        'player.volume': '音量',
        'player.seek': '進度列',
        'player.quality': '畫質',
        'player.pip': '子母畫面',
        'player.cinema': '劇院模式',
        'player.fullscreen': '全螢幕',
        'player.exitFullscreen': '結束全螢幕',
    },
    ja: {
        'settings.button': 'オプション',
        'settings.title': 'Iwara NeoUI - 設定',
        'settings.language': '言語',
        'settings.language.auto': '自動',
        'settings.showLikes': '「いいね」タブを表示',
        'settings.showRecs': '「おすすめ」タブを表示',
        'settings.hideNav': 'シネマモードでナビゲーションを隠す',
        'settings.newUi': '新UIを有効化(タブ + 全幅動画)',
        'settings.neoPlayer': 'NeoUI プレーヤーを使う',
        'settings.neoPlayerAutoplay': 'NeoUI プレーヤーで動画を自動再生',
        'actions.close': '閉じる',
        'actions.save': '保存',
        'tabs.about': '概要',
        'tabs.uploads': '投稿',
        'tabs.recs': 'おすすめ',
        'tabs.likes': 'いいね',
        'tabs.comments': 'コメント',
        'tabs.aria': 'Iwara NeoUI タブ',
        'cinema.button': 'シネマ',
        'cinema.title': 'シネマモードを切り替え (T)',
        'player.play': '再生',
        'player.pause': '一時停止',
        'player.mute': 'ミュート',
        'player.unmute': 'ミュート解除',
        'player.volume': '音量',
        'player.seek': 'シークバー',
        'player.quality': '画質',
        'player.pip': 'ピクチャー・イン・ピクチャー',
        'player.cinema': 'シネマモード',
        'player.fullscreen': '全画面',
        'player.exitFullscreen': '全画面を終了',
    },
    ko: {
        'settings.button': '옵션',
        'settings.title': 'Iwara NeoUI - 설정',
        'settings.language': '언어',
        'settings.language.auto': '자동',
        'settings.showLikes': '"좋아요" 탭 표시',
        'settings.showRecs': '"추천" 탭 표시',
        'settings.hideNav': '시네마 모드에서 내비게이션 숨기기',
        'settings.newUi': '새 UI 사용(탭 + 전체 폭 동영상)',
        'settings.neoPlayer': 'NeoUI 플레이어 사용',
        'settings.neoPlayerAutoplay': 'NeoUI 플레이어에서 동영상 자동 재생',
        'actions.close': '닫기',
        'actions.save': '저장',
        'tabs.about': '정보',
        'tabs.uploads': '업로드',
        'tabs.recs': '추천',
        'tabs.likes': '좋아요',
        'tabs.comments': '댓글',
        'tabs.aria': 'Iwara NeoUI 탭',
        'cinema.button': '시네마',
        'cinema.title': '시네마 모드 전환 (T)',
        'player.play': '재생',
        'player.pause': '일시정지',
        'player.mute': '음소거',
        'player.unmute': '음소거 해제',
        'player.volume': '볼륨',
        'player.seek': '탐색 바',
        'player.quality': '화질',
        'player.pip': '화면 속 화면',
        'player.cinema': '시네마 모드',
        'player.fullscreen': '전체 화면',
        'player.exitFullscreen': '전체 화면 종료',
    },
};
let currentLocale = 'en';
function getLocale() {
    return currentLocale;
}
function setLocale(locale) {
    currentLocale = locale;
}
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;
}
const NEO_CORE_API = Object.freeze({
    DEFAULTS,
    MESSAGES,
    createLanguageSelect,
    getBrowserLanguages,
    getLocale,
    setLocale,
    isLanguageSetting,
    localeFromLanguageTag,
    normalizeSettings,
    resolveLocale,
    t,
});
globalThis[Symbol.for('iwara-neoui/core/v1')] = NEO_CORE_API;
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = NEO_CORE_API;
}

"use strict";
(() => {
    'use strict';
    function core() {
        return typeof window === 'undefined'
            ? module.require('./iwara-neoui.ts')
            : globalThis[Symbol.for('iwara-neoui/core/v1')];
    }
    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[core().getLocale()][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();
            },
        };
    }
    const api = Object.freeze({
        EMPTY_BLACKLIST,
        BLACKLIST_TEXT,
        parseBlacklist,
        mergeBlacklist,
        blacklistId,
        applyBlacklist,
        createBlacklistFeature,
    });
    globalThis[Symbol.for('iwara-neoui/blacklist/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
(() => {
    'use strict';
    // ---- Page filter ----
    // These functions run inside iwara's page: pageFilterSource() serializes
    // them with Function.prototype.toString(). They may use each other, their
    // parameters and standard browser globals only; module-level constants and
    // helpers do not exist there.
    // 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,
    ];
    const playerBridge = () => globalThis[Symbol.for('iwara-neoui/player-bridge/v1')];
    // Every function the page script declares: the list filter, then the
    // player bridge and the stream-URL functions it calls. Only their source
    // text reaches the page; the script registers nothing there, so it cannot
    // collide with Iwara Keepalive's copy of the stream-URL api.
    function pageScriptParts() {
        return [...PAGE_FILTER_PARTS, ...playerBridge().pageParts()];
    }
    function pageFilterSource(eventName) {
        const parts = pageScriptParts().map(part => part.toString()).join('\n');
        const events = JSON.stringify(playerBridge().PLAYER_EVENTS);
        return `(() => {\n'use strict';\n${parts}\ninstallListFilter(window, ${JSON.stringify(eventName)});\n` +
            `installPlayerBridge(window, ${events});\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 }));
    }
    const api = Object.freeze({
        PAGE_FILTER_EVENT,
        listRoute,
        listItemId,
        listItemBlocked,
        listKey,
        listPage,
        createListMemory,
        parseFilterDetail,
        rebuildResponse,
        createListFilter,
        installListFilter,
        pageScriptParts,
        pageFilterSource,
        injectPageFilter,
        filterDetail,
        dispatchFilterDetail,
    });
    globalThis[Symbol.for('iwara-neoui/page-filter/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
(() => {
    'use strict';
    function featureCss() {
        return `
    .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 tab */
    body.itw-tabs-active .moreLikeThis { display:none !important; }
    body.itw-tabs-active [data-itw-tab="recs"].moreLikeThis, body.itw-tabs-active [data-itw-tab="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; }
    body.itw-theater .itw-player { width: 100% !important; margin: 0 auto !important; }
    body.itw-theater .itw-player__video { width: 100% !important; max-height: 85vh !important; object-fit: contain !important; }
    /* Restore padding below the player for tabs/content */
    body.itw-theater .itw-tabbar { padding-left: 16px !important; padding-right: 16px !important; }
    body.itw-theater.itw-tabs-active [data-itw-tab] { margin-left: 16px !important; margin-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. iwara's nodes stay in its two columns:
       display:contents turns both into one list under the tab bar, and only
       the entries marked with the selected tab show. */
    body.itw-tabs-active .col-12.col-md-9 { max-width: 100% !important; flex: 0 0 100% !important; }
    body.itw-tabs-active .row:has(> .page-video__sidebar) { flex-direction: column !important; flex-wrap: nowrap !important; margin-left: 0 !important; margin-right: 0 !important; }
    body.itw-tabs-active .row:has(> .page-video__sidebar) > .col-12 { display: contents !important; }
    body.itw-tabs-active .page-video__sidebar > :not([data-itw-tab]) { display: none !important; }
    /* iwara's unmarked blocks after the tab bar (its ad slot) stay below the tab content */
    body.itw-tabs-active .itw-tabbar ~ :not([data-itw-tab]) { order: 1; }
    body.itw-tabs-active[data-itw-tab-active="about"] [data-itw-tab]:not([data-itw-tab="about"]),
    body.itw-tabs-active[data-itw-tab-active="uploads"] [data-itw-tab]:not([data-itw-tab="uploads"]),
    body.itw-tabs-active[data-itw-tab-active="recs"] [data-itw-tab]:not([data-itw-tab="recs"]),
    body.itw-tabs-active[data-itw-tab-active="likes"] [data-itw-tab]:not([data-itw-tab="likes"]),
    body.itw-tabs-active[data-itw-tab-active="comments"] [data-itw-tab]:not([data-itw-tab="comments"]) { 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; }

    body.itw-tabs-active .itw-tabbar { margin-bottom:12px; }
    /* 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 { display:none !important; }

    /* Force visibility for Likes content in the Likes tab */
    body.itw-tabs-active [data-itw-tab="likes"].itw-liked-by,
    body.itw-tabs-active [data-itw-tab="likes"].block,
    body.itw-tabs-active [data-itw-tab="likes"] .block,
    body.itw-tabs-active [data-itw-tab="likes"] .block__content,
    body.itw-tabs-active [data-itw-tab="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 [data-itw-tab="likes"].block, body.itw-tabs-active [data-itw-tab="likes"] .block { display:flex !important; flex-direction:column !important; gap:16px !important; }
    /* Override Bootstrap row layout for horizontal scrolling */
    body.itw-tabs-active [data-itw-tab="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 [data-itw-tab="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 [data-itw-tab="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 [data-itw-tab="likes"] .likesList__item > * { display:flex !important; }
    /* Pagination row - centered below users */
    body.itw-tabs-active [data-itw-tab="likes"] .pagination { display:flex !important; justify-content:center !important; margin-top:8px !important; }
    /* Ensure user avatars are properly sized */
    body.itw-tabs-active [data-itw-tab="likes"] .likesList img { width:60px !important; height:60px !important; border-radius:50% !important; object-fit:cover !important; }
  ` + playerCss();
    }
    // The NeoUI player. The strip sits below the video on the page and turns
    // into an overlay in fullscreen. Dark values are the default; body.light
    // switches to iwara's light colours.
    // iwara's stopped video.js player stays in the host, hidden.
    function playerCss() {
        return `
    .itw-player-host > .videoPlayer { display:none !important; }
    .itw-player { --itw-player-controls:54px; position:relative; display:flex; flex-direction:column; width:100%; background:#000; font:500 13px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; user-select:none; }
    .itw-player__video { display:block; width:100%; aspect-ratio:16/9; background:#000; object-fit:contain; cursor:pointer; }
    .itw-player__controls { position:relative; z-index:1; background:#1f2329; color:#f8f8ff; }
    body.light .itw-player__controls { background:#e8e8e8; color:#444; }
    .itw-player__progress { position:relative; height:14px; cursor:pointer; touch-action:none; outline:none; }
    .itw-player__track { position:absolute; left:0; right:0; top:4px; height:4px; background:#3a404b; overflow:hidden; }
    .itw-player__progress:hover .itw-player__track, .itw-player__progress:focus-visible .itw-player__track { top:3px; height:6px; }
    .itw-player__buffered, .itw-player__played { position:absolute; top:0; bottom:0; left:0; width:0; }
    .itw-player__buffered { background:#4d535e; }
    .itw-player__played { background:#ff4b4b; }
    body.light .itw-player__track { background:#c4c4c4; }
    body.light .itw-player__buffered { background:#a8a8a8; }
    .itw-player__handle { position:absolute; top:6px; left:0; width:12px; height:12px; margin:-6px 0 0 -6px; border-radius:50%; background:#ff4b4b; pointer-events:none; }
    .itw-player__preview { position:absolute; bottom:calc(100% + 8px); left:0; transform:translateX(-50%); display:flex; flex-direction:column; align-items:center; gap:4px; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.8); pointer-events:none; }
    .itw-player__preview[hidden], .itw-player__preview-video[hidden] { display:none; }
    .itw-player__preview-video { display:block; width:160px; aspect-ratio:16/9; background:#000; border-radius:4px; object-fit:cover; }
    .itw-player__preview-time { padding:2px 6px; border-radius:4px; background:rgba(0,0,0,.7); font-variant-numeric:tabular-nums; }
    .itw-player__row { display:flex; align-items:center; gap:4px; height:40px; padding:0 8px; }
    .itw-player__row button { appearance:none; display:inline-flex; align-items:center; justify-content:center; min-width:36px; height:36px; padding:0 8px; border:0; border-radius:6px; background:none; color:inherit; font:inherit; cursor:pointer; }
    .itw-player__row button:hover, .itw-player__row button:focus-visible { background:rgba(127,127,127,.22); outline:none; }
    .itw-player__row button[hidden], .itw-player__quality-wrap[hidden] { display:none; }
    .itw-player svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
    .itw-player__volume { display:flex; align-items:center; }
    .itw-player__volume-range { width:80px; margin:0 4px; accent-color:#ff4b4b; cursor:pointer; }
    .itw-player__time { white-space:nowrap; font-variant-numeric:tabular-nums; }
    .itw-player__spacer { flex:1; }
    .itw-player__cinema.active { color:#ff4b4b; }
    .itw-player__quality-wrap { position:relative; }
    .itw-player__quality-menu { position:absolute; right:0; bottom:calc(100% + 6px); z-index:2; display:flex; flex-direction:column; min-width:96px; padding:4px; background:#1f2329; border:1px solid #3a404b; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.45); }
    body.light .itw-player__quality-menu { background:#e8e8e8; border-color:#c8c8c8; box-shadow:0 8px 24px rgba(0,0,0,.15); }
    .itw-player__quality-menu[hidden] { display:none; }
    .itw-player__quality-menu button { justify-content:flex-start; width:100%; }
    .itw-player__quality-menu button[aria-checked="true"] { color:#ff4b4b; }
    .itw-player__seek-total { position:absolute; top:0; bottom:var(--itw-player-controls); z-index:2; display:flex; align-items:center; gap:2px; color:#fff; font:700 22px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; text-shadow:0 1px 3px rgba(0,0,0,.9); pointer-events:none; }
    .itw-player__seek-total[hidden] { display:none; }
    .itw-player__seek-total--forward { right:24px; }
    .itw-player__seek-total--back { left:24px; }
    .itw-player__seek-total svg { width:24px; height:24px; stroke-width:3; filter:drop-shadow(0 1px 2px rgba(0,0,0,.9)); }
    /* Fullscreen: the controls overlay the bottom of the video */
    .itw-player:fullscreen { justify-content:center; }
    .itw-player:fullscreen .itw-player__video { height:100%; aspect-ratio:auto; }
    .itw-player:fullscreen .itw-player__controls { position:absolute; left:0; right:0; bottom:0; background:none; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.8); transition:opacity .2s; }
    .itw-player:fullscreen .itw-player__controls::before { content:''; position:absolute; left:0; right:0; bottom:0; z-index:-1; height:33vh; background:linear-gradient(to bottom, transparent, rgba(0,0,0,.55)); pointer-events:none; }
    .itw-player:fullscreen .itw-player__controls svg { filter:drop-shadow(0 1px 2px rgba(0,0,0,.8)); }
    .itw-player:fullscreen .itw-player__track { top:5px; height:3px; background:#3a404b; }
    .itw-player:fullscreen .itw-player__buffered { background:#4d535e; }
    .itw-player:fullscreen .itw-player__quality-menu { background:rgba(20,22,26,.92); border-color:rgba(255,255,255,.15); color:#fff; }
    .itw-player:fullscreen .itw-player__seek-total { bottom:0; }
    .itw-player--idle:fullscreen, .itw-player--idle:fullscreen .itw-player__video { cursor:none; }
    .itw-player--idle:fullscreen .itw-player__controls { opacity:0; pointer-events:none; }
  `;
    }
    const api = Object.freeze({
        featureCss,
    });
    globalThis[Symbol.for('iwara-neoui/styles/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
(() => {
    'use strict';
    function core() {
        return typeof window === 'undefined'
            ? module.require('./iwara-neoui.ts')
            : globalThis[Symbol.for('iwara-neoui/core/v1')];
    }
    function blacklist() {
        return typeof window === 'undefined'
            ? module.require('./blacklist.ts')
            : globalThis[Symbol.for('iwara-neoui/blacklist/v1')];
    }
    function createSettingsUi(ctx, videoPage) {
        const { t, createLanguageSelect, isLanguageSetting, resolveLocale, getBrowserLanguages, getLocale, setLocale } = core();
        const { BLACKLIST_TEXT } = blacklist();
        const { dom, domCache, observerManager, saveSettings } = ctx;
        const { applySettings, applyCinema, applyUiMode, teardownVideoTabs } = videoPage;
        // ---- 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, ctx.settings.language, getLocale()));
            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', ctx.settings.showLikesTab, t('settings.showLikes'));
                const recsTabRow = createSwitch('itw-show-recs', ctx.settings.showRecsTab, t('settings.showRecs'));
                const cinemaNavRow = createSwitch('itw-cinema-hide-nav', ctx.settings.cinemaHideNav, t('settings.hideNav'));
                const newUiRow = createSwitch('itw-new-ui', ctx.settings.newUI, t('settings.newUi'));
                const neoPlayerRow = createSwitch('itw-neo-player', ctx.settings.neoPlayer, t('settings.neoPlayer'));
                const autoplayRow = createSwitch('itw-neo-player-autoplay', ctx.settings.neoPlayerAutoplay, t('settings.neoPlayerAutoplay'));
                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, neoPlayerRow, autoplayRow);
                if (ctx.blacklistFeature)
                    content.append(ctx.blacklistFeature.createManager());
                if (ctx.blacklistError) {
                    const notice = document.createElement('p');
                    notice.setAttribute('role', 'alert');
                    notice.textContent = `${BLACKLIST_TEXT[getLocale()].loadFailed}: ${ctx.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: ctx.settings.newUI,
                        showLikesTab: ctx.settings.showLikesTab,
                        showRecsTab: ctx.settings.showRecsTab,
                        language: ctx.settings.language,
                    };
                    if (!modalEl)
                        return;
                    ctx.settings.showLikesTab = modalEl.querySelector('#itw-show-likes')?.checked ?? ctx.settings.showLikesTab;
                    ctx.settings.showRecsTab = modalEl.querySelector('#itw-show-recs')?.checked ?? ctx.settings.showRecsTab;
                    ctx.settings.cinemaHideNav = modalEl.querySelector('#itw-cinema-hide-nav')?.checked ?? ctx.settings.cinemaHideNav;
                    ctx.settings.newUI = modalEl.querySelector('#itw-new-ui')?.checked ?? ctx.settings.newUI;
                    ctx.settings.neoPlayer = modalEl.querySelector('#itw-neo-player')?.checked ?? ctx.settings.neoPlayer;
                    ctx.settings.neoPlayerAutoplay = modalEl.querySelector('#itw-neo-player-autoplay')?.checked ?? ctx.settings.neoPlayerAutoplay;
                    const languageValue = modalEl.querySelector('#itw-language')?.value;
                    if (isLanguageSetting(languageValue))
                        ctx.settings.language = languageValue;
                    setLocale(resolveLocale(ctx.settings.language, getBrowserLanguages()));
                    const settingsBtn = document.getElementById('itw-settings-btn');
                    if (settingsBtn)
                        syncHeaderBtnText(settingsBtn);
                    const hadTabs = !!document.querySelector('.itw-tabbar');
                    const newUiChanged = prev.newUI !== ctx.settings.newUI;
                    await saveSettings();
                    // The player setting applies from the next video.
                    ctx.syncPlayer();
                    applySettings();
                    // If staying in New UI and tab composition changed, rebuild tabs
                    const tabsChanged = prev.showLikesTab !== ctx.settings.showLikesTab ||
                        prev.showRecsTab !== ctx.settings.showRecsTab ||
                        prev.language !== ctx.settings.language;
                    if (!newUiChanged && hadTabs && ctx.settings.newUI && tabsChanged) {
                        teardownVideoTabs();
                    }
                    if (newUiChanged) {
                        // Force a single reload to guarantee full revert/apply of layout across SPA hydration
                        location.reload();
                        return;
                    }
                    applyUiMode();
                    ctx.blacklistFeature?.refresh();
                    toggleModal(false);
                    if (prev.language !== ctx.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 = () => {
                    // Destroys the player of a video being left. This hook sees only
                    // history calls made in the sandbox, not iwara's own pushState, so
                    // iwara's view report may go out first; the played-segment flags
                    // are keyed by video id, so the report is correct either way.
                    ctx.syncPlayer();
                    domCache.clear();
                    observerManager.disconnect('likes');
                    if (ctx.cinemaActive && ctx.settings.cinemaHideNav) {
                        ctx.cinemaActive = false;
                        applyCinema();
                    }
                    reanchor();
                    applyUiMode();
                    ctx.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);
            }
        };
        return { ensureModal, ensureHeaderButton };
    }
    const api = Object.freeze({
        createSettingsUi,
    });
    globalThis[Symbol.for('iwara-neoui/settings-ui/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
(() => {
    'use strict';
    function core() {
        return typeof window === 'undefined'
            ? module.require('./iwara-neoui.ts')
            : globalThis[Symbol.for('iwara-neoui/core/v1')];
    }
    function createVideoPage(ctx) {
        const { t } = core();
        const { dom, domCache, saveSettings, SELECTORS, CSS_CLASSES } = ctx;
        // ---- Liked by detection and toggle ----
        const LIKED_BY_TEXTS = [
            'liked by', 'likes by', 'liked-by', 'liked',
            '喜欢', '赞过',
            'いいね',
            '좋아요',
        ];
        const tagLiked = (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, and
            // hiding .itw-liked-by would then hide the whole column.
            if (el.matches('.page, .content, .container-fluid, .row, .col-12.col-md-9, .page-video__content, .page-video__sidebar') ||
                el.querySelector('.page-video__player, .video-js, .itw-tabbar'))
                return null;
            el.classList.add('itw-liked-by');
            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 tagLiked(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 = tagLiked(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 = ctx.settings.newUI ? !ctx.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 = ctx.settings.newUI ? !ctx.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', ctx.cinemaActive);
                root.classList.toggle('itw-theater-hide-nav', ctx.cinemaActive && ctx.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 (ctx.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 (ctx.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 = () => {
            ctx.cinemaActive = !ctx.cinemaActive;
            applyCinema();
            // Persist only when nav is NOT hidden (light cinema mode)
            if (!ctx.settings.cinemaHideNav) {
                ctx.settings.theaterMode = ctx.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();
                // The cinema button is in the NeoUI player while it is on, else in the tab bar.
                if (ctx.settings.neoPlayer) {
                    ctx.player?.syncCinema();
                }
                else {
                    const btn = dom.qs('.itw-cinema-btn');
                    if (btn)
                        btn.classList.toggle('active', ctx.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;
        };
        // ---- Tab content ----
        // The tabs never move iwara's nodes. iwara keeps a reference to every node
        // it rendered and later inserts siblings before them (its ad slots arrive
        // a few milliseconds after the columns). A node moved elsewhere makes that
        // insertBefore throw, and iwara replaces the page with its error screen.
        // Each column entry is marked with its tab where it is; the CSS lays both
        // columns out as one list under the tab bar and shows the selected tab.
        const TAB_ATTR = 'data-itw-tab';
        const ACTIVE_TAB_ATTR = 'data-itw-tab-active';
        const videoColumn = () => domCache.get(SELECTORS.VIDEO_COL) || domCache.get(SELECTORS.PAGE_VIDEO_CONTENT) || domCache.get(SELECTORS.COL_MD_9);
        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));
            }
        };
        // Marks the column child that holds each found element. The first tab to
        // claim an entry keeps it, so a heading search that lands in the comments
        // cannot pull them into another tab. main.ts calls this on every DOM
        // change; a column child left unclaimed by a pass loses its mark, so an
        // entry iwara reuses for other content does not keep its old tab.
        const markTabs = () => {
            if (!document.querySelector(SELECTORS.TABBAR))
                return;
            const mainCol = videoColumn();
            if (!mainCol)
                return;
            const columns = [mainCol, domCache.get(SELECTORS.SIDEBAR)].filter((col) => !!col);
            const claimed = new Map();
            const claim = (el, tab) => {
                let entry = el ?? null;
                while (entry && !columns.includes(entry.parentElement))
                    entry = entry.parentElement;
                if (!entry || entry.matches(SELECTORS.TABBAR) || entry.matches('.page-video__player') ||
                    entry.querySelector('.page-video__player, .video-js'))
                    return null;
                if (claimed.has(entry))
                    return claimed.get(entry) === tab ? entry : null;
                claimed.set(entry, tab);
                return entry;
            };
            const details = mainCol.querySelector('.page-video__details');
            claim(details, 'about');
            claim(details?.querySelector('.showMore, .page-video__description, .description, .markdown') ||
                [...mainCol.querySelectorAll('.showMore, .page-video__description, .description, .markdown')].find(el => !el.closest('.comments')), 'about');
            claim(mainCol.querySelector('.page-video__tags'), 'about');
            claim(mainCol.querySelector('.page-video__bottom'), 'about');
            claim(mainCol.querySelector('.comments'), 'comments');
            claim(document.querySelector('.page-video__sidebar .moreFromUser'), 'uploads');
            if (ctx.settings.showRecsTab) {
                claim(dom.qs('.itw-recs') || dom.qs('.moreLikeThis') || [...document.querySelectorAll('.text--h3, h2, h3')].find(h => /more like this|recommended|related/i.test(h.textContent || '')), 'recs');
            }
            if (ctx.settings.showLikesTab) {
                const likes = claim(document.querySelector(SELECTORS.LIKES_LIST) || dom.qs(SELECTORS.LIKED_BY) ||
                    [...document.querySelectorAll('.text--h3, h2, h3')].find(el => /liked by/i.test(el.textContent || '')), 'likes');
                if (likes && tagLiked(likes))
                    unhideDeep(likes);
            }
            for (const column of columns) {
                for (const entry of column.children) {
                    const tab = claimed.get(entry);
                    if (!tab)
                        entry.removeAttribute(TAB_ATTR);
                    else if (entry.getAttribute(TAB_ATTR) !== tab)
                        entry.setAttribute(TAB_ATTR, tab);
                }
            }
        };
        // ---- Tabs: About / Uploads / Recommended / Likes / Comments ----
        const setupVideoTabs = () => {
            try {
                // Do not build tabs in vanilla mode
                if (!ctx.settings.newUI)
                    return;
                // Safety: if previous class lingered but no UI is present, clear it
                if (!document.querySelector(SELECTORS.TABBAR)) {
                    document.body.classList.remove(CSS_CLASSES.TABS_ACTIVE);
                }
                if (document.querySelector(SELECTORS.TABBAR))
                    return; // already set up
                if (!isVideoPage())
                    return; // only on actual video pages
                const mainCol = videoColumn();
                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;
                // Tab bar
                const tabbar = dom.el(`<div class="itw-tabbar" role="tablist" aria-label="${t('tabs.aria')}"></div>`);
                const makeTab = (id, text) => dom.el(`<button class="itw-tab" role="tab" id="itw-tab-${id}" aria-selected="false" data-tab="${id}">${text}</button>`);
                const tabs = [makeTab('about', t('tabs.about')), makeTab('uploads', t('tabs.uploads'))];
                if (ctx.settings.showRecsTab)
                    tabs.push(makeTab('recs', t('tabs.recs')));
                if (ctx.settings.showLikesTab)
                    tabs.push(makeTab('likes', t('tabs.likes')));
                tabs.push(makeTab('comments', t('tabs.comments')));
                tabbar.append(...tabs);
                // Cinema mode toggle (pushed to the right via margin-left:auto). The
                // NeoUI player has its own in its control strip.
                if (!ctx.settings.neoPlayer) {
                    const cinemaBtn = dom.el(`<button class="itw-cinema-btn${ctx.cinemaActive ? ' active' : ''}" type="button" title="${t('cinema.title')}">${t('cinema.button')}</button>`);
                    dom.on(cinemaBtn, 'click', () => {
                        toggleCinema();
                        cinemaBtn.classList.toggle('active', ctx.cinemaActive);
                    });
                    tabbar.append(cinemaBtn);
                }
                // Right after the column entry that holds the player. An added node is
                // safe: iwara's references stay where it put them.
                let playerEntry = playerHost;
                while (playerEntry.parentElement && playerEntry.parentElement !== mainCol)
                    playerEntry = playerEntry.parentElement;
                mainCol.insertBefore(tabbar, playerEntry.nextSibling);
                markTabs();
                const activate = (which) => {
                    for (const btn of tabs)
                        btn.setAttribute('aria-selected', String(btn === which));
                    document.body.setAttribute(ACTIVE_TAB_ATTR, which.dataset.tab || 'about');
                };
                activate(tabs[0]);
                for (const tab of tabs) {
                    dom.on(tab, 'click', () => {
                        markTabs();
                        activate(tab);
                    });
                }
                document.body.classList.add(CSS_CLASSES.TABS_ACTIVE);
            }
            catch (e) {
                console.warn('[Iwara NeoUI] Tabs setup skipped:', e);
            }
        };
        const teardownVideoTabs = () => {
            try {
                document.querySelector(SELECTORS.TABBAR)?.remove();
                for (const entry of document.querySelectorAll(`[${TAB_ATTR}]`))
                    entry.removeAttribute(TAB_ATTR);
                document.body.removeAttribute(ACTIVE_TAB_ATTR);
                // 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 (ctx.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');
            }
        };
        return {
            findLikedBySection,
            findRecsSection,
            applySettings,
            applyCinema,
            toggleCinema,
            applyUiMode,
            setupVideoTabs,
            teardownVideoTabs,
            markTabs,
            isVideoPage,
        };
    }
    const api = Object.freeze({
        createVideoPage,
    });
    globalThis[Symbol.for('iwara-neoui/video-page/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
// iwara stream URLs: signed-URL expiry, variant choice and token renewal,
// shared by Iwara Keepalive and Iwara NeoUI. Registered under
// Symbol.for('iwara/stream-urls/v1').
//
// Every api function is a plain function declaration that calls the others by
// bare name and uses only its parameters and browser globals: no module
// constants. NeoUI injects them into the page by joining their toString(), so
// a reference to anything else in this file would fail there.
(() => {
    'use strict';
    function parseExpirySeconds(url) {
        let raw;
        try {
            raw = new URL(url, 'https://www.iwara.tv').searchParams.get('expires');
        }
        catch {
            return null;
        }
        if (raw == null || raw === '')
            return null;
        const n = Number(raw);
        if (!Number.isFinite(n) || n <= 0)
            return null;
        return n > 1e12 ? Math.floor(n / 1000) : n;
    }
    // marginSec: how long before expiry a URL already counts as 'near'.
    function classifyFreshness(url, nowSec, marginSec = 90) {
        const exp = parseExpirySeconds(url);
        if (exp == null)
            return 'stale';
        if (nowSec >= exp)
            return 'stale';
        if (nowSec >= exp - marginSec)
            return 'near';
        return 'fresh';
    }
    function isStaleOrNear(url, nowSec, marginSec = 90) {
        const f = classifyFreshness(url, nowSec, marginSec);
        return f === 'stale' || f === 'near';
    }
    function extractVideoId(pathname) {
        const m = /^\/video\/([^/]+)/.exec(pathname || '');
        return m ? m[1] : null;
    }
    function toAbsoluteUrl(url) {
        return url.startsWith('//') ? `https:${url}` : url;
    }
    function parseVariantName(viewUrl) {
        let filename;
        try {
            filename = new URL(viewUrl, 'https://www.iwara.tv').searchParams.get('filename');
        }
        catch {
            return null;
        }
        if (!filename)
            return null;
        const m = /_([^_.]+)\.[a-z0-9]+$/i.exec(filename);
        return m ? m[1] : null;
    }
    function pickVariant(variants, currentViewUrl) {
        if (!Array.isArray(variants) || variants.length === 0)
            return null;
        const byName = (name) => variants.find((v) => v && v.name === name);
        const desired = parseVariantName(currentViewUrl);
        return ((desired ? byName(desired) : undefined) ||
            byName('Source') ||
            variants.find((v) => v && v.name !== 'preview') ||
            variants[0]);
    }
    function shouldRefresh(currentUrl, nowSec, opts = {}) {
        if (opts.force)
            return true;
        return isStaleOrNear(currentUrl, nowSec, opts.marginSec);
    }
    // ── Buffered awareness ────────────────────────────────────────────────────
    // An expired URL is only fatal for data the player still has to FETCH. When
    // the buffer already covers the whole video, no refresh is needed at all —
    // and swapping the source would throw that buffer away.
    function isFullyBuffered(video, slackSec = 0.5) {
        const dur = video.duration;
        if (typeof dur !== 'number' || !Number.isFinite(dur) || dur <= 0)
            return false;
        const buffered = video.buffered;
        if (!buffered || !buffered.length)
            return false;
        const ranges = [];
        try {
            for (let i = 0; i < buffered.length; i++)
                ranges.push([buffered.start(i), buffered.end(i)]);
        }
        catch {
            return false;
        }
        ranges.sort((a, b) => a[0] - b[0]);
        let covered = 0;
        for (const [start, end] of ranges) {
            if (start > covered + slackSec)
                return false; // a gap a seek could land in
            if (end > covered)
                covered = end;
        }
        return covered >= dur - slackSec;
    }
    // Which player errors warrant attempting a URL refresh: code 4 (src rejected
    // outright); code 2 (download died part-way) only when the URL is
    // stale/near — that is expiry killing an unbuffered fetch (mid-play or a seek
    // into an unbuffered region). Code 2 on a fresh URL is a real network problem
    // and code 3 is corrupt media; a swap fixes neither.
    function shouldForceRefreshOnError(code, currentUrl, nowSec) {
        if (code === 4)
            return true;
        if (code === 2)
            return isStaleOrNear(currentUrl, nowSec);
        return false;
    }
    // A refresh only helps if it hands back a DIFFERENT url that reads as fresh.
    // Anything else (the same url again, one already past its expiry, or one whose
    // expiry cannot be read at all) leaves the next freshness check saying "stale"
    // — and since restoring playback fires `play`, that check runs immediately.
    function isProductiveRefresh(freshUrl, currentUrl, nowSec) {
        if (!freshUrl || freshUrl === currentUrl)
            return false;
        return !isStaleOrNear(freshUrl, nowSec);
    }
    // Metadata failures and exhausted media recovery block all checks. Repeated
    // unchanged/stale URLs only suppress automatic checks. Fetching a URL does
    // not prove that its media plays, so it cannot reset the recovery allowance.
    // A refresh whose source keeps 404ing even after token renewal cannot be
    // kept alive; without the gate every play/seek would hit the API again.
    function createRefreshGate(maxConsecutiveFailures = 3, maxUnproductive = 2, maxRecoveryAttempts = 3) {
        let currentId = null;
        let failures = 0;
        let tripped = false;
        let unproductive = 0;
        let suppressed = false;
        let recoveries = 0;
        let recoveryStopped = false;
        const sync = (id) => {
            if (id !== currentId) {
                currentId = id;
                failures = 0;
                tripped = false;
                unproductive = 0;
                suppressed = false;
                recoveries = 0;
                recoveryStopped = false;
            }
        };
        return {
            allow(id, opts = {}) {
                sync(id);
                if (tripped || recoveryStopped)
                    return false;
                return !suppressed || opts.force === true;
            },
            beginRecovery(id) {
                sync(id);
                if (recoveries >= maxRecoveryAttempts) {
                    recoveryStopped = true;
                    return false;
                }
                recoveries += 1;
                return true;
            },
            recordPlayback(id) {
                sync(id);
                recoveries = 0;
                recoveryStopped = false;
            },
            recordFailure(id) {
                sync(id);
                if (tripped)
                    return false;
                failures += 1;
                if (failures < maxConsecutiveFailures)
                    return false;
                tripped = true;
                return true; // just tripped — caller shows its one-time notice on this
            },
            recordSuccess(id, opts = {}) {
                sync(id);
                failures = 0;
                tripped = false;
                if (opts.productive === false) {
                    if (suppressed)
                        return false;
                    unproductive += 1;
                    if (unproductive < maxUnproductive)
                        return false;
                    suppressed = true;
                    return true; // just suppressed — caller logs this once
                }
                unproductive = 0;
                suppressed = false;
                return false;
            },
        };
    }
    function pickHarvestableHeaders(url, headers) {
        let host;
        try {
            host = new URL(url, 'https://www.iwara.tv').host;
        }
        catch {
            return null;
        }
        if (!/(^|\.)iwara\.tv$/i.test(host))
            return null;
        const out = {};
        for (const k of ['authorization', 'x-version', 'x-site']) {
            if (headers && headers[k])
                out[k] = headers[k];
        }
        return Object.keys(out).length ? out : null;
    }
    // ── Auth tokens ───────────────────────────────────────────────────────────
    // The API silently IGNORES invalid/expired Bearer tokens (no 401 — the
    // request is treated as anonymous) and answers 404 for videos that are
    // tag-gated to logged-in users. Signed stream URLs live ~50 min, about as
    // long as an access token, so by the time a refresh is needed the harvested
    // token is dead and a gated video 404s. These helpers classify tokens and
    // renew them the same way the site does: POST /user/token with the
    // long-lived token as Bearer.
    function stripBearer(value) {
        return String(value ?? '').replace(/^\s*Bearer\s+/i, '').trim();
    }
    function decodeJwtPayload(value) {
        const parts = stripBearer(value).split('.');
        if (parts.length !== 3)
            return null;
        try {
            const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
            const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
            const parsed = JSON.parse(atob(padded));
            return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
                ? parsed
                : null;
        }
        catch {
            return null;
        }
    }
    // 'opaque' (undecodable) stays usable as an access token — that is what the
    // script always did before tokens were classified, and the server ignores
    // bad tokens anyway. Expiry wins over type: an expired refresh token is
    // 'stale'. marginSec: how long before its expiry a token already counts as
    // stale.
    function classifyAuthValue(value, nowSec, marginSec = 30) {
        const payload = decodeJwtPayload(value);
        if (!payload)
            return 'opaque';
        const exp = typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp : null;
        if (exp != null && nowSec >= exp - marginSec)
            return 'stale';
        if (typeof payload.type === 'string' && /refresh/i.test(payload.type))
            return 'renewal';
        return 'access';
    }
    // The site keeps its long-lived token in localStorage; the stored shape is
    // not pinned by iwara, so accept a raw JWT, a JSON-quoted JWT, or a JSON
    // object with a token-ish field.
    function extractStoredToken(raw) {
        if (!raw)
            return null;
        const looksJwt = (s) => typeof s === 'string' && /^[\w-]+\.[\w-]+\.[\w-]+$/.test(s);
        const trimmed = String(raw).trim();
        if (looksJwt(trimmed))
            return trimmed;
        try {
            const parsed = JSON.parse(trimmed);
            if (looksJwt(parsed))
                return parsed;
            if (parsed && typeof parsed === 'object') {
                for (const key of ['token', 'accessToken', 'refreshToken']) {
                    const v = parsed[key];
                    if (looksJwt(v))
                        return v;
                }
            }
        }
        catch { /* not JSON */ }
        return null;
    }
    // Request headers with lower-case names, from a Request and from init.
    function requestHeaders(input, init) {
        const out = {};
        const collect = (headers) => {
            if (!headers)
                return;
            if (typeof Headers !== 'undefined' && headers instanceof Headers)
                headers.forEach((value, name) => { out[name.toLowerCase()] = value; });
            else if (Array.isArray(headers))
                for (const [name, value] of headers)
                    out[name.toLowerCase()] = value;
            else
                for (const name of Object.keys(headers))
                    out[name.toLowerCase()] = headers[name];
        };
        if (typeof Request !== 'undefined' && input instanceof Request)
            collect(input.headers);
        collect(init?.headers);
        return out;
    }
    // Authorization goes to a slot per token type: the site's own token
    // renewal request carries the long-lived token, which must not replace
    // the access token (or the reverse), or later refreshes fail.
    function harvestAuth(auth, url, headers, nowSec) {
        const picked = pickHarvestableHeaders(url, headers);
        if (!picked)
            return;
        const { authorization, ...rest } = picked;
        auth.headers = { ...auth.headers, ...rest };
        if (!authorization)
            return;
        const kind = classifyAuthValue(authorization, nowSec);
        if (kind === 'renewal')
            auth.renewalToken = stripBearer(authorization);
        else if (kind !== 'stale')
            auth.accessToken = stripBearer(authorization);
    }
    // The site keeps its long-lived token in localStorage under `token`; it is
    // the renewal fallback while no renewal token was harvested.
    function storedRenewalToken(win) {
        try {
            return extractStoredToken(win.localStorage.getItem('token'));
        }
        catch {
            return null; // storage access can throw in hardened contexts
        }
    }
    async function renewAccessToken(renewalToken, fetchImpl = fetch) {
        const res = await fetchImpl('https://api.iwara.tv/user/token', {
            method: 'POST',
            headers: { 'x-site': 'www.iwara.tv', authorization: `Bearer ${stripBearer(renewalToken)}` },
        });
        if (!res.ok)
            throw new Error(`renew ${res.status}`);
        const body = (await res.json());
        if (!body || typeof body.accessToken !== 'string' || !body.accessToken) {
            throw new Error('no accessToken in renewal response');
        }
        return body.accessToken;
    }
    // Deliberately NO `credentials: 'include'`: the API only returns a readable
    // CORS response in non-credentialed mode. With cookies it omits
    // Access-Control-Allow-Origin and the browser blocks the fetch. iwara
    // authorizes via the Bearer token harvested into `headers`, not cookies.
    async function fetchFreshVariants(videoId, headers, fetchImpl = fetch) {
        const base = { 'x-site': 'www.iwara.tv', ...(headers || {}) };
        const metaRes = await fetchImpl(`https://api.iwara.tv/video/${encodeURIComponent(videoId)}`, { headers: base });
        if (!metaRes.ok)
            throw new Error(`metadata ${metaRes.status}`);
        const meta = (await metaRes.json());
        if (!meta || !meta.fileUrl)
            throw new Error('no fileUrl in metadata');
        const manRes = await fetchImpl(meta.fileUrl, { headers: base });
        if (!manRes.ok)
            throw new Error(`manifest ${manRes.status}`);
        const variants = (await manRes.json());
        if (!Array.isArray(variants))
            throw new Error('manifest not an array');
        return variants;
    }
    async function selectFreshViewUrl(videoId, currentViewUrl, headers, fetchImpl = fetch) {
        const variants = await fetchFreshVariants(videoId, headers, fetchImpl);
        const variant = pickVariant(variants, currentViewUrl);
        if (!variant || !variant.src || !variant.src.view)
            throw new Error('no usable variant');
        return toAbsoluteUrl(variant.src.view);
    }
    // One metadata attempt with the best token we hold (none, if it is provably
    // dead — the server would ignore it and the anonymous result is the same),
    // then on 404 renew once and retry once. A 404 with a working renewal token
    // is the tag-gated case; without one it is genuinely deleted/private.
    // Non-404 failures are not auth problems and propagate untouched.
    async function fetchFreshVariantsWithAuth(videoId, auth, nowSec, fetchImpl = fetch) {
        const base = { ...(auth.base || {}) };
        delete base.authorization; // the chain owns the Authorization header
        const attempt = (token) => {
            const headers = token ? { ...base, authorization: `Bearer ${stripBearer(token)}` } : base;
            return fetchFreshVariants(videoId, headers, fetchImpl);
        };
        const access = auth.accessToken || null;
        const cls = access ? classifyAuthValue(access, nowSec) : null;
        const usableAccess = access && (cls === 'access' || cls === 'opaque') ? access : null;
        try {
            return { variants: await attempt(usableAccess), renewedAccessToken: null };
        }
        catch (e) {
            const msg = e instanceof Error ? e.message : String(e);
            const renewal = auth.renewalToken || null;
            if (!/^metadata 404\b/.test(msg) || !renewal)
                throw e;
            let renewed;
            try {
                renewed = await renewAccessToken(renewal, fetchImpl);
            }
            catch (renewErr) {
                const rmsg = renewErr instanceof Error ? renewErr.message : String(renewErr);
                throw new Error(`${msg}; renew failed: ${rmsg}`, { cause: renewErr });
            }
            return { variants: await attempt(renewed), renewedAccessToken: renewed };
        }
    }
    // A manifest without a usable variant fails after the token chain, so it
    // never triggers a renewal.
    async function selectFreshViewUrlWithAuth(videoId, currentViewUrl, auth, nowSec, fetchImpl = fetch) {
        const { variants, renewedAccessToken } = await fetchFreshVariantsWithAuth(videoId, auth, nowSec, fetchImpl);
        const variant = pickVariant(variants, currentViewUrl);
        if (!variant || !variant.src || !variant.src.view)
            throw new Error('no usable variant');
        return { viewUrl: toAbsoluteUrl(variant.src.view), renewedAccessToken };
    }
    const api = Object.freeze({
        parseExpirySeconds,
        classifyFreshness,
        isStaleOrNear,
        extractVideoId,
        toAbsoluteUrl,
        parseVariantName,
        pickVariant,
        shouldRefresh,
        isFullyBuffered,
        shouldForceRefreshOnError,
        isProductiveRefresh,
        createRefreshGate,
        pickHarvestableHeaders,
        stripBearer,
        decodeJwtPayload,
        classifyAuthValue,
        extractStoredToken,
        requestHeaders,
        harvestAuth,
        storedRenewalToken,
        renewAccessToken,
        fetchFreshVariants,
        fetchFreshVariantsWithAuth,
        selectFreshViewUrl,
        selectFreshViewUrlWithAuth,
    });
    globalThis[Symbol.for('iwara/stream-urls/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
(() => {
    'use strict';
    // ---- Player bridge ----
    // The page side of the NeoUI player. pageFilterSource() injects these
    // functions into iwara's page with the list filter, by toString(). There
    // they read iwara's own metadata and variant-list responses, harvest its
    // request headers and tokens, refresh variants when the sandbox asks, stop
    // iwara's video.js player on videos NeoUI's player plays, and rewrite
    // iwara's view report to carry the player's played-segment flags.
    // Like the list filter, they may use each other, their parameters, browser
    // globals and the injected page-filter and stream-URL functions only.
    const lib = (key) => globalThis[Symbol.for(key)];
    const streamUrls = () => lib('iwara/stream-urls/v1');
    // The stream-URL functions the bridge calls; the page script declares them
    // under these names.
    const STREAM_URL_PARTS = [
        'pickHarvestableHeaders', 'stripBearer', 'decodeJwtPayload', 'classifyAuthValue', 'extractStoredToken',
        'requestHeaders', 'harvestAuth', 'storedRenewalToken', 'renewAccessToken', 'fetchFreshVariants',
        'fetchFreshVariantsWithAuth',
    ];
    // In the page script these names are the injected declarations; here they
    // reach the same functions through the registered apis.
    const requestHeaders = (input, init) => streamUrls().requestHeaders(input, init);
    const harvestAuth = (auth, url, headers, nowSec) => streamUrls().harvestAuth(auth, url, headers, nowSec);
    const storedRenewalToken = win => streamUrls().storedRenewalToken(win);
    const fetchFreshVariantsWithAuth = (videoId, auth, nowSec, fetchImpl) => streamUrls().fetchFreshVariantsWithAuth(videoId, auth, nowSec, fetchImpl);
    // An id that does not decode matches no video, so the request passes
    // through instead of making fetch throw.
    function decodeVideoId(raw) {
        try {
            return decodeURIComponent(raw);
        }
        catch {
            return null;
        }
    }
    // The id of iwara's view report: POST https://api.iwara.tv/video/<id>/view.
    function viewReportVideoId(url, method) {
        if (String(method).toUpperCase() !== 'POST')
            return null;
        let parsed;
        try {
            parsed = new URL(url, 'https://www.iwara.tv/');
        }
        catch {
            return null;
        }
        if (parsed.protocol !== 'https:' || parsed.hostname !== 'api.iwara.tv')
            return null;
        const match = /^\/video\/([^/]+)\/view\/?$/.exec(parsed.pathname);
        return match ? decodeVideoId(match[1]) : null;
    }
    // The id of a video metadata request: https://api.iwara.tv/video/<id>.
    function metadataVideoId(url) {
        let parsed;
        try {
            parsed = new URL(url, 'https://www.iwara.tv/');
        }
        catch {
            return null;
        }
        if (parsed.protocol !== 'https:' || parsed.hostname !== 'api.iwara.tv')
            return null;
        const match = /^\/video\/([^/]+)\/?$/.exec(parsed.pathname);
        return match ? decodeVideoId(match[1]) : null;
    }
    // iwara requests a metadata fileUrl with `&download=<title>` appended, so
    // the list is matched without that parameter.
    function variantListKey(url) {
        try {
            const parsed = new URL(url, 'https://www.iwara.tv/');
            parsed.searchParams.delete('download');
            return parsed.href;
        }
        catch {
            return url;
        }
    }
    function rewriteViewInit(init, stats) {
        return { ...(init ?? {}), body: JSON.stringify({ stats }) };
    }
    function parsePlayerMode(detail) {
        try {
            return JSON.parse(String(detail))?.enabled === true;
        }
        catch {
            return false;
        }
    }
    function parsePlayerStats(detail) {
        try {
            const data = JSON.parse(String(detail));
            const videoId = data?.videoId;
            const stats = data?.stats;
            if (typeof videoId !== 'string' || !videoId)
                return null;
            if (!Array.isArray(stats) || stats.length !== 16 || !stats.every(flag => flag === 0 || flag === 1))
                return null;
            return { videoId, stats };
        }
        catch {
            return null;
        }
    }
    function parseRefreshRequest(detail) {
        try {
            const videoId = JSON.parse(String(detail))?.videoId;
            return typeof videoId === 'string' && videoId ? videoId : null;
        }
        catch {
            return null;
        }
    }
    // Records which video a metadata response's fileUrl belongs to. iwara gets
    // the response unchanged.
    async function recordFileUrl(response, videoId, state) {
        try {
            if (!response.ok)
                return response;
            const meta = await response.clone().json();
            const fileUrl = meta?.fileUrl;
            if (typeof fileUrl === 'string' && fileUrl) {
                state.fileUrlToVideo.set(variantListKey(fileUrl), videoId);
            }
        }
        catch { /* not JSON: nothing to record */ }
        return response;
    }
    // Sends a variant list to the sandbox. iwara gets the list unchanged once
    // the player mode is known: given an empty list it shows "Failed to fetch
    // video links", so its player is stopped after it mounts instead
    // (watchVideoJs), and that needs the mode before video.js starts.
    async function announceVariantList(response, videoId, state, signal) {
        let variants;
        try {
            if (!response.ok)
                return response;
            variants = await response.clone().json();
        }
        catch {
            return response;
        }
        if (!Array.isArray(variants))
            return response;
        const stopped = await state.stopFor(videoId).catch(() => false);
        if (signal?.aborted)
            throw signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');
        // The page alone decides which player plays this video, so the sandbox
        // mounts its player exactly when iwara's is stopped, even if its own
        // settings loaded at a different moment.
        try {
            state.onVariants(videoId, variants, stopped);
        }
        catch { /* the sandbox side is best-effort */ }
        return response;
    }
    // Disposes iwara's video.js player, which video.js keeps on its element,
    // before it loads media. iwara disposes the player again when the video
    // unmounts, and a second dispose throws in its video.js and crashes the
    // page, so that call is turned into a no-op. False when the element has
    // no player yet.
    function stopVideoJs(el) {
        const player = el.player;
        if (!player || typeof player.dispose !== 'function')
            return false;
        const dispose = player.dispose;
        player.dispose = () => { };
        try {
            dispose.call(player);
        }
        catch { /* already torn down */ }
        return true;
    }
    // Stops the video.js player iwara mounts on the video page while
    // stopping() is true. Players elsewhere on the site are left alone.
    // video.js attaches the player to its element shortly after inserting it,
    // so an element without one is retried a few times.
    function watchVideoJs(win, stopping) {
        const RETRIES = 100;
        const PLAYER = '.page-video__player .video-js';
        const stopWhenReady = (el, tries) => {
            if (!stopping() || stopVideoJs(el))
                return;
            if (tries < RETRIES && el.isConnected)
                win.setTimeout(() => stopWhenReady(el, tries + 1), tries < 5 ? 0 : 10);
        };
        new win.MutationObserver(records => {
            if (!stopping())
                return;
            for (const record of records) {
                for (const node of record.addedNodes) {
                    if (node.nodeType !== 1)
                        continue;
                    const el = node;
                    if (el.matches(PLAYER))
                        stopWhenReady(el, 0);
                    for (const inner of el.querySelectorAll(PLAYER))
                        stopWhenReady(inner, 0);
                }
            }
        }).observe(win.document, { childList: true, subtree: true });
    }
    async function refreshVariants(videoId, auth, readStoredToken, fetchImpl) {
        const { variants, renewedAccessToken } = await fetchFreshVariantsWithAuth(videoId, { base: auth.headers, accessToken: auth.accessToken, renewalToken: auth.renewalToken || readStoredToken() }, Date.now() / 1000, fetchImpl);
        if (renewedAccessToken)
            auth.accessToken = renewedAccessToken;
        return variants;
    }
    // The view report is rewritten synchronously: a report sent while the page
    // unloads would be lost behind any wait.
    function createPlayerBridge(next, state) {
        return (input, init) => {
            let url = null;
            let method = 'GET';
            const isRequest = typeof Request !== 'undefined' && input instanceof Request;
            try {
                url = isRequest ? input.url : new URL(String(input), 'https://www.iwara.tv/').href;
                method = String(init?.method ?? (isRequest ? input.method : 'GET')).toUpperCase();
                state.harvest(url, requestHeaders(input, init));
            }
            catch { /* harvesting is best-effort; never break the site's fetch */ }
            // Only plain URLs are rewritten or read, as in the list filter.
            if (!url || !(typeof input === 'string' || input instanceof URL))
                return next(input, init);
            const viewId = viewReportVideoId(url, method);
            if (viewId !== null) {
                const stats = state.stats.get(viewId);
                if (!stats)
                    return next(input, init);
                // One report per set of flags: a later report for this id, after a
                // revisit or with the player off, keeps iwara's own body.
                state.stats.delete(viewId);
                return next(input, rewriteViewInit(init, stats));
            }
            if (method !== 'GET')
                return next(input, init);
            const metaId = metadataVideoId(url);
            if (metaId !== null)
                return next(input, init).then(response => recordFileUrl(response, metaId, state));
            const listId = state.fileUrlToVideo.get(variantListKey(url));
            if (listId !== undefined) {
                return next(input, init).then(response => announceVariantList(response, listId, state, init?.signal));
            }
            return next(input, init);
        };
    }
    // Wraps the page fetch, outside the list filter. Variant lists wait for the
    // first player mode from NeoUI, at most waitMs. The first list for a video
    // decides from the mode at that moment which player plays it, and later
    // lists for the same video keep that decision until the address leaves the
    // video or another video's list arrives, so a setting change applies from
    // the next video page.
    function installPlayerBridge(win, events, waitMs = 3000) {
        const flags = win;
        if (flags.__itwNeoPlayer || typeof win.fetch !== 'function')
            return;
        flags.__itwNeoPlayer = true;
        const doc = win.document;
        // Strings: objects need cloneInto() to cross into a Firefox content script.
        const send = (name, detail) => {
            doc.dispatchEvent(new win.CustomEvent(name, { detail: JSON.stringify(detail) }));
        };
        const auth = { headers: {}, accessToken: null, renewalToken: null };
        let enabled = false;
        let decided = null;
        // iwara's router moves between pages with pushState in this window; the
        // sandbox does not see those calls. A change of slug or query on the same
        // video keeps the decision.
        const leaveVideo = () => {
            const match = /^\/video\/([^/]+)/.exec(win.location?.pathname ?? '');
            if (decided && (match ? decodeVideoId(match[1]) : null) !== decided.videoId)
                decided = null;
        };
        const history = win.history;
        if (history && typeof win.addEventListener === 'function') {
            for (const name of ['pushState', 'replaceState']) {
                const original = history[name];
                history[name] = function (...args) {
                    const result = original.apply(this, args);
                    leaveVideo();
                    return result;
                };
            }
            win.addEventListener('popstate', leaveVideo);
        }
        let release = () => { };
        const received = new Promise(resolve => { release = resolve; });
        const timer = win.setTimeout(() => release(), waitMs);
        // A window with MutationObserver is a real page window, whose document is a Node.
        if (typeof win.MutationObserver === 'function')
            watchVideoJs(win, () => decided?.stopped === true);
        doc.addEventListener(events.mode, event => {
            enabled = parsePlayerMode(event.detail);
            win.clearTimeout(timer);
            release();
        });
        const state = {
            stats: new Map(),
            fileUrlToVideo: new Map(),
            onVariants: (videoId, variants, stopped) => send(events.variants, { videoId, variants, stopped }),
            harvest: (url, headers) => harvestAuth(auth, url, headers, Date.now() / 1000),
            stopFor: async (videoId) => {
                await received;
                if (decided?.videoId !== videoId)
                    decided = { videoId, stopped: enabled };
                return decided.stopped;
            },
        };
        // A player mounted again for the same video starts with fewer flags, so
        // flags add up until the view report takes them.
        doc.addEventListener(events.stats, event => {
            const report = parsePlayerStats(event.detail);
            if (!report)
                return;
            const old = state.stats.get(report.videoId);
            state.stats.set(report.videoId, old ? old.map((flag, i) => flag | report.stats[i]) : report.stats);
        });
        const next = win.fetch.bind(win);
        doc.addEventListener(events.refresh, event => {
            const videoId = parseRefreshRequest(event.detail);
            if (!videoId)
                return;
            refreshVariants(videoId, auth, () => storedRenewalToken(win), next).then(variants => send(events.variants, { videoId, variants }), error => send(events.error, { videoId, message: error instanceof Error ? error.message : String(error) }));
        });
        win.fetch = createPlayerBridge(next, state);
    }
    const PLAYER_EVENTS = Object.freeze({
        mode: 'itw-neoui:player-mode',
        variants: 'itw-neoui:player-variants',
        refresh: 'itw-neoui:player-refresh',
        error: 'itw-neoui:player-error',
        stats: 'itw-neoui:player-stats',
    });
    const PLAYER_BRIDGE_PARTS = [
        decodeVideoId, viewReportVideoId, metadataVideoId, variantListKey, rewriteViewInit, parsePlayerMode, parsePlayerStats,
        parseRefreshRequest, recordFileUrl, announceVariantList, stopVideoJs, watchVideoJs, refreshVariants, createPlayerBridge,
        installPlayerBridge,
    ];
    function pageParts() {
        const urls = streamUrls();
        return [...PLAYER_BRIDGE_PARTS, ...STREAM_URL_PARTS.map(name => urls[name])];
    }
    const api = Object.freeze({
        PLAYER_EVENTS,
        pageParts,
        viewReportVideoId,
        metadataVideoId,
        variantListKey,
        rewriteViewInit,
        parsePlayerMode,
        parsePlayerStats,
        parseRefreshRequest,
        recordFileUrl,
        announceVariantList,
        stopVideoJs,
        watchVideoJs,
        refreshVariants,
        createPlayerBridge,
        installPlayerBridge,
    });
    globalThis[Symbol.for('iwara-neoui/player-bridge/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
(() => {
    'use strict';
    // ---- Player logic ----
    // Pure functions behind the NeoUI player: played-segment flags for iwara's
    // view report, the running seek total, quality choice, time formatting and
    // the key map. No DOM access beyond reading the element passed in.
    const SEGMENTS = 16;
    function createSegmentTracker() {
        const played = new Array(SEGMENTS).fill(0);
        return {
            mark(currentTime, duration) {
                if (!Number.isFinite(duration) || duration <= 0)
                    return false;
                if (!Number.isFinite(currentTime) || currentTime < 0)
                    return false;
                const index = Math.min(SEGMENTS - 1, Math.floor(currentTime / duration * SEGMENTS));
                if (played[index])
                    return false;
                played[index] = 1;
                return true;
            },
            flags: () => played.slice(),
            reset() { played.fill(0); },
        };
    }
    // The "+10 seconds" total shown while the arrow key is pressed repeatedly.
    function createSeekTotal(stepSec = 5, resetMs = 1000) {
        let total = 0;
        let lastMs = -Infinity;
        const visible = (nowMs) => nowMs - lastMs < resetMs;
        return {
            press(direction, nowMs) {
                if (!visible(nowMs) || Math.sign(total) !== direction)
                    total = 0;
                total += direction * stepSec;
                lastMs = nowMs;
                return total;
            },
            visible,
        };
    }
    const isPreview = (v) => v.name === 'preview';
    function qualityNames(variants) {
        const names = variants.filter(v => !isPreview(v)).map(v => v.name);
        const rank = (name) => (name === 'Source' ? Infinity : parseInt(name, 10));
        return names.sort((a, b) => {
            const ra = rank(a);
            const rb = rank(b);
            if (ra === rb)
                return 0;
            if (Number.isNaN(ra))
                return 1;
            if (Number.isNaN(rb))
                return -1;
            return rb - ra;
        });
    }
    function chooseVariant(variants, preferred) {
        const usable = variants.filter(v => !isPreview(v));
        if (!usable.length)
            return null;
        const byName = (name) => usable.find(v => v.name === name);
        const best = byName(qualityNames(usable)[0]);
        return (preferred !== null ? byName(preferred) : undefined) ?? byName('Source') ?? best ?? usable[0];
    }
    function formatTime(sec) {
        if (!Number.isFinite(sec) || sec < 0)
            return '0:00';
        const whole = Math.floor(sec);
        const h = Math.floor(whole / 3600);
        const m = Math.floor((whole % 3600) / 60);
        const s = String(whole % 60).padStart(2, '0');
        return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
    }
    function formatRemaining(currentSec, durationSec) {
        return `-${formatTime(Math.max(0, durationSec - currentSec))}`;
    }
    // Inputs that take no typed text; the player's own volume slider is one.
    const NON_TEXT_INPUTS = new Set(['range', 'checkbox', 'radio', 'button', 'submit', 'reset', 'color', 'file']);
    function isTypingTarget(el) {
        if (!el)
            return false;
        const tag = el.tagName.toUpperCase();
        if (tag === 'INPUT')
            return !NON_TEXT_INPUTS.has(String(el.type).toLowerCase());
        if (tag === 'TEXTAREA' || tag === 'SELECT')
            return true;
        const editable = el.isContentEditable;
        if (editable !== undefined)
            return editable === true;
        // jsdom does not implement isContentEditable; the attribute is the fallback.
        const attr = el.getAttribute('contenteditable');
        return attr !== null && attr.toLowerCase() !== 'false';
    }
    // `t` (cinema mode) keeps its own handler, so it is not mapped here.
    function playerKeyAction(e) {
        if (e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target))
            return null;
        switch (e.key) {
            case ' ':
            case 'k':
            case 'K': return { type: 'toggle' };
            case 'ArrowLeft': return { type: 'seek', direction: -1 };
            case 'ArrowRight': return { type: 'seek', direction: 1 };
            case 'ArrowUp': return { type: 'volume', delta: 0.1 };
            case 'ArrowDown': return { type: 'volume', delta: -0.1 };
            case 'f':
            case 'F': return { type: 'fullscreen' };
            case 'm':
            case 'M': return { type: 'mute' };
            case ',': return { type: 'frame', direction: -1 };
            case '.': return { type: 'frame', direction: 1 };
            default:
                return /^[0-9]$/.test(e.key) ? { type: 'jump', fraction: Number(e.key) / 10 } : null;
        }
    }
    const api = Object.freeze({
        createSegmentTracker,
        createSeekTotal,
        chooseVariant,
        qualityNames,
        formatTime,
        formatRemaining,
        isTypingTarget,
        playerKeyAction,
    });
    globalThis[Symbol.for('iwara-neoui/player-logic/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
(() => {
    'use strict';
    // ---- NeoUI player ----
    // The visible player that replaces iwara's video.js player: a <video> with
    // a control strip below it (an overlay in fullscreen), keyboard keys on the
    // whole page, quality switching that keeps the position, stream URL
    // refreshes, and the played-segment flags for iwara's view report. The page
    // side, the setting and the store live elsewhere; this file gets them as
    // callbacks in PlayerOptions.
    const lib = (key) => globalThis[Symbol.for(key)];
    const logic = () => lib('iwara-neoui/player-logic/v1');
    const streamUrls = () => lib('iwara/stream-urls/v1');
    const core = () => lib('iwara-neoui/core/v1');
    const IDLE_MS = 2000;
    const SEEK_STEP_SEC = 5;
    const SEEK_TOTAL_RESET_MS = 1000;
    const FRAME_SEC = 1 / 30;
    const STALE_CHECK_MS = 20000;
    const VOLUME_STEP = 0.05;
    const ICON_PLAY = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.5v15l12-7.5z"/></svg>';
    const ICON_PAUSE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14M16 5v14"/></svg>';
    const ICON_VOLUME = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4z"/><path d="M16.5 8.5a5 5 0 0 1 0 7"/><path d="M19 6a8.5 8.5 0 0 1 0 12"/></svg>';
    const ICON_MUTED = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4z"/><path d="M17 9l5 6M22 9l-5 6"/></svg>';
    const ICON_PIP = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><rect x="12" y="12" width="6" height="4" rx="1"/></svg>';
    const ICON_CINEMA = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 6v12M18 6v12"/></svg>';
    const ICON_FULLSCREEN = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/></svg>';
    const ICON_EXIT_FULLSCREEN = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5"/></svg>';
    const ICON_FORWARD = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
    const ICON_BACK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>';
    // Page controls that Space activates; Space on them is not a player key.
    const PAGE_CONTROLS = 'button, a[href], summary, [role="button"], [role="menuitem"]';
    // Page inputs that use Space or the arrow keys themselves; no player key
    // acts while one of them outside the player has focus.
    const PAGE_INPUTS = 'input, [role="checkbox"], [role="radio"], [role="slider"], [role="switch"]';
    const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
    const percent = (fraction) => `${clamp(fraction, 0, 1) * 100}%`;
    // Media calls that return promises reject on autoplay policy, aborted
    // loads and fullscreen refusals; none of those needs handling here.
    function quietly(result) {
        if (result && typeof result.catch === 'function')
            result.catch(() => { });
    }
    // A focusable keydown target, or null for the document itself.
    function elementTarget(target) {
        return target && typeof target.tagName === 'string' ? target : null;
    }
    function mountPlayer(options) {
        const { doc, videoId } = options;
        const { createSegmentTracker, createSeekTotal, chooseVariant, qualityNames, formatTime, formatRemaining, playerKeyAction, } = logic();
        const urls = streamUrls();
        const text = (key) => core().t(key, options.locale);
        const nowSec = () => Math.floor(Date.now() / 1000);
        const prefs = { ...options.prefs };
        const tracker = createSegmentTracker();
        const seekTotal = createSeekTotal(SEEK_STEP_SEC, SEEK_TOTAL_RESET_MS);
        const removers = [];
        const listen = (target, type, handler) => {
            const fn = handler;
            target.addEventListener(type, fn);
            removers.push(() => target.removeEventListener(type, fn));
        };
        // ---- DOM ----
        const el = (tag, className) => {
            const node = doc.createElement(tag);
            node.className = className;
            return node;
        };
        const setIcon = (button, icon, key) => {
            button.innerHTML = icon;
            button.setAttribute('aria-label', text(key));
            button.title = text(key);
        };
        const iconButton = (className, icon, key) => {
            const button = el('button', className);
            button.type = 'button';
            setIcon(button, icon, key);
            return button;
        };
        const root = el('div', 'itw-player');
        const video = el('video', 'itw-player__video');
        video.preload = 'auto';
        video.setAttribute('playsinline', '');
        const seekLabel = el('div', 'itw-player__seek-total');
        seekLabel.hidden = true;
        seekLabel.setAttribute('aria-hidden', 'true');
        const controls = el('div', 'itw-player__controls');
        const progress = el('div', 'itw-player__progress');
        progress.tabIndex = 0;
        progress.setAttribute('role', 'slider');
        progress.setAttribute('aria-label', text('player.seek'));
        progress.setAttribute('aria-valuemin', '0');
        const track = el('div', 'itw-player__track');
        const buffered = el('div', 'itw-player__buffered');
        const played = el('div', 'itw-player__played');
        const handle = el('div', 'itw-player__handle');
        const preview = el('div', 'itw-player__preview');
        preview.hidden = true;
        const previewVideo = el('video', 'itw-player__preview-video');
        previewVideo.muted = true;
        previewVideo.preload = 'metadata';
        previewVideo.hidden = true;
        const previewTime = el('span', 'itw-player__preview-time');
        preview.append(previewVideo, previewTime);
        track.append(buffered, played);
        progress.append(track, handle, preview);
        const row = el('div', 'itw-player__row');
        const playButton = iconButton('itw-player__play', ICON_PLAY, 'player.play');
        const volume = el('div', 'itw-player__volume');
        const muteButton = iconButton('itw-player__mute', ICON_VOLUME, 'player.mute');
        const volumeRange = el('input', 'itw-player__volume-range');
        volumeRange.type = 'range';
        volumeRange.min = '0';
        volumeRange.max = '1';
        volumeRange.step = String(VOLUME_STEP);
        volumeRange.setAttribute('aria-label', text('player.volume'));
        volume.append(muteButton, volumeRange);
        const timeButton = el('button', 'itw-player__time');
        timeButton.type = 'button';
        const spacer = el('div', 'itw-player__spacer');
        const qualityWrap = el('div', 'itw-player__quality-wrap');
        const qualityButton = el('button', 'itw-player__quality');
        qualityButton.type = 'button';
        qualityButton.setAttribute('aria-label', text('player.quality'));
        qualityButton.setAttribute('aria-haspopup', 'menu');
        qualityButton.setAttribute('aria-expanded', 'false');
        const qualityMenu = el('div', 'itw-player__quality-menu');
        qualityMenu.setAttribute('role', 'menu');
        qualityMenu.hidden = true;
        qualityWrap.append(qualityButton, qualityMenu);
        const pipButton = iconButton('itw-player__pip', ICON_PIP, 'player.pip');
        // Firefox has its own picture-in-picture toggle and no API for it.
        pipButton.hidden = !(doc.pictureInPictureEnabled === true && typeof video.requestPictureInPicture === 'function');
        const cinemaButton = options.showCinema ? iconButton('itw-player__cinema', ICON_CINEMA, 'player.cinema') : null;
        const fullscreenButton = iconButton('itw-player__fullscreen', ICON_FULLSCREEN, 'player.fullscreen');
        row.append(playButton, volume, timeButton, spacer, qualityWrap, pipButton);
        if (cinemaButton)
            row.append(cinemaButton);
        row.append(fullscreenButton);
        controls.append(progress, row);
        root.append(video, seekLabel, controls);
        // ---- State ----
        let variants = [];
        let current = null;
        let currentUrl = '';
        let previewUrl = '';
        // Carried over a source swap; applied on the new source's loadedmetadata.
        let pending = null;
        let lastTime = 0;
        let lastStaleCheck = Date.now();
        // Shared with keepalive: stops automatic refreshes after one that hands
        // back a URL still reading as stale (restoring playback fires play and
        // seeking, which would start the next one at once), after repeated
        // failures, and after repeated error recoveries that never play.
        const gate = urls.createRefreshGate(3, 1);
        let refreshing = false;
        // A media error that arrives while a refresh runs; it runs afterwards
        // unless that refresh already swapped the main source.
        let queuedError = false;
        let destroyed = false;
        // Applying the saved volume fires volumechange; that is not a change to save.
        let starting = true;
        let menuOpen = false;
        let overControls = false;
        let dragging = false;
        let spaceHandled = false;
        let idleTimer;
        let seekLabelTimer;
        let previewSeeking = false;
        let previewPending = null;
        const viewUrlOf = (v) => (v?.src?.view ? urls.toAbsoluteUrl(v.src.view) : '');
        const position = () => (video.currentTime > 0 ? video.currentTime : lastTime);
        // ---- Rendering ----
        function renderPlay() {
            const playing = !video.paused;
            setIcon(playButton, playing ? ICON_PAUSE : ICON_PLAY, playing ? 'player.pause' : 'player.play');
        }
        function renderVolume() {
            const silent = video.muted || video.volume === 0;
            setIcon(muteButton, silent ? ICON_MUTED : ICON_VOLUME, silent ? 'player.unmute' : 'player.mute');
            volumeRange.value = String(video.muted ? 0 : video.volume);
        }
        function renderTime() {
            const time = video.currentTime;
            const duration = video.duration;
            timeButton.textContent = prefs.showRemaining
                ? formatRemaining(time, duration)
                : `${formatTime(time)} / ${formatTime(duration)}`;
        }
        function bufferedEnd() {
            const time = video.currentTime;
            try {
                const ranges = video.buffered;
                for (let i = 0; i < ranges.length; i++) {
                    if (ranges.start(i) <= time + 0.5 && time <= ranges.end(i))
                        return ranges.end(i);
                }
            }
            catch { /* no buffered ranges yet */ }
            return 0;
        }
        function renderProgress() {
            const duration = video.duration;
            const known = Number.isFinite(duration) && duration > 0;
            const fraction = known ? video.currentTime / duration : 0;
            played.style.width = percent(fraction);
            handle.style.left = percent(fraction);
            buffered.style.width = percent(known ? bufferedEnd() / duration : 0);
            progress.setAttribute('aria-valuemax', String(known ? Math.floor(duration) : 0));
            progress.setAttribute('aria-valuenow', String(Math.floor(video.currentTime || 0)));
            progress.setAttribute('aria-valuetext', formatTime(video.currentTime));
        }
        function renderQuality() {
            const names = qualityNames(variants);
            qualityWrap.hidden = names.length === 0;
            qualityButton.textContent = current?.name ?? '';
            qualityMenu.replaceChildren(...names.map(name => {
                const choice = el('button', 'itw-player__quality-choice');
                choice.type = 'button';
                choice.setAttribute('role', 'menuitemradio');
                choice.setAttribute('aria-checked', String(name === current?.name));
                choice.textContent = name;
                choice.addEventListener('click', () => chooseQuality(name));
                return choice;
            }));
        }
        function renderFullscreen() {
            const on = doc.fullscreenElement === root;
            setIcon(fullscreenButton, on ? ICON_EXIT_FULLSCREEN : ICON_FULLSCREEN, on ? 'player.exitFullscreen' : 'player.fullscreen');
        }
        function syncCinema() {
            if (!cinemaButton)
                return;
            const on = options.cinemaActive();
            cinemaButton.classList.toggle('active', on);
            cinemaButton.setAttribute('aria-pressed', String(on));
        }
        // ---- Playback ----
        function play() { quietly(video.play()); }
        function togglePlay() { if (video.paused)
            play();
        else
            video.pause(); }
        function seekTo(time) {
            const duration = video.duration;
            video.currentTime = clamp(time, 0, Number.isFinite(duration) ? duration : Infinity);
            renderProgress();
            renderTime();
        }
        function volumeChanged() {
            renderVolume();
            if (starting || (prefs.volume === video.volume && prefs.muted === video.muted))
                return;
            prefs.volume = video.volume;
            prefs.muted = video.muted;
            options.savePrefs({ ...prefs });
        }
        function setSource(v, keepState) {
            const url = viewUrlOf(v);
            if (!url)
                return;
            // A swap still waiting for metadata keeps its state: by now the browser
            // has reset the element's position, paused state and rate.
            pending = keepState
                ? pending ?? { time: position(), paused: video.paused, rate: video.playbackRate, volume: video.volume, muted: video.muted }
                : null;
            current = v;
            currentUrl = url;
            video.src = url;
            renderQuality();
        }
        function restorePending() {
            if (!pending)
                return;
            const state = pending;
            pending = null;
            if (state.time > 0)
                video.currentTime = state.time;
            // The only place the player writes playbackRate: a new source resets it.
            if (video.playbackRate !== state.rate)
                video.playbackRate = state.rate;
            video.volume = state.volume;
            video.muted = state.muted;
            if (!state.paused)
                play();
        }
        function chooseQuality(name) {
            closeMenu();
            prefs.quality = name;
            options.savePrefs({ ...prefs });
            const next = variants.find(v => v.name === name);
            if (next && name !== current?.name)
                setSource(next, true);
        }
        // swapCurrent: load the current quality's new URL, keeping the position.
        // True when the main source was swapped.
        function applyVariants(list, swapCurrent) {
            let swapped = false;
            variants = Array.isArray(list) ? list.slice() : [];
            previewUrl = viewUrlOf(variants.find(v => v.name === 'preview'));
            if (!current) {
                const first = chooseVariant(variants, prefs.quality);
                if (first) {
                    setSource(first, false);
                    if (options.autoplay)
                        play();
                }
            }
            else if (swapCurrent) {
                const same = variants.find(v => v.name === current?.name);
                if (same && viewUrlOf(same) !== currentUrl) {
                    setSource(same, true);
                    swapped = true;
                }
            }
            renderQuality();
            return swapped;
        }
        function setVariants(list) {
            if (destroyed)
                return;
            applyVariants(list, currentNeedsRefresh());
        }
        function currentNeedsRefresh() {
            return Boolean(currentUrl) && urls.isStaleOrNear(currentUrl, nowSec()) && !urls.isFullyBuffered(video);
        }
        // auto: the main source is near expiry. error: the main source failed and
        // needs a new URL whatever its expiry says. preview: the preview failed.
        async function refresh(kind) {
            const force = kind === 'error';
            if (refreshing) {
                if (force)
                    queuedError = true;
                return;
            }
            if (!gate.allow(videoId, { force }))
                return;
            if (force && !gate.beginRecovery(videoId))
                return;
            const targetName = kind === 'preview' ? 'preview' : current?.name;
            const targetUrl = kind === 'preview' ? previewUrl : currentUrl;
            refreshing = true;
            let swapped = false;
            try {
                const list = await options.requestRefresh(videoId);
                if (destroyed)
                    return;
                const fresh = viewUrlOf((Array.isArray(list) ? list : []).find(v => v.name === targetName));
                gate.recordSuccess(videoId, { productive: urls.isProductiveRefresh(fresh, targetUrl, nowSec()) });
                // A fully buffered main video plays to the end without its URL, so a
                // preview refresh leaves it alone even when it has expired.
                swapped = applyVariants(list, force || queuedError || currentNeedsRefresh());
            }
            catch {
                gate.recordFailure(videoId);
            }
            finally {
                refreshing = false;
            }
            if (destroyed)
                return;
            if (swapped) {
                queuedError = false;
            }
            else if (queuedError) {
                queuedError = false;
                await refresh('error');
            }
        }
        function checkStale() {
            if (!pending && currentNeedsRefresh())
                void refresh('auto');
        }
        // ---- Seek total, idle and fullscreen ----
        function showSeekTotal(total) {
            const forward = total > 0;
            const value = el('span', 'itw-player__seek-total-value');
            value.textContent = `${forward ? '+' : '−'}${Math.abs(total)}`;
            const chevron = el('span', 'itw-player__seek-total-icon');
            chevron.innerHTML = forward ? ICON_FORWARD : ICON_BACK;
            seekLabel.replaceChildren(...(forward ? [value, chevron] : [chevron, value]));
            seekLabel.classList.toggle('itw-player__seek-total--forward', forward);
            seekLabel.classList.toggle('itw-player__seek-total--back', !forward);
            seekLabel.hidden = false;
            clearTimeout(seekLabelTimer);
            seekLabelTimer = setTimeout(() => { seekLabel.hidden = true; }, SEEK_TOTAL_RESET_MS);
        }
        function seekBy(direction) {
            const total = seekTotal.press(direction, Date.now());
            seekTo(video.currentTime + direction * SEEK_STEP_SEC);
            showSeekTotal(total);
        }
        function goIdle() {
            if (!overControls && !menuOpen && !video.paused)
                root.classList.add('itw-player--idle');
        }
        function showControls() {
            clearTimeout(idleTimer);
            root.classList.remove('itw-player--idle');
        }
        function wake() {
            root.classList.remove('itw-player--idle');
            clearTimeout(idleTimer);
            idleTimer = setTimeout(goIdle, IDLE_MS);
        }
        function toggleFullscreen() {
            if (doc.fullscreenElement === root) {
                if (typeof doc.exitFullscreen === 'function')
                    quietly(doc.exitFullscreen());
            }
            else if (typeof root.requestFullscreen === 'function') {
                quietly(root.requestFullscreen());
            }
        }
        function togglePip() {
            if (doc.pictureInPictureElement === video)
                quietly(doc.exitPictureInPicture());
            else if (typeof video.requestPictureInPicture === 'function')
                quietly(video.requestPictureInPicture());
        }
        function openMenu() {
            menuOpen = true;
            qualityMenu.hidden = false;
            qualityButton.setAttribute('aria-expanded', 'true');
        }
        function closeMenu() {
            menuOpen = false;
            qualityMenu.hidden = true;
            qualityButton.setAttribute('aria-expanded', 'false');
        }
        // ---- Seek bar and hover preview ----
        function fractionAt(clientX) {
            const rect = progress.getBoundingClientRect();
            return rect.width > 0 ? clamp((clientX - rect.left) / rect.width, 0, 1) : 0;
        }
        function seekPreview(time) {
            if (previewSeeking) {
                previewPending = time;
                return;
            }
            previewSeeking = true;
            try {
                previewVideo.currentTime = time;
            }
            catch {
                previewSeeking = false;
            }
        }
        function showPreview(fraction) {
            const duration = video.duration;
            if (!Number.isFinite(duration) || duration <= 0) {
                preview.hidden = true;
                return;
            }
            previewTime.textContent = formatTime(fraction * duration);
            previewVideo.hidden = !previewUrl;
            if (previewUrl) {
                if (previewVideo.getAttribute('src') !== previewUrl) {
                    previewSeeking = false;
                    previewVideo.src = previewUrl;
                }
                const previewDuration = previewVideo.duration;
                seekPreview(Number.isFinite(previewDuration) && previewDuration > 0 ? fraction * previewDuration : fraction * duration);
            }
            preview.hidden = false;
            const width = progress.getBoundingClientRect().width;
            const half = preview.offsetWidth / 2;
            preview.style.left = `${clamp(fraction * width, half, Math.max(half, width - half))}px`;
        }
        // ---- Keys ----
        function runAction(action) {
            switch (action.type) {
                case 'toggle':
                    togglePlay();
                    break;
                case 'seek':
                    seekBy(action.direction);
                    break;
                case 'volume':
                    video.volume = Math.round(clamp(video.volume + action.delta, 0, 1) * 100) / 100;
                    if (action.delta > 0 && video.muted)
                        video.muted = false;
                    volumeChanged();
                    break;
                case 'mute':
                    video.muted = !video.muted;
                    volumeChanged();
                    break;
                case 'fullscreen':
                    toggleFullscreen();
                    break;
                case 'jump':
                    if (Number.isFinite(video.duration))
                        seekTo(action.fraction * video.duration);
                    break;
                case 'frame':
                    seekTo(video.currentTime + action.direction * FRAME_SEC);
                    break;
            }
        }
        // Space presses a focused page control or quality menu item, as usual.
        function spaceBelongsToTarget(target) {
            if (!target)
                return false;
            if (qualityMenu.contains(target))
                return true;
            return !root.contains(target) && target.closest(PAGE_CONTROLS) !== null;
        }
        function onKeyDown(event) {
            if (event.key === 'Escape' && menuOpen) {
                closeMenu();
                return;
            }
            const target = elementTarget(event.target);
            if (target && !root.contains(target) && target.matches(PAGE_INPUTS))
                return;
            if (event.key === ' ' && spaceBelongsToTarget(target))
                return;
            const action = playerKeyAction({
                key: event.key,
                ctrlKey: event.ctrlKey,
                metaKey: event.metaKey,
                altKey: event.altKey,
                target,
            });
            if (!action)
                return;
            // A focused slider or button would act on the same key again.
            event.preventDefault();
            if (event.key === ' ')
                spaceHandled = true;
            runAction(action);
        }
        // Some browsers press a focused button on Space keyup.
        function onKeyUp(event) {
            if (event.key !== ' ' || !spaceHandled)
                return;
            spaceHandled = false;
            event.preventDefault();
        }
        // ---- Listeners ----
        listen(video, 'click', togglePlay);
        listen(video, 'play', () => { renderPlay(); wake(); checkStale(); });
        listen(video, 'pause', () => { renderPlay(); showControls(); });
        listen(video, 'playing', () => gate.recordPlayback(videoId));
        listen(video, 'seeking', checkStale);
        listen(video, 'loadedmetadata', () => { restorePending(); renderProgress(); renderTime(); });
        listen(video, 'durationchange', () => { renderProgress(); renderTime(); });
        listen(video, 'progress', renderProgress);
        listen(video, 'volumechange', volumeChanged);
        listen(video, 'ended', () => options.onEnded());
        listen(video, 'timeupdate', () => {
            if (destroyed)
                return;
            if (video.currentTime > 0)
                lastTime = video.currentTime;
            renderProgress();
            renderTime();
            if (tracker.mark(video.currentTime, video.duration))
                options.sendStats(videoId, tracker.flags());
            if (Date.now() - lastStaleCheck >= STALE_CHECK_MS) {
                lastStaleCheck = Date.now();
                checkStale();
            }
        });
        listen(video, 'error', () => {
            const code = video.error?.code;
            // The swap resumes from lastTime when the failed element lost its position.
            if (currentUrl && urls.shouldForceRefreshOnError(code, currentUrl, nowSec()))
                void refresh('error');
        });
        // A seek before the preview's metadata only sets where it starts, with
        // no seeked event, so the latest hover position is applied once it loads.
        function previewSeekDone() {
            previewSeeking = false;
            if (previewPending !== null) {
                const next = previewPending;
                previewPending = null;
                seekPreview(next);
            }
        }
        listen(previewVideo, 'seeked', previewSeekDone);
        listen(previewVideo, 'loadedmetadata', previewSeekDone);
        listen(previewVideo, 'error', () => {
            if (previewUrl && urls.shouldForceRefreshOnError(previewVideo.error?.code, previewUrl, nowSec()))
                void refresh('preview');
        });
        listen(playButton, 'click', togglePlay);
        listen(muteButton, 'click', () => { video.muted = !video.muted; volumeChanged(); });
        listen(volumeRange, 'input', () => {
            const value = clamp(Number(volumeRange.value), 0, 1);
            video.volume = value;
            if (value > 0)
                video.muted = false;
            volumeChanged();
        });
        listen(timeButton, 'click', () => {
            prefs.showRemaining = !prefs.showRemaining;
            options.savePrefs({ ...prefs });
            renderTime();
        });
        listen(qualityButton, 'click', () => { if (menuOpen)
            closeMenu();
        else
            openMenu(); });
        listen(pipButton, 'click', togglePip);
        if (cinemaButton)
            listen(cinemaButton, 'click', () => { options.toggleCinema(); syncCinema(); });
        listen(fullscreenButton, 'click', toggleFullscreen);
        listen(progress, 'pointerdown', event => {
            if (event.button !== 0)
                return;
            dragging = true;
            try {
                progress.setPointerCapture(event.pointerId);
            }
            catch { /* not capturable */ }
            if (Number.isFinite(video.duration))
                seekTo(fractionAt(event.clientX) * video.duration);
        });
        listen(progress, 'pointermove', event => {
            const fraction = fractionAt(event.clientX);
            showPreview(fraction);
            if (dragging && Number.isFinite(video.duration))
                seekTo(fraction * video.duration);
        });
        const endDrag = () => { dragging = false; };
        listen(progress, 'pointerup', endDrag);
        listen(progress, 'pointercancel', endDrag);
        listen(progress, 'pointerleave', () => { if (!dragging)
            preview.hidden = true; });
        listen(root, 'mousemove', wake);
        listen(controls, 'mouseenter', () => { overControls = true; });
        listen(controls, 'mouseleave', () => { overControls = false; });
        listen(doc, 'keydown', onKeyDown);
        listen(doc, 'keyup', onKeyUp);
        listen(doc, 'fullscreenchange', () => { renderFullscreen(); if (doc.fullscreenElement === root)
            wake(); });
        listen(doc, 'click', event => {
            const target = elementTarget(event.target);
            if (menuOpen && !(target && qualityWrap.contains(target)))
                closeMenu();
        });
        // ---- Start ----
        const startVolume = Number(prefs.volume);
        const startMuted = prefs.muted === true;
        video.volume = Number.isFinite(startVolume) ? clamp(startVolume, 0, 1) : 1;
        video.muted = startMuted;
        starting = false;
        applyVariants(options.variants, false);
        renderPlay();
        renderVolume();
        renderTime();
        renderProgress();
        renderFullscreen();
        syncCinema();
        options.host.append(root);
        function destroy() {
            if (destroyed)
                return;
            destroyed = true;
            for (const remove of removers.splice(0))
                remove();
            clearTimeout(idleTimer);
            clearTimeout(seekLabelTimer);
            // Listeners go first: pausing and unloading fire events.
            video.pause();
            video.removeAttribute('src');
            video.load();
            if (previewVideo.getAttribute('src') !== null) {
                previewVideo.removeAttribute('src');
                previewVideo.load();
            }
            root.remove();
        }
        return { root, video, setVariants, syncCinema, destroy };
    }
    const api = Object.freeze({ mountPlayer });
    globalThis[Symbol.for('iwara-neoui/player/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
(() => {
    'use strict';
    // ---- NeoUI player wiring ----
    // Connects the player to the page: takes the variant lists the page side
    // announces, mounts the player for the video in the address bar once its
    // host and the saved preferences exist, destroys it when the video changes
    // or the page leaves the video, and turns the player's callbacks into
    // events for the page side. Also: playlist next on end, and the debounced
    // preference writer. iwara's own player is stopped by the page side.
    const lib = (key) => globalThis[Symbol.for(key)];
    const streamUrls = () => lib('iwara/stream-urls/v1');
    const DEFAULT_PLAYER_PREFS = Object.freeze({
        quality: null, volume: 1, muted: false, showRemaining: false,
    });
    const HOST = '.page-video__player';
    const HOST_CLASS = 'itw-player-host';
    const REFRESH_TIMEOUT_MS = 30000;
    function normalizePlayerPrefs(saved) {
        const raw = (saved && typeof saved === 'object' ? saved : {});
        const volume = raw.volume;
        return {
            quality: typeof raw.quality === 'string' ? raw.quality : DEFAULT_PLAYER_PREFS.quality,
            volume: typeof volume === 'number' && volume >= 0 && volume <= 1 ? volume : DEFAULT_PLAYER_PREFS.volume,
            muted: typeof raw.muted === 'boolean' ? raw.muted : DEFAULT_PLAYER_PREFS.muted,
            showRemaining: typeof raw.showRemaining === 'boolean' ? raw.showRemaining : DEFAULT_PLAYER_PREFS.showRemaining,
        };
    }
    // Volume changes arrive on every step of a slider drag; only the last
    // value within `delayMs` is written.
    function createPrefsSaver(write, delayMs, timers) {
        let pending = null;
        let timer = null;
        const flush = () => {
            if (timer !== null)
                timers.clearTimeout(timer);
            timer = null;
            if (!pending)
                return;
            const prefs = pending;
            pending = null;
            write(prefs);
        };
        const save = (prefs) => {
            pending = prefs;
            if (timer !== null)
                timers.clearTimeout(timer);
            timer = timers.setTimeout(flush, delayMs);
        };
        return { save, flush };
    }
    // The video file id, from the `filename=<file id>_<quality>.mp4` parameter
    // of a view URL. iwara names thumbnails after the same id.
    function variantFileId(variants) {
        for (const v of variants) {
            const view = v?.src?.view;
            if (typeof view !== 'string')
                continue;
            try {
                const name = new URL(view, 'https://www.iwara.tv/').searchParams.get('filename');
                const cut = name ? name.lastIndexOf('_') : -1;
                if (name && cut > 0)
                    return name.slice(0, cut);
            }
            catch { /* not a URL */ }
        }
        return null;
    }
    // The clickable part of the playlist entry after the current video. The
    // entries carry no video id: the current one is the entry whose thumbnail
    // is named after the video's file, or else the only entry with its title
    // (a custom thumbnail has its own id). Null on the last entry or when the
    // current one cannot be told apart.
    function nextPlaylistItem(doc, fileId, title) {
        const items = [...doc.querySelectorAll('.playlist .playlist__item')];
        let index = fileId
            ? items.findIndex(item => (item.querySelector('img.thumbnail')?.getAttribute('src') ?? '').includes(`/${fileId}/`))
            : -1;
        if (index < 0 && title) {
            const titled = items.flatMap((item, i) => (item.querySelector('.playlist__item__info .text--bold')?.textContent?.trim() === title ? [i] : []));
            if (titled.length === 1)
                index = titled[0];
        }
        if (index < 0 || index + 1 >= items.length)
            return null;
        return items[index + 1].querySelector('.playlist__item__info');
    }
    function parseDetail(detail) {
        try {
            const data = JSON.parse(String(detail));
            return data && typeof data === 'object' ? data : null;
        }
        catch {
            return null;
        }
    }
    function createPlayerWiring(options) {
        const { doc, events } = options;
        const win = doc.defaultView;
        const send = (name, detail) => options.send(name, JSON.stringify(detail));
        const currentVideoId = () => streamUrls().extractVideoId(win.location.pathname);
        const pageTitle = () => doc.title.replace(/\s*\|\s*Iwara\s*$/, '').trim();
        // The latest list for the video in the address bar that the page stopped iwara's player for.
        let latest = null;
        let mounted = null;
        const waiting = new Map();
        function settle(videoId, outcome) {
            const list = waiting.get(videoId);
            if (!list?.length)
                return false;
            waiting.delete(videoId);
            for (const waiter of list) {
                win.clearTimeout(waiter.timer);
                if ('variants' in outcome)
                    waiter.resolve(outcome.variants);
                else
                    waiter.reject(outcome.error);
            }
            return true;
        }
        function requestRefresh(videoId) {
            return new Promise((resolve, reject) => {
                const timer = win.setTimeout(() => {
                    const list = waiting.get(videoId) ?? [];
                    const rest = list.filter(waiter => waiter.timer !== timer);
                    if (rest.length)
                        waiting.set(videoId, rest);
                    else
                        waiting.delete(videoId);
                    reject(new Error('variant refresh timed out'));
                }, options.refreshTimeoutMs ?? REFRESH_TIMEOUT_MS);
                const list = waiting.get(videoId) ?? [];
                list.push({ resolve, reject, timer });
                waiting.set(videoId, list);
                send(events.refresh, { videoId });
            });
        }
        function goToNextPlaylistItem() {
            if (!new URLSearchParams(win.location.search).has('playlist'))
                return;
            const variants = latest?.variants ?? [];
            nextPlaylistItem(doc, variantFileId(variants), pageTitle())?.click();
        }
        function unmount() {
            if (!mounted)
                return;
            const { player, host } = mounted;
            mounted = null;
            player.destroy();
            host.classList.remove(HOST_CLASS);
            options.flushPrefs();
        }
        function mount(videoId, variants, host, prefs) {
            const player = options.mountPlayer({
                doc,
                host,
                videoId,
                variants,
                prefs,
                savePrefs: options.savePrefs,
                autoplay: options.autoplay() || new URLSearchParams(win.location.search).has('playlist'),
                showCinema: options.showCinema(),
                cinemaActive: options.cinemaActive,
                toggleCinema: options.toggleCinema,
                onEnded: goToNextPlaylistItem,
                // iwara sends the view report after the address bar has moved on;
                // flags that arrive later would wait on the page for a later visit.
                sendStats: (id, stats) => { if (id === currentVideoId())
                    send(events.stats, { videoId: id, stats }); },
                requestRefresh,
                locale: options.locale(),
            });
            // iwara's stopped player stays in the host (hidden by CSS); the
            // playlist panel follows it, so the player goes in between.
            host.querySelector(':scope > .videoPlayer')?.after(player.root);
            host.classList.add(HOST_CLASS);
            mounted = { videoId, player, host };
        }
        function sync() {
            const videoId = currentVideoId();
            if (latest && latest.videoId !== videoId)
                latest = null;
            if (mounted && (mounted.videoId !== videoId || !mounted.player.root.isConnected))
                unmount();
            if (mounted || !latest)
                return;
            const host = doc.querySelector(HOST);
            const prefs = options.prefs();
            if (host && prefs)
                mount(latest.videoId, latest.variants, host, prefs);
        }
        doc.addEventListener(events.variants, event => {
            const data = parseDetail(event.detail);
            const videoId = data?.videoId;
            const variants = data?.variants;
            if (typeof videoId !== 'string' || !Array.isArray(variants))
                return;
            const answered = settle(videoId, { variants: variants });
            if (videoId !== currentVideoId())
                return;
            if (mounted?.videoId === videoId) {
                // iwara's player plays this video now, so ours goes and stays gone.
                if (data?.stopped === false) {
                    latest = null;
                    unmount();
                    return;
                }
                latest = { videoId, variants: variants };
                if (!answered)
                    mounted.player.setVariants(variants);
                return;
            }
            // The page decides which player plays: a list it did not stop iwara's
            // player for belongs to iwara's player, which is already playing.
            if (data?.stopped !== true)
                return;
            latest = { videoId, variants: variants };
            sync();
        });
        doc.addEventListener(events.error, event => {
            const data = parseDetail(event.detail);
            if (typeof data?.videoId !== 'string')
                return;
            settle(data.videoId, { error: new Error(typeof data.message === 'string' ? data.message : 'variant refresh failed') });
        });
        return {
            get player() { return mounted?.player ?? null; },
            sync,
            destroy: unmount,
        };
    }
    const api = Object.freeze({
        DEFAULT_PLAYER_PREFS,
        normalizePlayerPrefs,
        createPrefsSaver,
        variantFileId,
        nextPlaylistItem,
        createPlayerWiring,
    });
    globalThis[Symbol.for('iwara-neoui/player-wiring/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
if (typeof window !== 'undefined')
    (() => {
        'use strict';
        const neoWindow = window;
        if (neoWindow.__IWARA_NEOUI_ACTIVE)
            return;
        neoWindow.__IWARA_NEOUI_ACTIVE = true;
        // ---- Modules: every source file before this one has registered its api ----
        const lib = (key) => globalThis[Symbol.for(key)];
        const { DEFAULTS, normalizeSettings, resolveLocale, getBrowserLanguages, getLocale, setLocale } = lib('iwara-neoui/core/v1');
        const { EMPTY_BLACKLIST, parseBlacklist, createBlacklistFeature } = lib('iwara-neoui/blacklist/v1');
        const { PAGE_FILTER_EVENT, injectPageFilter, filterDetail, dispatchFilterDetail } = lib('iwara-neoui/page-filter/v1');
        const { PLAYER_EVENTS } = lib('iwara-neoui/player-bridge/v1');
        const { mountPlayer } = lib('iwara-neoui/player/v1');
        const { DEFAULT_PLAYER_PREFS, normalizePlayerPrefs, createPrefsSaver, createPlayerWiring, } = lib('iwara-neoui/player-wiring/v1');
        const { featureCss } = lib('iwara-neoui/styles/v1');
        const { createSettingsUi } = lib('iwara-neoui/settings-ui/v1');
        const { createVideoPage } = lib('iwara-neoui/video-page/v1');
        // ---- 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();
        };
        // The page script holds each video's variant list until it gets a player
        // mode, so the mode is sent as soon as both settings and the script exist.
        let playerMode = null;
        const sendPlayerMode = () => {
            if (filterInstalled && playerMode !== null)
                dispatchFilterDetail(document, PLAYER_EVENTS.mode, playerMode);
        };
        injectPageFilter(document, PAGE_FILTER_EVENT, () => { filterInstalled = true; sendFilterPayload(); sendPlayerMode(); });
        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'
        };
        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;
        };
        // ---- Storage (GM or localStorage fallback) ----
        const store = {
            async get(key, def) {
                try {
                    if (typeof GM !== 'undefined' && typeof GM?.getValue === 'function')
                        return await GM.getValue(key, def);
                    console.warn(`[Iwara NeoUI] Storage: GM.getValue for "${key}" unavailable; trying localStorage fallback.`);
                }
                catch (error) {
                    console.warn(`[Iwara NeoUI] Storage: GM.getValue for "${key}" failed; trying localStorage fallback.`, error);
                }
                try {
                    const value = localStorage.getItem(`itw:${key}`);
                    if (value === null) {
                        console.warn(`[Iwara NeoUI] Storage: "${key}" missing from localStorage fallback; using defaults.`);
                        return def;
                    }
                    const parsed = JSON.parse(value);
                    console.warn(`[Iwara NeoUI] Storage: "${key}" loaded from localStorage fallback.`);
                    return parsed;
                }
                catch (error) {
                    console.error(`[Iwara NeoUI] Storage: localStorage read for "${key}" failed; using defaults.`, error);
                    return def;
                }
            },
            async set(key, val) {
                try {
                    if (typeof GM !== 'undefined' && typeof GM?.setValue === 'function')
                        return await GM.setValue(key, val);
                    console.warn(`[Iwara NeoUI] Storage: GM.setValue for "${key}" unavailable; trying localStorage fallback.`);
                }
                catch (error) {
                    console.warn(`[Iwara NeoUI] Storage: GM.setValue for "${key}" failed; trying localStorage fallback.`, error);
                }
                try {
                    localStorage.setItem(`itw:${key}`, JSON.stringify(val));
                    console.warn(`[Iwara NeoUI] Storage: "${key}" saved to localStorage fallback; GM storage was not updated.`);
                }
                catch (error) {
                    console.error(`[Iwara NeoUI] Storage: localStorage write for "${key}" failed; changes were not saved.`, error);
                }
            }
        };
        let settings = { ...DEFAULTS };
        let blacklistFeature = null;
        let blacklistError = null;
        // Runtime-only cinema state (not persisted when cinemaHideNav is true)
        let cinemaActive = false;
        // Null until loaded; the player mounts only after that.
        let playerPrefs = null;
        let playerWiring = null;
        const loadSettings = async () => {
            try {
                const saved = await store.get('settings', {});
                settings = normalizeSettings(saved);
                setLocale(resolveLocale(settings.language, getBrowserLanguages()));
            }
            catch (e) {
                console.warn('[Iwara NeoUI] loadSettings failed:', e);
                settings = { ...DEFAULTS };
                setLocale(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(featureCss());
        const ctx = {
            get settings() { return settings; },
            saveSettings,
            dom,
            domCache,
            observerManager,
            TIMEOUTS,
            SELECTORS,
            CSS_CLASSES,
            waitFor,
            sleep,
            get cinemaActive() { return cinemaActive; },
            set cinemaActive(active) { cinemaActive = active; },
            get blacklistFeature() { return blacklistFeature; },
            get blacklistError() { return blacklistError; },
            get player() { return playerWiring?.player ?? null; },
            syncPlayer() {
                const mode = JSON.stringify({ enabled: settings.neoPlayer });
                if (mode !== playerMode) {
                    playerMode = mode;
                    sendPlayerMode();
                }
                playerWiring?.sync();
            },
        };
        const videoPage = createVideoPage(ctx);
        const { ensureModal, ensureHeaderButton } = createSettingsUi(ctx, videoPage);
        // ---- NeoUI player ----
        // Created at document-start: the page announces the variant list while
        // iwara loads the video, before the DOM is ready.
        const prefsSaver = createPrefsSaver(prefs => { void store.set('player', prefs); }, 300, window);
        window.addEventListener('pagehide', () => prefsSaver.flush());
        playerWiring = createPlayerWiring({
            doc: document,
            events: PLAYER_EVENTS,
            send: (name, detail) => dispatchFilterDetail(document, name, detail),
            mountPlayer,
            prefs: () => playerPrefs,
            savePrefs: prefs => {
                playerPrefs = prefs;
                prefsSaver.save(prefs);
            },
            flushPrefs: () => prefsSaver.flush(),
            showCinema: () => settings.newUI,
            autoplay: () => settings.neoPlayerAutoplay,
            cinemaActive: () => cinemaActive,
            toggleCinema: () => videoPage.toggleCinema(),
            locale: getLocale,
        });
        const { findLikedBySection, findRecsSection, applySettings, applyCinema, applyUiMode, setupVideoTabs, teardownVideoTabs, markTabs, isVideoPage, } = videoPage;
        // ---- Initialize ----
        (async () => {
            await loadSettings();
            // The page holds each variant list until it knows the mode.
            ctx.syncPlayer();
            void store.get('player', DEFAULT_PLAYER_PREFS).then(saved => {
                playerPrefs = normalizePlayerPrefs(saved);
                playerWiring?.sync();
            });
            // 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();
                playerWiring?.sync();
                // Always correct stray class if UI not present
                if (!document.querySelector(SELECTORS.TABBAR)) {
                    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)
                        markTabs();
                }
                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');
            // A later page/theme update can replace body classes without changing
            // any children. Keep the mounted tabs and saved cinema state applied.
            observerManager.create('layout', () => {
                if (!settings.newUI || !isVideoPage())
                    return;
                const classes = document.body.classList;
                if (!classes.contains(CSS_CLASSES.TABS_ACTIVE) &&
                    document.querySelector(SELECTORS.TABBAR)) {
                    classes.add(CSS_CLASSES.TABS_ACTIVE);
                }
                if (cinemaActive && !classes.contains(CSS_CLASSES.THEATER))
                    applyCinema();
            });
            observerManager.observe('layout', document.body, {
                attributes: true,
                attributeFilter: ['class'],
            });
        })();
    })();