commit 4e35222571b73f4db3be458a2822ded3e018890c Author: multipleof4 Date: Sun Sep 13 13:57:29 2026 -0700 Add YouTube.os.js: Reddit Comments on YouTube user script diff --git a/README.md b/README.md new file mode 100644 index 0000000..9076b1a --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Reddit.openscript + +User scripts for Reddit integrations powered by [OpenScript](https://github.com/GetOpenScript/OpenScript). + +## Scripts + +### 1. Reddit Comments on YouTube (`YouTube.os.js`) + +Embeds relevant Reddit discussion threads and comments directly above the native YouTube comment section on any YouTube video page. + +#### Features +- **Zero API Keys & Read-Only:** Queries public Reddit JSON endpoints without credentials or cookies. Completely anonymous. +- **Dual Comment Display:** YouTube comments remain visible and accessible directly beneath the Reddit discussion section. +- **Multiple Threads Support:** Displays clickable subreddit tabs sorted by `top` score. +- **Official Subreddit Detection:** Automatically detects subreddit links in video descriptions and badges matching threads as `Official`. +- **Collapsible Comment Trees:** Clean nested thread view with `[–]` and `[+]` collapse toggles. +- **Native YouTube Theming:** Adapts seamlessly to YouTube's light and dark themes using YouTube CSS color variables. +- **Always Fresh:** No caching, so newly submitted threads appear on page navigation. + +#### Installation +1. Install [OpenScript](https://chromewebstore.google.com/detail/openscript/dkelmgdchndagjemmodhkphdikhpnfol) (requires version `1.0.4` or newer for `OpenScript.fetch`). +2. Open the OpenScript popup and click **New Script**. +3. Copy and paste the contents of [`YouTube.os.js`](YouTube.os.js) into the editor. +4. Save the script and ensure it is enabled. +5. Open any YouTube video with Reddit discussions (e.g. trailers, music videos, podcasts). diff --git a/YouTube.os.js b/YouTube.os.js new file mode 100644 index 0000000..75fce0e --- /dev/null +++ b/YouTube.os.js @@ -0,0 +1,526 @@ +// ==UserScript== +// @name Reddit Comments on YouTube +// @version 1.0.0 +// @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 COMMENTS_SELECTOR = '#comments'; +const DESC_SELECTOR = '#description, #description-inline-expander, ytd-text-inline-expander'; + +let currentVideoId = null; +let activePostId = null; +let cachedPosts = []; +let abortController = null; + +const ensureStyles = () => { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + #${MOUNT_ID} { + margin-bottom: 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-inner { + padding: 4px 0; + } + .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: 12px 0; + font-size: 13px; + color: var(--yt-spec-text-secondary, #606060); + font-style: italic; + } + `; + 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(/]*)>/gi, ''); + 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) return mount; + const target = document.querySelector(COMMENTS_SELECTOR); + if (!target || !target.parentNode) return null; + mount = document.createElement('div'); + mount.id = MOUNT_ID; + target.parentNode.insertBefore(mount, target); + return mount; +}; + +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, id, parent_id } = 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) || []; + const rendered = renderFlatComments(items, postAuthor); + btn.replaceWith(rendered); + } 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 = '
Loading Reddit comments...
'; + 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 = '
No comments in this thread yet.
'; + return; + } + postContainer.append(renderComments(children, post.author)); + } catch (err) { + postContainer.innerHTML = `
Unable to load Reddit comments (${err.message}).
`; + } +}; + +const renderThread = post => { + const mount = getMount(); + if (!mount) return; + + 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 => { + const mount = getMount(); + if (!mount) return; + + const officialSub = getOfficialSub(); + if (officialSub) { + posts.sort((a, b) => (a.subreddit.toLowerCase() === officialSub ? -1 : b.subreddit.toLowerCase() === officialSub ? 1 : 0)); + } + + let tabs = mount.querySelector('.os-tabs'); + if (!tabs) { + tabs = document.createElement('div'); + tabs.className = 'os-tabs'; + mount.prepend(tabs); + } + tabs.innerHTML = ''; + + 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); + }; + + tabs.append(tab); + }); + + renderThread(activePost); +}; + +const updateForVideo = async videoId => { + ensureStyles(); + const mount = getMount(); + if (!mount) return; + + mount.innerHTML = '
Searching Reddit for discussion threads...
'; + cachedPosts = []; + + 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 (!posts.length) { + mount.innerHTML = ''; + return; + } + + cachedPosts = posts; + renderTabs(posts); + } catch (err) { + mount.innerHTML = `
Could not search Reddit (${err.message}).
`; + } +}; + +const check = () => { + const id = new URLSearchParams(location.search).get('v'); + 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()) { + renderTabs(cachedPosts); + } + return; + } + currentVideoId = id; + activePostId = null; + updateForVideo(id); +}; + +document.addEventListener('yt-navigate-finish', check); +setInterval(check, 1200); +check();