Files
Reddit.openscript/YouTube.os.js

575 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name Reddit Comments on YouTube
// @version 1.0.1
// @description View Reddit discussion threads and comments directly above YouTube comments.
// @match https://www.youtube.com/*
// @run-at document_idle
// ==/UserScript==
const MOUNT_ID = 'os-reddit-mount';
const STYLE_ID = 'os-reddit-style';
const DESC_SELECTOR = '#description, #description-inline-expander, ytd-text-inline-expander';
let currentVideoId = null;
let activePostId = null;
let cachedPosts = [];
const ensureStyles = () => {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
#${MOUNT_ID} {
margin: 16px 0 24px;
font-family: Roboto, Arial, sans-serif;
color: var(--yt-spec-text-primary, #0f0f0f);
}
.os-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 4px 0 12px;
border-bottom: 1px solid var(--yt-spec-outline, rgba(0,0,0,0.1));
scrollbar-width: thin;
}
.os-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 16px;
border: 1px solid var(--yt-spec-outline, rgba(0,0,0,0.1));
background: var(--yt-spec-badge-chip-background, rgba(0,0,0,0.05));
color: var(--yt-spec-text-secondary, #606060);
cursor: pointer;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
transition: background 0.2s, border-color 0.2s;
}
.os-tab:hover {
background: var(--yt-spec-button-chip-background-hover, rgba(0,0,0,0.1));
}
.os-tab.os-active {
background: #ff4500;
color: #fff;
border-color: #ff4500;
}
.os-tab .os-badge {
font-size: 10px;
padding: 1px 5px;
background: rgba(255,255,255,0.25);
border-radius: 8px;
}
.os-tab:not(.os-active) .os-badge {
background: var(--yt-spec-outline, rgba(0,0,0,0.1));
color: var(--yt-spec-text-primary, #0f0f0f);
}
.os-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin: 14px 0 12px;
gap: 12px;
}
.os-post-title {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--yt-spec-text-primary, #0f0f0f);
text-decoration: none;
}
.os-post-title:hover {
text-decoration: underline;
}
.os-post-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 12px;
color: var(--yt-spec-text-secondary, #606060);
}
.os-official-tag {
background: #0079d3;
color: #fff;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
}
.os-comment-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.os-comment {
font-size: 13px;
line-height: 1.5;
}
.os-comment-tagline {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--yt-spec-text-secondary, #606060);
margin-bottom: 4px;
}
.os-collapse {
background: none;
border: none;
color: var(--yt-spec-text-secondary, #606060);
cursor: pointer;
font-family: monospace;
font-size: 12px;
padding: 0 4px;
}
.os-author {
font-weight: 600;
color: var(--yt-spec-text-primary, #0f0f0f);
text-decoration: none;
}
.os-author.os-op {
color: #0079d3;
}
.os-score {
font-weight: 500;
}
.os-body {
color: var(--yt-spec-text-primary, #0f0f0f);
word-break: break-word;
overflow-wrap: anywhere;
}
.os-body p { margin: 4px 0; }
.os-body a { color: var(--yt-spec-brand-link-text, #065fd4); text-decoration: none; }
.os-body a:hover { text-decoration: underline; }
.os-body blockquote {
margin: 4px 0;
padding-left: 8px;
border-left: 3px solid var(--yt-spec-outline, rgba(0,0,0,0.2));
color: var(--yt-spec-text-secondary, #606060);
}
.os-body pre, .os-body code {
background: var(--yt-spec-badge-chip-background, rgba(0,0,0,0.05));
border-radius: 4px;
font-size: 12px;
padding: 2px 4px;
}
.os-replies {
margin-left: 12px;
padding-left: 12px;
border-left: 2px solid var(--yt-spec-outline, rgba(0,0,0,0.1));
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
.os-collapsed > .os-body,
.os-collapsed > .os-replies,
.os-collapsed > .os-load-more {
display: none !important;
}
.os-load-more {
background: none;
border: 1px solid var(--yt-spec-outline, rgba(0,0,0,0.1));
border-radius: 12px;
padding: 4px 10px;
color: var(--yt-spec-brand-link-text, #065fd4);
font-size: 12px;
cursor: pointer;
margin-top: 6px;
align-self: flex-start;
}
.os-load-more:hover {
background: var(--yt-spec-badge-chip-background, rgba(0,0,0,0.05));
}
.os-status {
padding: 10px 14px;
font-size: 13px;
border-radius: 8px;
background: var(--yt-spec-badge-chip-background, rgba(0,0,0,0.04));
color: var(--yt-spec-text-secondary, #606060);
margin-bottom: 8px;
}
.os-retry-btn {
margin-left: 8px;
color: var(--yt-spec-brand-link-text, #065fd4);
background: none;
border: none;
cursor: pointer;
font-size: 13px;
text-decoration: underline;
}
`;
document.head.append(style);
};
const formatScore = n => {
if (typeof n !== 'number') return '0';
if (Math.abs(n) >= 1e4) return `${(n / 1e3).toFixed(1)}k`;
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
return String(n);
};
const timeAgo = utc => {
const s = Math.max(0, Math.floor(Date.now() / 1e3 - utc));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
const mo = Math.floor(d / 30);
if (mo < 12) return `${mo}mo ago`;
return `${Math.floor(mo / 12)}y ago`;
};
const decodeHtml = str => {
const txt = document.createElement('textarea');
txt.innerHTML = str || '';
return txt.value;
};
const sanitizeHtml = html => {
const decoded = decodeHtml(html || '')
.replace(/href="\/((r|u)\/[^"]+)"/g, 'href="https://www.reddit.com/$1"')
.replace(/<a\b([^>]*)>/gi, '<a target="_blank" rel="noopener noreferrer" $1>');
const tpl = document.createElement('template');
tpl.innerHTML = decoded;
tpl.content.querySelectorAll('script, style, iframe, form, button').forEach(el => el.remove());
return tpl.innerHTML;
};
const getOfficialSub = () => {
const descEl = document.querySelector(DESC_SELECTOR);
const match = descEl?.textContent?.match(/reddit\.com\/r\/([^ /\n\r?]+)/i);
return match ? match[1].toLowerCase() : null;
};
const getMount = () => {
let mount = document.getElementById(MOUNT_ID);
if (mount && mount.isConnected) return mount;
const comments = document.querySelector('#comments, ytd-comments');
if (comments && comments.parentNode) {
mount = document.createElement('div');
mount.id = MOUNT_ID;
comments.parentNode.insertBefore(mount, comments);
return mount;
}
const meta = document.querySelector('#below > ytd-watch-metadata, #below > #watch-metadata');
if (meta && meta.parentNode) {
mount = document.createElement('div');
mount.id = MOUNT_ID;
meta.parentNode.insertBefore(mount, meta.nextSibling);
return mount;
}
const below = document.querySelector('#below');
if (below) {
mount = document.createElement('div');
mount.id = MOUNT_ID;
below.append(mount);
return mount;
}
return null;
};
const waitForMount = async (maxAttempts = 40) => {
for (let i = 0; i < maxAttempts; i++) {
const m = getMount();
if (m) return m;
await new Promise(r => setTimeout(r, 250));
}
return null;
};
const renderComments = (children, postAuthor) => {
const container = document.createElement('div');
container.className = 'os-comment-list';
for (const child of children) {
if (child.kind === 'more') {
const { count, children: moreIds } = child.data;
if (!moreIds?.length) continue;
const btn = document.createElement('button');
btn.className = 'os-load-more';
btn.textContent = `Load more comments (${count || moreIds.length})`;
btn.onclick = async () => {
btn.disabled = true;
btn.textContent = 'Loading...';
try {
const res = await OpenScript.fetch(
`https://www.reddit.com/api/morechildren.json?api_type=json&link_id=${activePostId}&children=${moreIds.slice(0, 20).join(',')}&sort=best`
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const items = json?.json?.data?.things?.map(t => t.data) || [];
btn.replaceWith(renderFlatComments(items, postAuthor));
} catch {
btn.textContent = 'Failed to load comments';
}
};
container.append(btn);
continue;
}
const { id, author, score, created_utc, body_html, replies } = child.data;
if (!body_html) continue;
const el = document.createElement('div');
el.className = 'os-comment';
el.id = `os-c-${id}`;
const tagline = document.createElement('div');
tagline.className = 'os-comment-tagline';
const toggle = document.createElement('button');
toggle.className = 'os-collapse';
toggle.textContent = '[]';
toggle.onclick = () => {
const isCol = el.classList.toggle('os-collapsed');
toggle.textContent = isCol ? '[+]' : '[]';
};
const authorLink = document.createElement('a');
authorLink.className = `os-author${author === postAuthor ? ' os-op' : ''}`;
authorLink.href = `https://www.reddit.com/u/${author}`;
authorLink.target = '_blank';
authorLink.rel = 'noopener noreferrer';
authorLink.textContent = author;
const metaSpan = document.createElement('span');
metaSpan.className = 'os-score';
metaSpan.textContent = `${formatScore(score)} points • ${timeAgo(created_utc)}`;
tagline.append(toggle, authorLink, metaSpan);
const body = document.createElement('div');
body.className = 'os-body';
body.innerHTML = sanitizeHtml(body_html);
el.append(tagline, body);
if (replies?.data?.children?.length) {
const sub = renderComments(replies.data.children, postAuthor);
sub.className = 'os-replies';
el.append(sub);
}
container.append(el);
}
return container;
};
const renderFlatComments = (items, postAuthor) => {
const wrap = document.createDocumentFragment();
for (const item of items) {
if (!item.body_html) continue;
const el = document.createElement('div');
el.className = 'os-comment';
const tagline = document.createElement('div');
tagline.className = 'os-comment-tagline';
const authorLink = document.createElement('a');
authorLink.className = `os-author${item.author === postAuthor ? ' os-op' : ''}`;
authorLink.href = `https://www.reddit.com/u/${item.author}`;
authorLink.target = '_blank';
authorLink.rel = 'noopener noreferrer';
authorLink.textContent = item.author;
const meta = document.createElement('span');
meta.className = 'os-score';
meta.textContent = `${formatScore(item.score)} points • ${timeAgo(item.created_utc)}`;
tagline.append(authorLink, meta);
const body = document.createElement('div');
body.className = 'os-body';
body.innerHTML = sanitizeHtml(item.body_html);
el.append(tagline, body);
wrap.append(el);
}
return wrap;
};
const loadPostComments = async (post, postContainer) => {
postContainer.innerHTML = '<div class="os-status">Loading Reddit comments...</div>';
try {
const res = await OpenScript.fetch(`https://www.reddit.com/comments/${post.id}.json?sort=best`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const [, commentsData] = await res.json();
const children = commentsData?.data?.children || [];
postContainer.innerHTML = '';
if (!children.length) {
postContainer.innerHTML = '<div class="os-status">No comments in this thread yet.</div>';
return;
}
postContainer.append(renderComments(children, post.author));
} catch (err) {
postContainer.innerHTML = `<div class="os-status">Unable to load Reddit comments (${err.message}).</div>`;
}
};
const renderThread = (post, mount) => {
let contentArea = mount.querySelector('.os-content-area');
if (!contentArea) {
contentArea = document.createElement('div');
contentArea.className = 'os-content-area';
mount.append(contentArea);
}
contentArea.innerHTML = '';
const header = document.createElement('div');
header.className = 'os-header';
const left = document.createElement('div');
const title = document.createElement('a');
title.className = 'os-post-title';
title.href = `https://www.reddit.com${post.permalink}`;
title.target = '_blank';
title.rel = 'noopener noreferrer';
title.textContent = decodeHtml(post.title);
const meta = document.createElement('div');
meta.className = 'os-post-meta';
meta.textContent = `r/${post.subreddit} • Posted by u/${post.author} ${timeAgo(post.created_utc)}${formatScore(post.score)} points`;
left.append(title, meta);
header.append(left);
contentArea.append(header);
const commentsArea = document.createElement('div');
commentsArea.className = 'os-comments-area';
contentArea.append(commentsArea);
loadPostComments(post, commentsArea);
};
const renderTabs = (posts, mount) => {
mount.innerHTML = '';
const officialSub = getOfficialSub();
if (officialSub) {
posts.sort((a, b) => (a.subreddit.toLowerCase() === officialSub ? -1 : b.subreddit.toLowerCase() === officialSub ? 1 : 0));
}
const tabs = document.createElement('div');
tabs.className = 'os-tabs';
mount.append(tabs);
const activePost = posts.find(p => p.name === activePostId) || posts[0];
activePostId = activePost.name;
posts.forEach(post => {
const isOfficial = officialSub && post.subreddit.toLowerCase() === officialSub;
const tab = document.createElement('div');
tab.className = `os-tab${post.name === activePostId ? ' os-active' : ''}`;
tab.textContent = `r/${post.subreddit}`;
if (isOfficial) {
const off = document.createElement('span');
off.className = 'os-official-tag';
off.textContent = 'Official';
tab.append(off);
}
const badge = document.createElement('span');
badge.className = 'os-badge';
badge.textContent = `${formatScore(post.score)} ↑ • ${post.num_comments} 💬`;
tab.append(badge);
tab.onclick = () => {
if (activePostId === post.name) return;
activePostId = post.name;
tabs.querySelectorAll('.os-tab').forEach(t => t.classList.remove('os-active'));
tab.classList.add('os-active');
renderThread(post, mount);
};
tabs.append(tab);
});
renderThread(activePost, mount);
};
const updateForVideo = async videoId => {
ensureStyles();
cachedPosts = [];
activePostId = null;
const initialMount = getMount();
if (initialMount) {
initialMount.innerHTML = '<div class="os-status">Searching Reddit for discussion threads...</div>';
}
try {
const res = await OpenScript.fetch(
`https://www.reddit.com/search.json?q=url:'${encodeURIComponent(videoId)}'&sort=top&type=link`
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const posts = data?.data?.children?.map(c => c.data) || [];
if (videoId !== currentVideoId) return;
const mount = await waitForMount();
if (!mount || videoId !== currentVideoId) return;
if (!posts.length) {
mount.innerHTML = '<div class="os-status">No Reddit discussions found for this video.</div>';
return;
}
cachedPosts = posts;
renderTabs(posts, mount);
} catch (err) {
const mount = await waitForMount();
if (mount && videoId === currentVideoId) {
mount.innerHTML = `
<div class="os-status">
Could not search Reddit (${err.message}).
<button class="os-retry-btn">Retry</button>
</div>
`;
mount.querySelector('.os-retry-btn')?.addEventListener('click', () => updateForVideo(videoId));
}
}
};
const getVideoId = () => {
const search = new URLSearchParams(location.search);
if (search.get('v')) return search.get('v');
const match = location.pathname.match(/\/shorts\/([a-zA-Z0-9_-]+)/);
return match ? match[1] : null;
};
const check = () => {
const id = getVideoId();
if (!id) {
currentVideoId = null;
const mount = document.getElementById(MOUNT_ID);
if (mount) mount.innerHTML = '';
return;
}
if (id === currentVideoId) {
if (cachedPosts.length && !document.getElementById(MOUNT_ID)?.hasChildNodes()) {
const mount = getMount();
if (mount) renderTabs(cachedPosts, mount);
}
return;
}
currentVideoId = id;
updateForVideo(id);
};
document.addEventListener('yt-navigate-finish', check);
setInterval(check, 1000);
check();