☰

Iwara NeoUI

为 Iwara 提供增强 UI:标签布局、影院模式、可自定义区块和改进的视频页面体验。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴、Greasemonkey 油猴子 或 Violentmonkey 暴力猴,才能安装此脚本。

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴 或 Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴 或 Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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'],
            });
        })();
    })();