Enhanced UI for Iwara with tabbed layout, theater mode, customizable sections, and improved video page experience.
// ==UserScript==
// @name Iwara NeoUI
// @name:zh-CN Iwara NeoUI
// @name:zh-TW Iwara NeoUI
// @name:ja Iwara NeoUI
// @name:ko Iwara NeoUI
// @namespace codeberg.org/0xdev/neoUI-iwara
// @version 0.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'],
});
})();
})();