1088 lines
32 KiB
JavaScript
1088 lines
32 KiB
JavaScript
// Current settings snapshot.
|
|
let whitelist = [];
|
|
let whitelistHandles = [];
|
|
let debugEnabled = false;
|
|
// In-memory index of known member-only videos.
|
|
const memberOnlyIndex = new Map(); // id -> { channelKey, hidden }
|
|
const sharedIndex = new Map(); // stableId -> meta for cross-tab HUD (hidden only)
|
|
const sharedSeenIndex = new Map(); // stableId -> meta for all seen videos
|
|
const sharedMemberIndex = new Map(); // stableId -> meta for member-only videos
|
|
// Data-* attribute prefix for DOM tags.
|
|
const DATA_PREFIX = "chipperfluff-nobs"; // nobs = "no-bs" (member-only videos)
|
|
const HUD_DOT_ID = `${DATA_PREFIX}-hud-dot`;
|
|
const HUD_PANEL_ID = `${DATA_PREFIX}-hud-panel`;
|
|
let generatedIdCounter = 0;
|
|
let hudDirty = false;
|
|
let lastHudSignature = "";
|
|
const STORAGE_KEY = "memberOnlyHidden";
|
|
const STORAGE_KEY_SEEN = "memberOnlySeen";
|
|
const STORAGE_KEY_MEMBER = "memberOnlyDetected";
|
|
const DEFAULT_MAX_ARCHIVE = 500;
|
|
const MAX_SNAPSHOT_CHARS = 20000;
|
|
let persistHiddenTimer = null;
|
|
let persistSeenTimer = null;
|
|
let persistMemberTimer = null;
|
|
let contextInvalidated = false;
|
|
let hudFlashTimer = null;
|
|
|
|
function safeSnapshot(video) {
|
|
if (typeof buildSnapshot !== "function") return "";
|
|
return buildSnapshot(video);
|
|
}
|
|
|
|
function stripHiddenMarkers(html) {
|
|
const pattern = new RegExp(
|
|
`\\s*data-${DATA_PREFIX}-[^=\\s>]+(?:=(\"[^\"]*\"|'[^']*'|[^\\s>]*))?`,
|
|
"gi"
|
|
);
|
|
return html
|
|
.replace(pattern, "")
|
|
.replace(new RegExp(`data-${DATA_PREFIX}-[^\\s>]+`, "gi"), "");
|
|
}
|
|
|
|
function sanitizeSnapshotToFragment(html) {
|
|
const template = document.createElement("template");
|
|
const cleaned = stripHiddenMarkers(html);
|
|
template.innerHTML = cleaned;
|
|
return template.content;
|
|
}
|
|
|
|
function forceStripDataMarkers(root) {
|
|
const all = root.querySelectorAll("*");
|
|
all.forEach(el => {
|
|
Array.from(el.attributes).forEach(attr => {
|
|
if (attr.name.includes(`data-${DATA_PREFIX}-`)) {
|
|
el.removeAttribute(attr.name);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------- CSS injection ---------------- */
|
|
|
|
// Inject a single style tag for hiding matched videos.
|
|
function injectStyleOnce() {
|
|
if (document.getElementById("member-filter-style")) return;
|
|
|
|
const style = document.createElement("style");
|
|
style.id = "member-filter-style";
|
|
style.textContent = `
|
|
[data-${DATA_PREFIX}-hidden="true"] {
|
|
display: none !important;
|
|
}
|
|
#${HUD_DOT_ID} {
|
|
position: fixed;
|
|
right: 18px;
|
|
bottom: 18px;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 999px;
|
|
background: #9b9b9b;
|
|
border: 2px solid #6f6f6f;
|
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.12);
|
|
z-index: 2147483647;
|
|
cursor: pointer;
|
|
}
|
|
#${HUD_DOT_ID}[data-${DATA_PREFIX}-active="true"] {
|
|
background: #ff9c1a;
|
|
border-color: #b04a00;
|
|
box-shadow: 0 0 0 3px rgba(255, 140, 0, 0.4);
|
|
animation: ${DATA_PREFIX}-pulse 1.8s ease-in-out infinite;
|
|
}
|
|
#${HUD_DOT_ID}[data-${DATA_PREFIX}-flash="true"] {
|
|
background: #ffd84a;
|
|
border-color: #e0a600;
|
|
transform: scale(1.8);
|
|
box-shadow: 0 0 0 10px rgba(255, 216, 74, 0.4);
|
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
}
|
|
@keyframes ${DATA_PREFIX}-pulse {
|
|
0% {
|
|
transform: scale(0.9);
|
|
box-shadow: 0 0 0 0 rgba(255, 140, 0, 0.6);
|
|
}
|
|
70% {
|
|
transform: scale(1.1);
|
|
box-shadow: 0 0 0 6px rgba(255, 140, 0, 0);
|
|
}
|
|
100% {
|
|
transform: scale(0.9);
|
|
box-shadow: 0 0 0 0 rgba(255, 140, 0, 0);
|
|
}
|
|
}
|
|
#${HUD_PANEL_ID} {
|
|
position: fixed;
|
|
left: 50%;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 520px;
|
|
height: 520px;
|
|
overflow: auto;
|
|
padding: 0;
|
|
border-radius: 12px;
|
|
border: 1px solid #9b3b0c;
|
|
background: radial-gradient(circle at 20% 10%, #2b2b2b, #151515 60%);
|
|
color: #f5efe6;
|
|
font-size: 12px;
|
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
|
|
z-index: 2147483647;
|
|
}
|
|
#${HUD_PANEL_ID}[data-${DATA_PREFIX}-hidden="true"] {
|
|
display: none;
|
|
}
|
|
.${DATA_PREFIX}-hud-title {
|
|
font-weight: 700;
|
|
margin: 0;
|
|
padding: 12px 14px;
|
|
text-transform: uppercase;
|
|
font-size: 12px;
|
|
letter-spacing: 0.6px;
|
|
background: linear-gradient(90deg, #e35b14, #c9490e);
|
|
border-bottom: 2px solid #b2410e;
|
|
color: #fff8ee;
|
|
}
|
|
.${DATA_PREFIX}-hud-body {
|
|
padding: 10px 12px 14px;
|
|
}
|
|
.${DATA_PREFIX}-channel {
|
|
margin: 8px 0;
|
|
padding: 6px 8px;
|
|
border-radius: 10px;
|
|
background: rgba(15, 15, 15, 0.55);
|
|
border: 1px solid #2a2a2a;
|
|
}
|
|
.${DATA_PREFIX}-channel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
color: #ffd8b0;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
font-size: 11px;
|
|
}
|
|
.${DATA_PREFIX}-channel-header::before {
|
|
content: "▸";
|
|
margin-right: 6px;
|
|
color: #8b5a2a;
|
|
font-weight: 700;
|
|
}
|
|
details[open] > .${DATA_PREFIX}-channel-header::before {
|
|
content: "▾";
|
|
}
|
|
.${DATA_PREFIX}-video-list {
|
|
margin: 6px 0 0;
|
|
padding-left: 18px;
|
|
border-left: 2px solid #3a2c22;
|
|
}
|
|
.${DATA_PREFIX}-hud-item {
|
|
display: grid;
|
|
grid-template-columns: 64px 1fr;
|
|
gap: 8px;
|
|
align-items: center;
|
|
margin: 6px 0;
|
|
padding: 6px;
|
|
border-radius: 8px;
|
|
background: #232323;
|
|
border: 1px solid #3a2c22;
|
|
}
|
|
.${DATA_PREFIX}-hud-thumb {
|
|
width: 64px;
|
|
height: 36px;
|
|
object-fit: cover;
|
|
border-radius: 4px;
|
|
background: #2a2a2a;
|
|
}
|
|
.${DATA_PREFIX}-hud-link {
|
|
color: #ffb26a;
|
|
text-decoration: none;
|
|
font-weight: 700;
|
|
}
|
|
.${DATA_PREFIX}-hud-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-bottom: 8px;
|
|
}
|
|
.${DATA_PREFIX}-hud-clear {
|
|
padding: 6px 10px;
|
|
border: 1px solid #4a3a2a;
|
|
border-radius: 8px;
|
|
background: transparent;
|
|
color: #f5efe6;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
}
|
|
.${DATA_PREFIX}-hud-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
.${DATA_PREFIX}-snapshot {
|
|
border: 1px solid #3a2c22;
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
background: #232323;
|
|
color: #f5efe6;
|
|
font-size: 11px;
|
|
line-height: 1.35;
|
|
}
|
|
.${DATA_PREFIX}-snapshot a {
|
|
color: #ffb26a;
|
|
text-decoration: none;
|
|
}
|
|
.${DATA_PREFIX}-snapshot img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
border-radius: 6px;
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
injectStyleOnce();
|
|
|
|
/* ---------------- load whitelist ---------------- */
|
|
|
|
// Debug helpers (no output unless debug is enabled).
|
|
function debugLog(...args) {
|
|
if (!debugEnabled) return;
|
|
console.log("[MemberFilter]", ...args);
|
|
}
|
|
|
|
function debugGroup(...args) {
|
|
if (!debugEnabled) return;
|
|
console.group("[MemberFilter]", ...args);
|
|
}
|
|
|
|
function debugGroupEnd() {
|
|
if (!debugEnabled) return;
|
|
console.groupEnd();
|
|
}
|
|
|
|
// Normalize strings so matching is consistent.
|
|
function normalizeKey(value) {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/\u00a0/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.replace(/\u2022/g, "")
|
|
.trim();
|
|
}
|
|
|
|
// Support legacy strings and new { name, handle } entries.
|
|
function normalizeWhitelist(list) {
|
|
const names = [];
|
|
const handles = [];
|
|
(list || []).forEach(item => {
|
|
if (typeof item === "string") {
|
|
if (item) names.push(normalizeKey(item));
|
|
return;
|
|
}
|
|
const name = normalizeKey(item.name || "");
|
|
const handle = normalizeKey(item.handle || "");
|
|
if (name) names.push(name);
|
|
if (handle) handles.push(handle);
|
|
});
|
|
return { names, handles };
|
|
}
|
|
|
|
// Read settings and re-apply visibility immediately.
|
|
function loadSettings() {
|
|
if (!chrome?.storage?.local) return;
|
|
chrome.storage.local.get({ whitelist: [], debug: false }, data => {
|
|
const { names, handles } = normalizeWhitelist(data.whitelist);
|
|
whitelist = names;
|
|
whitelistHandles = handles;
|
|
debugEnabled = Boolean(data.debug);
|
|
debugLog("settings loaded:", { whitelist, whitelistHandles, debugEnabled });
|
|
if (isWhitelistedChannelPage()) {
|
|
revealAll();
|
|
return;
|
|
}
|
|
updateKnownVisibility();
|
|
process();
|
|
updateHudDot();
|
|
});
|
|
}
|
|
|
|
loadSettings();
|
|
loadStoredIndex();
|
|
|
|
// Reload settings or shared HUD data if storage changes.
|
|
chrome.storage.onChanged.addListener(changes => {
|
|
if (changes[STORAGE_KEY]) {
|
|
const items = changes[STORAGE_KEY].newValue || [];
|
|
sharedIndex.clear();
|
|
items.forEach(item => sharedIndex.set(item.id, item));
|
|
updateHudDot();
|
|
}
|
|
if (changes[STORAGE_KEY_SEEN]) {
|
|
const items = changes[STORAGE_KEY_SEEN].newValue || [];
|
|
sharedSeenIndex.clear();
|
|
items.forEach(item => sharedSeenIndex.set(item.id, item));
|
|
updateHudDot();
|
|
}
|
|
if (changes[STORAGE_KEY_MEMBER]) {
|
|
const items = changes[STORAGE_KEY_MEMBER].newValue || [];
|
|
sharedMemberIndex.clear();
|
|
items.forEach(item => sharedMemberIndex.set(item.id, item));
|
|
updateHudDot();
|
|
}
|
|
if (changes.whitelist || changes.debug) {
|
|
loadSettings();
|
|
}
|
|
});
|
|
|
|
/* ---------------- detection logic ---------------- */
|
|
|
|
// Detect member-only videos and tag them for future updates.
|
|
function process(root = document) {
|
|
if (isWhitelistedChannelPage()) return;
|
|
scanAllVideos(root);
|
|
const badges = root.querySelectorAll("badge-shape");
|
|
|
|
badges.forEach(badge => {
|
|
if (!badge.textContent?.includes("Nur für Kanalmitglieder")) return;
|
|
|
|
const video = badge.closest(
|
|
"ytd-rich-item-renderer, ytd-video-renderer, ytd-grid-video-renderer"
|
|
);
|
|
|
|
if (!video) return;
|
|
|
|
const videoMeta = getVideoMeta(video);
|
|
const title = videoMeta.title;
|
|
const url = videoMeta.url;
|
|
const thumb = videoMeta.thumb;
|
|
const channelInfo = getChannelInfo(video);
|
|
const channel = channelInfo.label || "(no channel)";
|
|
const channelUrl = channelInfo.url || "";
|
|
const channelKey = channelInfo.key;
|
|
const id = videoMeta.videoId || getOrCreateVideoId(video, url);
|
|
const stableId = videoMeta.videoId || getStableId(video, url, id);
|
|
|
|
video.setAttribute(`data-${DATA_PREFIX}-member-only`, "true");
|
|
if (channelKey) video.setAttribute(`data-${DATA_PREFIX}-channel`, channelKey);
|
|
if (id) video.setAttribute(`data-${DATA_PREFIX}-id`, id);
|
|
|
|
if (id) {
|
|
const existing = memberOnlyIndex.get(id);
|
|
if (!existing) {
|
|
memberOnlyIndex.set(id, {
|
|
channelKey,
|
|
channelLabel: channel,
|
|
channelUrl,
|
|
stableId,
|
|
hidden: false,
|
|
title,
|
|
url,
|
|
thumb
|
|
});
|
|
hudDirty = true;
|
|
updateMemberIndex({
|
|
stableId,
|
|
channelKey,
|
|
channelLabel: channel,
|
|
channelUrl
|
|
});
|
|
updateSeenIndex({
|
|
stableId,
|
|
channelKey,
|
|
channelLabel: channel,
|
|
channelUrl
|
|
});
|
|
} else {
|
|
// Fill missing metadata as it becomes available.
|
|
if (!existing.title && title) existing.title = title;
|
|
if (!existing.url && url) existing.url = url;
|
|
if (!existing.thumb && thumb) existing.thumb = thumb;
|
|
if (!existing.channelLabel && channel) existing.channelLabel = channel;
|
|
if (!existing.channelUrl && channelUrl) existing.channelUrl = channelUrl;
|
|
if (existing.hidden) {
|
|
if (!existing.snapshot) existing.snapshot = safeSnapshot(video);
|
|
updateSharedIndex(existing, true);
|
|
}
|
|
updateMemberIndex({
|
|
stableId: existing.stableId,
|
|
channelKey: existing.channelKey,
|
|
channelLabel: existing.channelLabel,
|
|
channelUrl: existing.channelUrl
|
|
});
|
|
updateSeenIndex({
|
|
stableId: existing.stableId,
|
|
channelKey: existing.channelKey,
|
|
channelLabel: existing.channelLabel,
|
|
channelUrl: existing.channelUrl
|
|
});
|
|
}
|
|
}
|
|
|
|
const whitelisted = whitelist.includes(channelKey);
|
|
|
|
debugGroup();
|
|
debugLog("Title :", title);
|
|
debugLog("Channel:", channel);
|
|
debugLog("URL :", url);
|
|
debugLog("ID :", id || "(no id)");
|
|
debugLog("Whitelisted:", whitelisted);
|
|
debugGroupEnd();
|
|
|
|
if (!whitelisted) {
|
|
if (id && memberOnlyIndex.has(id)) {
|
|
const meta = memberOnlyIndex.get(id);
|
|
if (!meta.hidden) flashHudDot();
|
|
if (!meta.snapshot) meta.snapshot = safeSnapshot(video);
|
|
if (!meta.hidden) hudDirty = true;
|
|
meta.hidden = true;
|
|
updateSharedIndex(meta, true);
|
|
}
|
|
video.setAttribute(`data-${DATA_PREFIX}-hidden`, "true");
|
|
} else {
|
|
video.removeAttribute(`data-${DATA_PREFIX}-hidden`);
|
|
if (id && memberOnlyIndex.has(id)) {
|
|
const meta = memberOnlyIndex.get(id);
|
|
if (meta.hidden) hudDirty = true;
|
|
meta.hidden = false;
|
|
updateSharedIndex(meta, false);
|
|
}
|
|
}
|
|
if (videoMeta.needsRetry) scheduleMetaRetry(video);
|
|
});
|
|
updateHudDot();
|
|
}
|
|
|
|
function scanAllVideos(root) {
|
|
const selector =
|
|
"ytd-rich-item-renderer, ytd-video-renderer, ytd-grid-video-renderer";
|
|
const videos = root.querySelectorAll(selector);
|
|
const rootIsVideo =
|
|
root instanceof HTMLElement && root.matches && root.matches(selector);
|
|
const allVideos = rootIsVideo ? [root, ...videos] : Array.from(videos);
|
|
|
|
allVideos.forEach(video => {
|
|
const videoMeta = getVideoMeta(video);
|
|
if (!videoMeta.videoId) return;
|
|
const channelInfo = getChannelInfo(video);
|
|
updateSeenIndex({
|
|
stableId: videoMeta.videoId,
|
|
channelKey: channelInfo.key,
|
|
channelLabel: channelInfo.label,
|
|
channelUrl: channelInfo.url
|
|
});
|
|
});
|
|
}
|
|
|
|
// Apply whitelist to already-tagged member-only videos.
|
|
function updateKnownVisibility() {
|
|
if (isWhitelistedChannelPage()) {
|
|
revealAll();
|
|
return;
|
|
}
|
|
for (const [id, meta] of memberOnlyIndex.entries()) {
|
|
if (!meta.channelKey) continue;
|
|
const video = findVideoById(id);
|
|
if (!video) {
|
|
memberOnlyIndex.delete(id);
|
|
continue;
|
|
}
|
|
if (whitelist.includes(meta.channelKey)) {
|
|
video.removeAttribute(`data-${DATA_PREFIX}-hidden`);
|
|
if (meta.hidden) hudDirty = true;
|
|
meta.hidden = false;
|
|
updateSharedIndex(meta, false);
|
|
} else {
|
|
video.setAttribute(`data-${DATA_PREFIX}-hidden`, "true");
|
|
if (!meta.hidden) hudDirty = true;
|
|
meta.hidden = true;
|
|
if (!meta.snapshot) meta.snapshot = safeSnapshot(video);
|
|
updateSharedIndex(meta, true);
|
|
}
|
|
}
|
|
updateHudDot();
|
|
}
|
|
|
|
// Allow full visibility on whitelisted channel pages.
|
|
function isWhitelistedChannelPage() {
|
|
const handle = getChannelHandleFromUrl(location.href);
|
|
if (!handle) return false;
|
|
return whitelistHandles.includes(normalizeKey(handle));
|
|
}
|
|
|
|
// Extract @handle from a channel URL.
|
|
function getChannelHandleFromUrl(url) {
|
|
const match = url.match(/youtube\.com\/@([^/]+)/i);
|
|
return match ? match[1] : "";
|
|
}
|
|
|
|
// Remove hidden flags from any tagged videos.
|
|
function revealAll() {
|
|
const videos = document.querySelectorAll(
|
|
`[data-${DATA_PREFIX}-hidden="true"]`
|
|
);
|
|
videos.forEach(video => video.removeAttribute(`data-${DATA_PREFIX}-hidden`));
|
|
}
|
|
|
|
// Choose a stable identifier for indexing.
|
|
function getOrCreateVideoId(video, url) {
|
|
const dataId = video.getAttribute("data-video-id");
|
|
if (dataId) return dataId;
|
|
if (url) return url;
|
|
const fallbackId = video.id;
|
|
if (fallbackId) return fallbackId;
|
|
const generated = `mf-${generatedIdCounter++}`;
|
|
video.setAttribute(`data-${DATA_PREFIX}-id`, generated);
|
|
return generated;
|
|
}
|
|
|
|
function getStableId(video, url, fallbackId) {
|
|
const dataId = video.getAttribute("data-video-id");
|
|
if (dataId) return dataId;
|
|
if (url) return url;
|
|
return fallbackId || "";
|
|
}
|
|
|
|
function getVideoMeta(video) {
|
|
const linkEl = findWatchLink(video);
|
|
const href = linkEl?.getAttribute("href") || "";
|
|
const videoId =
|
|
getVideoIdFromHref(href) ||
|
|
video.getAttribute("data-video-id") ||
|
|
video.querySelector("[data-video-id]")?.getAttribute("data-video-id") ||
|
|
"";
|
|
const url = videoId
|
|
? `https://www.youtube.com/watch?v=${videoId}`
|
|
: href.startsWith("http")
|
|
? href
|
|
: href
|
|
? new URL(href, location.origin).toString()
|
|
: "";
|
|
const title =
|
|
getLinkTitle(linkEl) || getFallbackTitle(video) || "(no title)";
|
|
const thumb =
|
|
(videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : "") ||
|
|
video.querySelector("img")?.currentSrc ||
|
|
video.querySelector("img")?.getAttribute("src") ||
|
|
"";
|
|
const needsRetry = !videoId || !title || title === "(no title)";
|
|
return { title, url, thumb, videoId, needsRetry };
|
|
}
|
|
|
|
function findWatchLink(video) {
|
|
const links = Array.from(video.querySelectorAll("a[href]"));
|
|
let best = null;
|
|
for (const link of links) {
|
|
const href = link.getAttribute("href") || "";
|
|
if (!href.includes("watch?v=")) continue;
|
|
if (!best) best = link;
|
|
const titled = getLinkTitle(link) || link.textContent?.trim();
|
|
if (titled) return link;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function getLinkTitle(link) {
|
|
if (!link) return "";
|
|
const title = link.getAttribute("title") || link.getAttribute("aria-label");
|
|
return title ? title.trim() : "";
|
|
}
|
|
|
|
function getFallbackTitle(video) {
|
|
const titled = Array.from(video.querySelectorAll("[title]"))
|
|
.map(el => el.getAttribute("title") || "")
|
|
.map(text => text.trim())
|
|
.find(text => text && text.toLowerCase() !== "undefined");
|
|
if (titled) return titled;
|
|
const aria = Array.from(video.querySelectorAll("[aria-label]"))
|
|
.map(el => el.getAttribute("aria-label") || "")
|
|
.map(text => text.trim())
|
|
.find(text => text);
|
|
return aria || "";
|
|
}
|
|
|
|
function scheduleMetaRetry(video) {
|
|
const attr = `data-${DATA_PREFIX}-meta-retry`;
|
|
const current = parseInt(video.getAttribute(attr) || "0", 10);
|
|
if (current >= 3) return;
|
|
video.setAttribute(attr, String(current + 1));
|
|
setTimeout(() => {
|
|
process(video);
|
|
}, 400 + current * 400);
|
|
}
|
|
|
|
function getVideoIdFromHref(href) {
|
|
if (!href) return "";
|
|
const match = href.match(/[?&]v=([^&]+)/);
|
|
if (match) return match[1];
|
|
const shortMatch = href.match(/youtu\.be\/([^?]+)/);
|
|
if (shortMatch) return shortMatch[1];
|
|
return "";
|
|
}
|
|
|
|
function buildSnapshot(video) {
|
|
try {
|
|
const root =
|
|
video.closest(
|
|
"ytd-rich-item-renderer, ytd-video-renderer, ytd-grid-video-renderer"
|
|
) || video;
|
|
const clone = root.cloneNode(true);
|
|
removeDataMarkers(clone);
|
|
const html = stripHiddenMarkers(clone.outerHTML).trim();
|
|
if (!html) return "";
|
|
if (html.length > MAX_SNAPSHOT_CHARS) {
|
|
return html.slice(0, MAX_SNAPSHOT_CHARS);
|
|
}
|
|
return html;
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Read the channel key from DOM or fallback to text.
|
|
function getChannelKey(video) {
|
|
const stored = video.getAttribute(`data-${DATA_PREFIX}-channel`);
|
|
if (stored) return stored;
|
|
return getChannelInfo(video).key;
|
|
}
|
|
|
|
function getChannelKeyFromData(channelText, channelUrl) {
|
|
const handle = getChannelHandleFromUrl(channelUrl);
|
|
if (handle) return normalizeKey(handle);
|
|
return channelText ? normalizeKey(channelText) : "";
|
|
}
|
|
|
|
function getChannelInfo(video) {
|
|
const channelBlock =
|
|
video.querySelector("ytd-channel-name") ||
|
|
video.querySelector(".ytd-channel-name");
|
|
const linkEl =
|
|
channelBlock?.querySelector("a") ||
|
|
video.querySelector('a[href^="/@"]') ||
|
|
video.querySelector('a[href^="/channel/"]') ||
|
|
video.querySelector('a[href^="/c/"]');
|
|
const labelEl =
|
|
channelBlock?.querySelector("#text") ||
|
|
channelBlock?.querySelector("yt-formatted-string") ||
|
|
channelBlock;
|
|
const label = labelEl?.textContent?.trim() ?? "";
|
|
const url = linkEl?.href ?? "";
|
|
const handleFromUrl = getChannelHandleFromUrl(url);
|
|
const handleFromPage = getChannelHandleFromUrl(location.href);
|
|
const fallbackLabel = handleFromUrl || handleFromPage || "";
|
|
const finalLabel = label || fallbackLabel;
|
|
const key = getChannelKeyFromData(finalLabel, url || handleFromPage);
|
|
return { label: finalLabel, url: url || handleFromPage, key };
|
|
}
|
|
|
|
// Find a video element by stored id.
|
|
function findVideoById(id) {
|
|
const escaped = cssEscape(id);
|
|
return document.querySelector(`[data-${DATA_PREFIX}-id="${escaped}"]`);
|
|
}
|
|
|
|
// Safe attribute selector escaping.
|
|
function cssEscape(value) {
|
|
if (window.CSS && CSS.escape) return CSS.escape(value);
|
|
return String(value).replace(/["\\]/g, "\\$&");
|
|
}
|
|
|
|
function ensureHudDot() {
|
|
let dot = document.getElementById(HUD_DOT_ID);
|
|
if (!dot) {
|
|
dot = document.createElement("div");
|
|
dot.id = HUD_DOT_ID;
|
|
dot.setAttribute(`data-${DATA_PREFIX}-active`, "false");
|
|
dot.title = "Member-only videos: 0 (0 hidden)";
|
|
document.body.appendChild(dot);
|
|
}
|
|
let panel = document.getElementById(HUD_PANEL_ID);
|
|
if (!panel) {
|
|
panel = document.createElement("div");
|
|
panel.id = HUD_PANEL_ID;
|
|
panel.setAttribute(`data-${DATA_PREFIX}-hidden`, "true");
|
|
document.body.appendChild(panel);
|
|
}
|
|
if (!dot.dataset.bound) {
|
|
dot.dataset.bound = "true";
|
|
dot.addEventListener("click", event => {
|
|
event.stopPropagation();
|
|
const isHidden =
|
|
panel.getAttribute(`data-${DATA_PREFIX}-hidden`) === "true";
|
|
panel.setAttribute(
|
|
`data-${DATA_PREFIX}-hidden`,
|
|
isHidden ? "false" : "true"
|
|
);
|
|
if (isHidden) renderHudPanel(panel);
|
|
});
|
|
panel.addEventListener("click", event => event.stopPropagation());
|
|
document.addEventListener("click", event => {
|
|
if (panel.getAttribute(`data-${DATA_PREFIX}-hidden`) !== "false") return;
|
|
if (panel.contains(event.target) || dot.contains(event.target)) return;
|
|
panel.setAttribute(`data-${DATA_PREFIX}-hidden`, "true");
|
|
});
|
|
}
|
|
return dot;
|
|
}
|
|
|
|
function updateHudDot() {
|
|
if (contextInvalidated || !document?.body) return;
|
|
try {
|
|
const dot = ensureHudDot();
|
|
const panel = document.getElementById(HUD_PANEL_ID);
|
|
const total = sharedIndex.size;
|
|
const hidden = total;
|
|
const signature = `${total}:${hidden}`;
|
|
dot.title = `Member-only videos: ${total} (${hidden} hidden)`;
|
|
dot.setAttribute(
|
|
`data-${DATA_PREFIX}-active`,
|
|
total > 0 ? "true" : "false"
|
|
);
|
|
if (
|
|
panel &&
|
|
panel.getAttribute(`data-${DATA_PREFIX}-hidden`) === "false" &&
|
|
(hudDirty || signature !== lastHudSignature)
|
|
) {
|
|
renderHudPanel(panel);
|
|
hudDirty = false;
|
|
lastHudSignature = signature;
|
|
}
|
|
} catch (error) {
|
|
contextInvalidated = true;
|
|
}
|
|
}
|
|
|
|
function flashHudDot() {
|
|
const dot = ensureHudDot();
|
|
dot.setAttribute(`data-${DATA_PREFIX}-flash`, "true");
|
|
if (hudFlashTimer) clearTimeout(hudFlashTimer);
|
|
hudFlashTimer = setTimeout(() => {
|
|
dot.removeAttribute(`data-${DATA_PREFIX}-flash`);
|
|
}, 900);
|
|
}
|
|
|
|
function updateSharedIndex(meta, hidden) {
|
|
if (!meta.stableId) {
|
|
meta.stableId = meta.url || meta.id || "";
|
|
}
|
|
if (!meta.stableId) return;
|
|
if (hidden) {
|
|
const existing = sharedIndex.get(meta.stableId) || {};
|
|
sharedIndex.set(meta.stableId, {
|
|
id: meta.stableId,
|
|
channelKey: meta.channelKey || existing.channelKey,
|
|
channelLabel: meta.channelLabel || existing.channelLabel,
|
|
channelUrl: meta.channelUrl || existing.channelUrl,
|
|
title: meta.title || existing.title,
|
|
url: meta.url || existing.url,
|
|
thumb: meta.thumb || existing.thumb,
|
|
snapshot: meta.snapshot ? stripHiddenMarkers(meta.snapshot) : existing.snapshot,
|
|
hidden: true,
|
|
lastSeen: Date.now()
|
|
});
|
|
} else {
|
|
sharedIndex.delete(meta.stableId);
|
|
}
|
|
schedulePersistSharedIndex();
|
|
}
|
|
|
|
function updateSeenIndex(meta) {
|
|
if (!meta.stableId) return;
|
|
const existing = sharedSeenIndex.get(meta.stableId);
|
|
if (!existing) {
|
|
sharedSeenIndex.set(meta.stableId, {
|
|
id: meta.stableId,
|
|
channelKey: meta.channelKey,
|
|
channelLabel: meta.channelLabel,
|
|
channelUrl: meta.channelUrl,
|
|
lastSeen: Date.now()
|
|
});
|
|
schedulePersistSeenIndex();
|
|
return;
|
|
}
|
|
existing.channelKey = meta.channelKey || existing.channelKey;
|
|
existing.channelLabel = meta.channelLabel || existing.channelLabel;
|
|
existing.channelUrl = meta.channelUrl || existing.channelUrl;
|
|
existing.lastSeen = Date.now();
|
|
schedulePersistSeenIndex();
|
|
}
|
|
|
|
function updateMemberIndex(meta) {
|
|
if (!meta.stableId) return;
|
|
const existing = sharedMemberIndex.get(meta.stableId);
|
|
if (!existing) {
|
|
sharedMemberIndex.set(meta.stableId, {
|
|
id: meta.stableId,
|
|
channelKey: meta.channelKey,
|
|
channelLabel: meta.channelLabel,
|
|
channelUrl: meta.channelUrl,
|
|
lastSeen: Date.now()
|
|
});
|
|
schedulePersistMemberIndex();
|
|
return;
|
|
}
|
|
existing.channelKey = meta.channelKey || existing.channelKey;
|
|
existing.channelLabel = meta.channelLabel || existing.channelLabel;
|
|
existing.channelUrl = meta.channelUrl || existing.channelUrl;
|
|
existing.lastSeen = Date.now();
|
|
schedulePersistMemberIndex();
|
|
}
|
|
|
|
function schedulePersistSharedIndex() {
|
|
if (persistHiddenTimer) return;
|
|
persistHiddenTimer = setTimeout(() => {
|
|
persistHiddenTimer = null;
|
|
persistSharedIndex();
|
|
}, 200);
|
|
}
|
|
|
|
function persistSharedIndex() {
|
|
if (!chrome?.storage?.local || contextInvalidated) return;
|
|
const items = Array.from(sharedIndex.values());
|
|
items.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
|
|
chrome.storage.local.get({ maxArchive: DEFAULT_MAX_ARCHIVE }, data => {
|
|
const maxArchive = Number(data.maxArchive);
|
|
const max = Number.isFinite(maxArchive) ? maxArchive : DEFAULT_MAX_ARCHIVE;
|
|
if (!chrome?.storage?.local || contextInvalidated) return;
|
|
if (max < 0) {
|
|
safeStorageSet({ [STORAGE_KEY]: items });
|
|
return;
|
|
}
|
|
safeStorageSet({ [STORAGE_KEY]: items.slice(0, max) });
|
|
});
|
|
}
|
|
|
|
function schedulePersistSeenIndex() {
|
|
if (persistSeenTimer) return;
|
|
persistSeenTimer = setTimeout(() => {
|
|
persistSeenTimer = null;
|
|
persistSeenIndex();
|
|
}, 200);
|
|
}
|
|
|
|
function persistSeenIndex() {
|
|
if (!chrome?.storage?.local || contextInvalidated) return;
|
|
const items = Array.from(sharedSeenIndex.values());
|
|
items.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
|
|
chrome.storage.local.get({ maxArchive: DEFAULT_MAX_ARCHIVE }, data => {
|
|
const maxArchive = Number(data.maxArchive);
|
|
const max = Number.isFinite(maxArchive) ? maxArchive : DEFAULT_MAX_ARCHIVE;
|
|
if (max < 0) {
|
|
safeStorageSet({ [STORAGE_KEY_SEEN]: items });
|
|
return;
|
|
}
|
|
safeStorageSet({ [STORAGE_KEY_SEEN]: items.slice(0, max) });
|
|
});
|
|
}
|
|
|
|
function schedulePersistMemberIndex() {
|
|
if (persistMemberTimer) return;
|
|
persistMemberTimer = setTimeout(() => {
|
|
persistMemberTimer = null;
|
|
persistMemberIndex();
|
|
}, 200);
|
|
}
|
|
|
|
function persistMemberIndex() {
|
|
if (!chrome?.storage?.local || contextInvalidated) return;
|
|
const items = Array.from(sharedMemberIndex.values());
|
|
items.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
|
|
chrome.storage.local.get({ maxArchive: DEFAULT_MAX_ARCHIVE }, data => {
|
|
const maxArchive = Number(data.maxArchive);
|
|
const max = Number.isFinite(maxArchive) ? maxArchive : DEFAULT_MAX_ARCHIVE;
|
|
if (max < 0) {
|
|
safeStorageSet({ [STORAGE_KEY_MEMBER]: items });
|
|
return;
|
|
}
|
|
safeStorageSet({ [STORAGE_KEY_MEMBER]: items.slice(0, max) });
|
|
});
|
|
}
|
|
|
|
function loadStoredIndex() {
|
|
if (!chrome?.storage?.local) return;
|
|
chrome.storage.local.get(
|
|
{ [STORAGE_KEY]: [], [STORAGE_KEY_SEEN]: [], [STORAGE_KEY_MEMBER]: [] },
|
|
data => {
|
|
sharedIndex.clear();
|
|
data[STORAGE_KEY].forEach(item => sharedIndex.set(item.id, item));
|
|
sharedSeenIndex.clear();
|
|
data[STORAGE_KEY_SEEN].forEach(item => sharedSeenIndex.set(item.id, item));
|
|
sharedMemberIndex.clear();
|
|
data[STORAGE_KEY_MEMBER].forEach(item =>
|
|
sharedMemberIndex.set(item.id, item)
|
|
);
|
|
if (sharedMemberIndex.size === 0 && sharedIndex.size > 0) {
|
|
for (const item of sharedIndex.values()) {
|
|
sharedMemberIndex.set(item.id, {
|
|
id: item.id,
|
|
channelKey: item.channelKey,
|
|
channelLabel: item.channelLabel,
|
|
channelUrl: item.channelUrl,
|
|
lastSeen: item.lastSeen || Date.now()
|
|
});
|
|
}
|
|
schedulePersistMemberIndex();
|
|
}
|
|
updateHudDot();
|
|
}
|
|
);
|
|
}
|
|
|
|
function renderHudPanel(panel) {
|
|
panel.innerHTML = "";
|
|
const title = document.createElement("div");
|
|
title.className = `${DATA_PREFIX}-hud-title`;
|
|
title.textContent = "Hidden member-only videos";
|
|
panel.appendChild(title);
|
|
|
|
const body = document.createElement("div");
|
|
body.className = `${DATA_PREFIX}-hud-body`;
|
|
panel.appendChild(body);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = `${DATA_PREFIX}-hud-actions`;
|
|
const clearBtn = document.createElement("button");
|
|
clearBtn.className = `${DATA_PREFIX}-hud-clear`;
|
|
clearBtn.textContent = "Clear archive";
|
|
clearBtn.addEventListener("click", () => clearArchive());
|
|
actions.appendChild(clearBtn);
|
|
body.appendChild(actions);
|
|
|
|
const groups = new Map();
|
|
for (const meta of sharedIndex.values()) {
|
|
if (!meta.hidden) continue;
|
|
const key =
|
|
meta.channelKey ||
|
|
getChannelKeyFromData(meta.channelLabel, meta.channelUrl) ||
|
|
"(unknown)";
|
|
const list = groups.get(key) || [];
|
|
list.push(meta);
|
|
groups.set(key, list);
|
|
}
|
|
|
|
if (groups.size === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.textContent = "No hidden member-only videos.";
|
|
body.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
const totals = getSeenTotalsByCreator();
|
|
const memberTotals = getMemberTotalsByCreator();
|
|
|
|
for (const [creator, items] of groups.entries()) {
|
|
const details = document.createElement("details");
|
|
details.className = `${DATA_PREFIX}-channel`;
|
|
details.open = false;
|
|
|
|
const summary = document.createElement("summary");
|
|
summary.className = `${DATA_PREFIX}-channel-header`;
|
|
const label = items[0]?.channelLabel && items[0].channelLabel !== "(no channel)"
|
|
? items[0].channelLabel
|
|
: creator;
|
|
const total = totals.get(creator) || items.length;
|
|
const memberCount = memberTotals.get(creator) || items.length;
|
|
const percent = total > 0 ? Math.round((memberCount / total) * 100) : 0;
|
|
summary.textContent = `${label} (${memberCount}/${total} known, ${percent}%)`;
|
|
details.appendChild(summary);
|
|
|
|
const list = document.createElement("div");
|
|
list.className = `${DATA_PREFIX}-video-list`;
|
|
|
|
items.forEach(meta => {
|
|
if (meta.snapshot) {
|
|
const snap = document.createElement("div");
|
|
snap.className = `${DATA_PREFIX}-snapshot`;
|
|
snap.appendChild(sanitizeSnapshotToFragment(meta.snapshot));
|
|
forceStripDataMarkers(snap);
|
|
list.appendChild(snap);
|
|
return;
|
|
}
|
|
|
|
const item = document.createElement("div");
|
|
item.className = `${DATA_PREFIX}-hud-item`;
|
|
|
|
const img = document.createElement("img");
|
|
img.className = `${DATA_PREFIX}-hud-thumb`;
|
|
img.alt = meta.title || "thumbnail";
|
|
if (meta.thumb) img.src = meta.thumb;
|
|
item.appendChild(img);
|
|
|
|
const link = document.createElement("a");
|
|
link.className = `${DATA_PREFIX}-hud-link`;
|
|
link.textContent = meta.title || meta.url || "(no title)";
|
|
link.href = meta.url || "#";
|
|
link.target = "_blank";
|
|
link.rel = "noopener noreferrer";
|
|
item.appendChild(link);
|
|
|
|
list.appendChild(item);
|
|
});
|
|
|
|
body.appendChild(details);
|
|
details.appendChild(list);
|
|
}
|
|
}
|
|
|
|
function getSeenTotalsByCreator() {
|
|
const totals = new Map();
|
|
for (const meta of sharedSeenIndex.values()) {
|
|
const key =
|
|
meta.channelKey ||
|
|
getChannelKeyFromData(meta.channelLabel, meta.channelUrl) ||
|
|
"(unknown)";
|
|
totals.set(key, (totals.get(key) || 0) + 1);
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
function getMemberTotalsByCreator() {
|
|
const totals = new Map();
|
|
for (const meta of sharedMemberIndex.values()) {
|
|
const key =
|
|
meta.channelKey ||
|
|
getChannelKeyFromData(meta.channelLabel, meta.channelUrl) ||
|
|
"(unknown)";
|
|
totals.set(key, (totals.get(key) || 0) + 1);
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
function clearArchive() {
|
|
sharedIndex.clear();
|
|
sharedSeenIndex.clear();
|
|
sharedMemberIndex.clear();
|
|
safeStorageSet(
|
|
{ [STORAGE_KEY]: [], [STORAGE_KEY_SEEN]: [], [STORAGE_KEY_MEMBER]: [] },
|
|
() => updateHudDot()
|
|
);
|
|
}
|
|
|
|
function safeStorageSet(payload, callback) {
|
|
try {
|
|
chrome.storage.local.set(payload, callback);
|
|
} catch (error) {
|
|
contextInvalidated = true;
|
|
}
|
|
}
|
|
|
|
/* ---------------- initial + observer ---------------- */
|
|
|
|
process();
|
|
|
|
// Watch for new items so member-only tags are applied as they load.
|
|
const observer = new MutationObserver(mutations => {
|
|
for (const m of mutations) {
|
|
for (const node of m.addedNodes) {
|
|
if (node instanceof HTMLElement) {
|
|
process(node);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|