您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
A userscript that improves existing features of NanI and adds new ones.
// ==UserScript== // @name Enhanced NanI // @name:ja なんI+ // @description A userscript that improves existing features of NanI and adds new ones. // @description:ja なんIの機能を改善したり新たに機能を追加したりするユーザースクリプトです。 // @version 2.0.0 // @namespace 65c9f364-2ddd-44f5-bbc4-716f44f91335 // @author MaxTachibana // @license MIT // @match https://openlive2ch.pages.dev/* // @grant GM.setValue // @grant GM.getValue // @grant GM.deleteValue // @grant unsafeWindow // @run-at document-start // ==/UserScript== (async () => { "use strict"; // src/h.ts var h = (tag, attrs, ...children) => { const el = document.createElement(tag); if (attrs) { const { style, class: classes, ...extra } = attrs; Object.assign(el.style, style); if (classes) { for (const cls of classes) { el.classList.add(cls); } } for (const prop in extra) { const val = extra[prop]; if (prop.startsWith("on")) { el.addEventListener(prop.slice(2), val); continue; } el[prop] = val; } } el.append(...children); return el; }; function q(query, parent) { if (query[0] === "#") { return document.getElementById(query.slice(1)); } return parent?.getElementsByClassName(query.slice(1)); } var compileStyle = (style) => { let css2 = ""; for (const k in style) { const prop = k.replaceAll( /(.)([A-Z])/g, (_m, a, b) => `${a}-${b.toLowerCase()}`, ); css2 += `${prop}: ${style[k]}; `; } return css2; }; var compileCss = (rules) => { let css2 = ""; for (const selector in rules) { const style = rules[selector]; const rule = compileStyle(style); css2 += `${selector} { ${rule} }`; } return css2; }; var css = (rules, styleProps) => { const css2 = compileCss(rules); const s = h("style", styleProps, css2); document.head.append(s); return s; }; // src/event.ts var EventEmitter = class { #listenersMap = /* @__PURE__ */ new Map(); #initListeners(event) { const listeners = /* @__PURE__ */ new Map(); this.#listenersMap.set(event, listeners); return listeners; } on(event, listener, signal) { const listeners = this.#listenersMap.get(event) ?? this.#initListeners(event); if (listeners.has(listener)) return; let data; if (signal) { const abortHandler = () => { this.off(event, listener); }; signal.addEventListener("abort", abortHandler, { once: true, }); data = { signal, abortHandler, }; } listeners.set(listener, data); } off(event, listener) { const listeners = this.#listenersMap.get(event); if (!listeners) return; const data = listeners.get(listener); if (data) { data.signal.removeEventListener("abort", data.abortHandler); } listeners.delete(listener); if (listeners.size <= 0) { this.#listenersMap.delete(event); } } emit(event, ...args) { const listeners = this.#listenersMap.get(event); if (!listeners) return; for (const listener of listeners.keys()) { try { listener(...args); } catch {} } } }; // src/nani.ts var getThreadInfo = (threadEl) => { let params; if (threadEl) { const a = threadEl.getElementsByTagName("a")[0]; params = new URL(a.href).searchParams; } else { params = new URLSearchParams(location.search); } const thread = params.get("thread"); const board = params.get("board"); if (!thread || !board) { return; } return { thread, board, }; }; var BOARD_PARAM = /board=(\w+)/; var getCurrentBoard = () => location.search.match(BOARD_PARAM)?.[1]; var NaniEvents = { ThreadsCacheUpdate: "threadsCacheUpdate", TitlesCacheUpdate: "titlesCacheUpdate", PostsCacheUpdate: "postsCacheUpdate", }; var Nani = class { cache = { threads: void 0, posts: /* @__PURE__ */ new Map(), titles: /* @__PURE__ */ new Map(), }; events = new EventEmitter(); constructor() { this.#prepare(); } #prepare() { const origFetch = unsafeWindow.fetch; const fetch = async (...args) => { const res = await origFetch(...args); const cloned = res.clone(); (async () => { const url = new URL(res.url); if ( !url.hostname.endsWith(".supabase.co") || args[1]?.method !== "GET" ) return; if (url.pathname === "/rest/v1/threads") { const id = url.searchParams.get("id")?.replace(/^eq\./, ""); const json = await cloned.json(); if (id) { this.cache.titles.set(id, json.title); this.events.emit(NaniEvents.TitlesCacheUpdate, id, json.title); } else { this.cache.threads = json; this.events.emit(NaniEvents.ThreadsCacheUpdate); } return; } if ( url.pathname === "/rest/v1/posts" && url.searchParams.get("select") === "id,content,created_at" ) { const id = url.searchParams.get("thread_id")?.replace(/^eq\./, ""); if (!id) return; const json = await cloned.json(); this.cache.posts.set(id, json); this.events.emit(NaniEvents.PostsCacheUpdate, id, json); return; } })(); return res; }; Object.defineProperty(unsafeWindow, "fetch", { ...Object.getOwnPropertyDescriptor(unsafeWindow, "fetch"), value: fetch, }); } }; // src/features/all-image.ts var IMAGE_URL = /https:\/\/[a-z]+\.supabase\.co\/storage\/v1\/object\/public\/images\/\S+\.\S+|https:\/\/i\.imgur\.com\/\w+\.\w+/g; var allImage = () => ({ documentEnd: (ctx2) => { const button = h("button", void 0, "⛰️画像一覧"); const button2 = button.cloneNode(true); const handleClick = () => { const gallery = h("div", { style: { display: "grid", gap: "0.25rem", gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr", }, }); const images = []; const info = getThreadInfo(); if (info) { const posts = ctx2.threadCache.posts.get(info.thread); if (!posts) return; for (const post of posts) { const urls = post.content.match(IMAGE_URL); if (!urls) continue; for (const url of urls) { images.push({ board: info.board, thread: info.thread, timestamp: new Date(post.created_at).getTime(), url, }); } } } else if (ctx2.threadCache.threads) { for (const thread of ctx2.threadCache.threads) { for (const post of thread.posts) { const urls = post.content.match(IMAGE_URL); if (!urls) continue; for (const url of urls) { images.push({ board: thread.board, thread: thread.id.toString(), timestamp: new Date(post.created_at).getTime(), url, }); } } } } const labelPrefix = info ? `スレッド内の画像` : "画像"; const label = h("strong", void 0, `${labelPrefix}: ${images.length}件`); for (const [i, image] of images .sort((a, b) => b.timestamp - a.timestamp) .entries()) { const children = [ h("span", void 0, `${images.length - i}.`), h( "a", { href: image.url, target: "_blank", rel: "noopener noreferrer", }, h("img", { src: image.url, style: { display: "block", width: "100%", height: "auto", }, }), ), ]; if (!info) { children.push( h( "a", { href: `?board=${image.board}&thread=${image.thread}` }, "スレに移動", ), ); } gallery.append( h( "div", { style: { display: "flex", gap: "0.1rem", flexDirection: "column", alignItems: "flex-start", }, }, ...children, ), ); } ctx2.dialog.content.append(label); ctx2.dialog.content.append(gallery); ctx2.dialog.open(() => { label.remove(); gallery.remove(); }); }; button.addEventListener("click", handleClick); button2.addEventListener("click", handleClick); ctx2.elems.appendNav.append(button); ctx2.elems.appendNavThread.append(button2); }, }); // src/features/defined-elements.ts var definedElements = () => ({ documentEnd: (ctx2) => { const settingsPanel = q("#unhide-panel"); const threadBox = q("#thread-box"); const posts = q("#posts"); if (!settingsPanel || !threadBox || !posts) { throw new TypeError("element not found"); } const extraSettings = h("div", { style: { display: "flex", alignItems: "center", gap: "1rem", flexDirection: "column", padding: "0.5rem", }, }); settingsPanel.append(h("hr")); settingsPanel.append(extraSettings); const appendNav = h("div", { style: { display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "8px", }, }); const appendNavThread = h("div", { style: { display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "8px", }, }); threadBox.parentNode?.insertBefore(appendNav, threadBox); posts.parentNode?.insertBefore(appendNavThread, posts); ctx2.elems = { settingsPanel, threadBox, posts, appendNav, appendNavThread, extraSettings, }; }, }); // src/features/history.ts var mergeToOriginal = async () => { const originalHistoryItem = localStorage.getItem("threadHistoryMap"); if (!originalHistoryItem) return; const originalHistory = JSON.parse(originalHistoryItem); const savedHistory = JSON.parse(await GM.getValue("history", "{}")); for (const info in savedHistory) { const [board, thread] = info.split("_"); const orig = originalHistory[thread]; const saved = savedHistory[info]; if (!orig) { originalHistory[thread] = { lastViewed: saved.timestamp, title: saved.title, url: `?board=${board}&thread=${thread}`, }; continue; } if (saved.timestamp > orig.lastViewed) { orig.lastViewed = saved.timestamp; } } localStorage.setItem("threadHistoryMap", JSON.stringify(originalHistory)); await GM.deleteValue("history"); }; var history = () => ({ documentStart: async () => { await mergeToOriginal(); }, }); // src/features/ngword.ts var savedNgwords = await GM.getValue("ngwords", ""); var splitNgwords = (ngwords) => ngwords .split("\n") .map((l) => l.trim()) .filter((l) => !!l); var ngword = () => ({ documentEnd: (ctx2) => { css({ ".ngwordHidden": { display: "none", }, }); const input = h("textarea", { value: savedNgwords, placeholder: "【画像】\n過疎", onchange: async () => { await GM.setValue("ngwords", input.value); }, oninput: () => { const ngwords = splitNgwords(input.value); for (const threadEl of ctx2.elems.threadBox.children) { updateDisplay(threadEl, ngwords); } }, }); const label = h( "label", { style: { display: "inline-flex", flexDirection: "column", gap: "0.5rem", alignItems: "flex-start", }, }, h("strong", void 0, "NGワード (改行区切り): "), input, ); ctx2.elems.extraSettings.append(label); const updateDisplay = (threadElem, ngwords) => { const info = getThreadInfo(threadElem); if (!info) return; const thread = ctx2.threadCache.threads?.find( (th) => th.board === info.board && th.id.toString() === info.thread, ); if (!thread) return; if (ngwords.some((ngword2) => thread.title.includes(ngword2))) { threadElem.classList.add("ngwordHidden"); return; } const firstPost = thread.posts[0]; if (!firstPost) return; if (ngwords.some((ngword2) => firstPost.content.includes(ngword2))) { threadElem.classList.add("ngwordHidden"); return; } threadElem.classList.remove("ngwordHidden"); }; const threadObs = new MutationObserver((recs) => { const ngwords = splitNgwords(input.value); if (ngwords.length <= 0) return; for (const rec of recs) { for (const threadElem of rec.addedNodes) { if (!(threadElem instanceof HTMLElement)) continue; updateDisplay(threadElem, ngwords); } } }); threadObs.observe(ctx2.elems.threadBox, { childList: true, }); const postObs = new MutationObserver((recs) => { const ngwords = splitNgwords(input.value); if (ngwords.length <= 0) return; for (const rec of recs) { for (const postEl of rec.addedNodes) { if (!(postEl instanceof HTMLElement)) continue; const nameEl = postEl.querySelector('[class^="name-"]'); if (!nameEl) continue; if (!(nameEl instanceof HTMLElement)) continue; postEl.classList.remove("ngwordHidden"); if (ngwords.some((ngword2) => nameEl.innerText.includes(ngword2))) { postEl.classList.add("ngwordHidden"); continue; } const bodyEl = postEl.getElementsByClassName("post-body")[0]; if (!bodyEl) continue; if (!(bodyEl instanceof HTMLElement)) continue; if (ngwords.some((ngword2) => bodyEl.innerText.includes(ngword2))) { postEl.classList.add("ngwordHidden"); } } } }); postObs.observe(ctx2.elems.posts, { childList: true, }); }, }); // src/features/period.ts var PERIOD_1H = 1e3 * 60 * 60; var PERIOD_1D = 1e3 * 60 * 60 * 24; var savedPeriod = await GM.getValue("period", "all"); var period = () => ({ documentEnd: (ctx2) => { css({ ".periodHidden": { display: "none", }, }); const select = h( "select", { value: savedPeriod, onchange: async (ev) => { if (!(ev.target instanceof HTMLSelectElement)) return; const period2 = ev.target.value; await GM.setValue("period", period2); const now = /* @__PURE__ */ new Date(); for (const threadEl of ctx2.elems.threadBox.children) { updateDisplay(threadEl, now); } }, }, h("option", { value: "all" }, "全て"), h("option", { value: PERIOD_1H.toString() }, "1時間"), h("option", { value: PERIOD_1D.toString() }, "1日"), ); const label = h( "label", { style: { display: "inline-flex", alignItems: "center", gap: "0.5rem", marginBottom: "8px", }, }, h("span", void 0, "期間: "), select, ); select.value = savedPeriod; ctx2.elems.threadBox.parentNode?.insertBefore( label, ctx2.elems.threadBox, ); const updateDisplay = (threadElem, now) => { if (select.value === "all") { threadElem.classList.remove("periodHidden"); return; } const info = getThreadInfo(threadElem); if (!info) return; const thread = ctx2.threadCache.threads?.find( (th) => th.board === info.board && th.id.toString() === info.thread, ); if (!thread) return; const lastPost = thread.posts.at(-1); if (!lastPost) return; const lastPostDate = new Date(lastPost.created_at); const period2 = Number.parseInt(select.value, 10); const diff = now.getTime() - lastPostDate.getTime(); if (diff >= period2) { threadElem.classList.add("periodHidden"); } else { threadElem.classList.remove("periodHidden"); } }; const obs = new MutationObserver((records) => { const now = /* @__PURE__ */ new Date(); for (const record of records) { for (const threadElem of record.addedNodes) { if (!(threadElem instanceof HTMLElement)) continue; updateDisplay(threadElem, now); } } }); obs.observe(ctx2.elems.threadBox, { childList: true, }); }, }); // src/features/thread-cache.ts var threadCache = () => ({ documentStart: (ctx2) => { ctx2.threadCache = { threads: void 0, posts: /* @__PURE__ */ new Map(), titles: /* @__PURE__ */ new Map(), }; const origFetch = unsafeWindow.fetch; const interceptFetch = async (...args) => { const res = await origFetch(...args); const url = new URL(res.url); try { if ( url.hostname.endsWith(".supabase.co") && args[1]?.method === "GET" ) { if (url.pathname === "/rest/v1/threads") { const id = url.searchParams.get("id")?.slice(3); if (id) { const json = await res.clone().json(); ctx2.threadCache.titles.set(id, json.title); } else { ctx2.threadCache.threads = await res.clone().json(); } } else if ( url.pathname === "/rest/v1/posts" && url.searchParams.get("select") === "id,content,created_at" ) { const threadId = url.searchParams .get("thread_id") ?.replace(/^eq\./, ""); if (threadId) { ctx2.threadCache.posts.set(threadId, await res.clone().json()); } } } } catch (err) { console.warn(err); } return res; }; Object.defineProperty(unsafeWindow, "fetch", { ...Object.getOwnPropertyDescriptor(unsafeWindow, "fetch"), value: interceptFetch, }); }, }); // src/features/audio-context.ts var audioContext = () => ({ documentEnd: (ctx2) => { document.body.addEventListener( "click", () => { const audioCtx = new AudioContext(); const src = audioCtx.createBufferSource(); src.start(); src.stop(); ctx2.audioContext = audioCtx; }, { once: true, }, ); }, }); // src/features/dialog.ts var dialog = () => ({ documentEnd: (ctx2) => { const content = h("div", { style: { flex: "1", overflow: "auto", }, }); const dialog2 = h( "div", { style: { display: "none", gap: "0.5rem", flexDirection: "column", zIndex: "99999", border: "1px solid #000000", backgroundColor: "inherit", width: "80%", height: "80%", position: "fixed", top: "calc(50svh - 40%)", left: "calc(50svw - 40%)", boxSizing: "border-box", borderRadius: "5px", padding: "0.5rem", }, }, h( "button", { onclick: () => { ctx2.dialog.close(); }, }, "閉じる", ), content, ); let controller; const destructors = /* @__PURE__ */ new Set(); ctx2.dialog = { isOpen: () => dialog2.style.display !== "none", open: (destructor) => { dialog2.style.display = "flex"; requestAnimationFrame(() => { controller = new AbortController(); document.body.addEventListener( "click", (ev) => { if (!ev.composedPath().includes(dialog2)) { ctx2.dialog.close(); } }, { signal: controller.signal, }, ); }); if (destructor) { destructors.add(destructor); } }, close: () => { dialog2.style.display = "none"; controller?.abort(); for (const f of destructors) { f(); destructors.delete(f); } }, content, }; document.body.append(dialog2); }, }); // src/features/display-poster.ts var displayPoster = () => ({ documentStart: async () => { await GM.deleteValue("displayPoster"); }, }); // src/features/fix-ui.ts var holder = () => { const labels = []; return { checkbox: (label_, inputProps) => { const input = h("input", { type: "checkbox", ...inputProps, }); const label = h( "label", { style: { display: "flex", gap: "0.5rem", alignItems: "center", }, }, h("span", void 0, `${label_}: `), input, ); labels.push(label); return input; }, [Symbol.iterator]() { return labels.values(); }, }; }; var toggleStyle = (rules) => { const id = `a${Math.random().toString(36).slice(2)}`; let s; const obj = { enable: () => { if (s?.parentNode) return; if (s) { document.head.append(s); return; } s = css(rules, { id, }); }, disable: () => { s?.remove(); }, update: (enabled) => { if (enabled) { obj.enable(); } else { obj.disable(); } }, }; return obj; }; var fixUi = () => ({ documentEnd: async (ctx2) => { const checkboxes = holder(); const savedBgcolor = await GM.getValue("bgcolor", "#ffffff"); const bgcolorInput = h("input", { type: "color", value: savedBgcolor, oninput: async (ev) => { if (!ev.target) return; if (!(ev.target instanceof HTMLInputElement)) return; const bgcolor = ev.target.value; document.body.style.backgroundColor = bgcolor; await GM.setValue("bgcolor", bgcolor); }, }); const bgcolorInputLabel = h( "label", { style: { display: "inline-flex", gap: "0.5rem", alignItems: "center", }, }, h("span", void 0, "背景色: "), bgcolorInput, ); document.body.style.backgroundColor = savedBgcolor; const fixedPanelHeightStyle = toggleStyle({ ".nav-panel": { height: "50%", overflow: "auto", }, ".panel-header": { top: "0", left: "0", position: "sticky", backgroundColor: "inherit", }, }); const fixedPanelHeight = checkboxes.checkbox( "ナビパネルのサイズを固定する", { checked: await GM.getValue("fixedPanelHeight", true), onchange: async () => { await GM.setValue("fixedPanelHeight", fixedPanelHeight.checked); fixedPanelHeightStyle.update(fixedPanelHeight.checked); }, }, ); fixedPanelHeightStyle.update(fixedPanelHeight.checked); const navPanelSizingStyle = toggleStyle({ ".nav-panel": { boxSizing: "border-box", }, }); const navPanelSizing = checkboxes.checkbox( "設定パネルのはみ出しを抑える", { checked: await GM.getValue("navPanelSizing", true), onchange: async () => { await GM.setValue("navPanelSizing", navPanelSizing.checked); navPanelSizingStyle.update(navPanelSizing.checked); }, }, ); navPanelSizingStyle.update(navPanelSizing.checked); const paddingNavStyle = toggleStyle({ "#bottom-nav": { padding: "0.5rem", }, ".nav-panel": { bottom: "calc(60px + 1rem)", }, }); const paddingNav = checkboxes.checkbox("下部ナビに余白を付ける", { checked: await GM.getValue("paddingNav", true), onchange: async () => { await GM.setValue("paddingNav", paddingNav.checked); paddingNavStyle.update(paddingNav.checked); }, }); paddingNavStyle.update(paddingNav.checked); const hideOnlineCountStyle = toggleStyle({ "#viewers-count": { display: "none", }, }); const hideOnlineCount = checkboxes.checkbox("閲覧者数を非表示にする", { checked: await GM.getValue("hideOnlineCount", false), onchange: async () => { await GM.setValue("hideOnlineCount", hideOnlineCount.checked); hideOnlineCountStyle.update(hideOnlineCount.checked); }, }); hideOnlineCountStyle.update(hideOnlineCount.checked); const uiFixSettings = h( "div", { style: { display: "flex", flexDirection: "column", gap: "0.5rem", }, }, h("strong", void 0, "UIの調整"), bgcolorInputLabel, ...checkboxes, ); ctx2.elems.extraSettings.append(uiFixSettings); }, }); // src/iter-tools.ts function pipe(value, ...ops) { let ret = value; for (const op of ops) { ret = op(ret); } return ret; } var flatMap = (func) => function* (source) { for (const value of source) { yield* func(value); } }; function filter(predicate) { return function* (source) { for (const value of source) { if (predicate(value)) { yield value; } } }; } var map = (func) => function* (source) { for (const value of source) { yield func(value); } }; var toArray = (source) => [...source]; // src/features/headline.ts var dateCache = /* @__PURE__ */ new Map(); var getDate = (time) => { let date = dateCache.get(time); if (!date) { date = new Date(time); dateCache.set(time, date); } return date; }; var headline = () => ({ documentEnd: async (ctx2) => { const savedHeadline = await GM.getValue("headline", true); let destructor; const enable = () => { const controller = new AbortController(); const headline2 = h("div", { style: { fontSize: "0.9em", border: "1px solid #000000", marginBottom: "8px", height: "150px", overflow: "auto", padding: "0.5rem", flexDirection: "column", display: "flex", gap: "0.5rem", }, }); ctx2.elems.threadBox.parentNode?.insertBefore( headline2, ctx2.elems.threadBox, ); ctx2.nani.events.on( NaniEvents.ThreadsCacheUpdate, () => { if (!ctx2.nani.cache.threads) return; const currentBoard = getCurrentBoard(); if (!currentBoard) return; const posts = pipe( ctx2.nani.cache.threads, filter((th) => th.board === currentBoard), flatMap((th) => pipe( th.posts, map((p) => ({ thread: th, post: p, })), ), ), toArray, ); const latestPosts = posts .sort( (a, b) => getDate(b.post.created_at).getTime() - getDate(a.post.created_at).getTime(), ) .slice(0, 10); headline2.replaceChildren(); for (const { thread, post } of latestPosts) { const res = h( "div", { style: { display: "flex", flexDirection: "column", gap: "0.3rem", }, }, h( "a", { href: `?board=${thread.board}&thread=${thread.id}`, }, `${thread.title} (${thread.posts.length})`, ), h( "span", { style: { textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap", }, }, post.content, ), ); headline2.append(res); } }, controller.signal, ); destructor = () => { headline2.remove(); controller.abort(); }; }; const disable = () => { destructor?.(); destructor = void 0; }; const update = () => { if (headlineCheckbox.checked) { enable(); } else { disable(); } }; const headlineCheckbox = h("input", { type: "checkbox", checked: savedHeadline, onchange: async () => { await GM.setValue("headline", headlineCheckbox.checked); update(); }, }); const headlineCheckboxLabel = h( "label", { style: { display: "inline-flex", gap: "0.5rem", alignItems: "center", }, }, h("span", void 0, "ヘッドラインを有効化: "), headlineCheckbox, ); const headlineSettings = h( "div", { style: { display: "flex", flexDirection: "column", gap: "0.5rem", }, }, h("strong", void 0, "ヘッドライン"), headlineCheckboxLabel, ); ctx2.elems.extraSettings.append(headlineSettings); update(); }, }); // src/features/nani.ts var nani = () => ({ documentStart: (ctx2) => { ctx2.nani = new Nani(); }, }); // src/audio.ts var playCoin = (ctx2) => { const osc = ctx2.createOscillator(); const gain = ctx2.createGain(); osc.type = "square"; osc.frequency.setValueAtTime(987.766, ctx2.currentTime); osc.frequency.setValueAtTime(1318.51, ctx2.currentTime + 0.09); gain.gain.value = 0.05; gain.gain.linearRampToValueAtTime(0, ctx2.currentTime + 0.7); osc.connect(gain).connect(ctx2.destination); osc.start(); osc.stop(ctx2.currentTime + 0.5); }; var playPico = (ctx2) => { const osc = ctx2.createOscillator(); const gain = ctx2.createGain(); osc.type = "square"; osc.frequency.setValueAtTime(1200, ctx2.currentTime); gain.gain.setValueAtTime(0.2, ctx2.currentTime); gain.gain.exponentialRampToValueAtTime(1e-3, ctx2.currentTime + 0.15); osc.connect(gain).connect(ctx2.destination); osc.start(); osc.stop(ctx2.currentTime + 0.2); }; // src/features/notification.ts var SOUND = { coin: playCoin, pico: playPico, }; var notification = () => ({ documentEnd: async (ctx2) => { const play = () => { if (!ctx2.audioContext) return; SOUND[soundSelect.value](ctx2.audioContext); }; const update = () => { if (postNotificationEnabled.checked) { obs.observe(ctx2.elems.posts, { childList: true, }); } else { obs.disconnect(); } }; const postNotificationEnabled = h("input", { type: "checkbox", checked: await GM.getValue("postNotificationEnabled", false), onchange: async () => { await GM.setValue( "postNotificationEnabled", postNotificationEnabled.checked, ); update(); }, }); const postNotificationEnabledLabel = h( "label", { style: { display: "inline-flex", alignItems: "center", gap: "0.5rem", }, }, h("span", void 0, "新着レスを通知: "), postNotificationEnabled, ); const soundSelect = h( "select", { onchange: async () => { await GM.setValue("notificationSound", soundSelect.value); }, }, h("option", { value: "coin" }, "コイン"), h("option", { value: "pico" }, "ピコ"), ); const soundSelectLabel = h( "div", { style: { display: "inline-flex", gap: "0.5rem", alignItems: "center", }, }, h( "label", { style: { display: "inline-flex", alignItems: "center", gap: "0.5rem", }, }, h("span", void 0, "通知音: "), soundSelect, ), h( "button", { class: ["smallbtn"], onclick: () => { play(); }, }, "再生", ), ); soundSelect.value = await GM.getValue("notificationSound", "coin"); const notificationSettings = h( "div", { style: { display: "flex", flexDirection: "column", gap: "0.5rem", }, }, h("strong", void 0, "通知"), postNotificationEnabledLabel, soundSelectLabel, ); ctx2.elems.extraSettings.append(notificationSettings); let prevPostsId; let prevPosts; const obs = new MutationObserver(() => { if (!postNotificationEnabled.checked) return; const info = getThreadInfo(); if (!info) return; const posts = ctx2.threadCache.posts.get(info.thread); if (!posts) return; if (prevPosts && prevPostsId !== info.thread.toString()) { prevPostsId = void 0; prevPosts = void 0; } if (!prevPosts) { prevPostsId = info.thread.toString(); prevPosts = posts; return; } if (posts.length <= prevPosts.length) return; prevPosts = posts; play(); }); update(); }, }); // src/index.ts var features = [ nani(), audioContext(), definedElements(), threadCache(), dialog(), displayPoster(), period(), allImage(), ngword(), history(), fixUi(), notification(), headline(), ]; var ctx = {}; for (const f of features) { await f.documentStart?.(ctx); } document.addEventListener("DOMContentLoaded", async () => { for (const f of features) { await f.documentEnd?.(ctx); } }); })();