Sleazy Fork is available in English.
Reddit video with sound: the clip in view plays with sound while you scroll (Reddit video + RedGifs), and one button turns any subreddit, search or profile into a fullscreen player (auto-advance or scroll).
// ==UserScript==
// @name Glide for Reddit
// @namespace https://github.com/Bensindunk123/glide-for-reddit
// @version 1.0.0
// @description Reddit video with sound: the clip in view plays with sound while you scroll (Reddit video + RedGifs), and one button turns any subreddit, search or profile into a fullscreen player (auto-advance or scroll).
// @author Bensindunk123
// @license MIT
// @match https://www.reddit.com/*
// @match https://old.reddit.com/*
// @match https://sh.reddit.com/*
// @match https://*.redgifs.com/ifr/*
// @match https://*.redgifs.com/watch/*
// @grant none
// @run-at document-idle
// @homepageURL https://github.com/Bensindunk123/glide-for-reddit
// @supportURL https://github.com/Bensindunk123/glide-for-reddit/issues
// ==/UserScript==
// Generated by build.mjs from src/. Source: https://github.com/Bensindunk123/glide-for-reddit
// ---- feed autoplay with sound (src/autoplay.js) ----
if (location.hostname === 'www.reddit.com' || /(^|\.)redgifs\.com$/.test(location.hostname)) {
/*
* How it works: on reddit.com an IntersectionObserver watches every Reddit
* <video>/<shreddit-player> and every embedded RedGifs iframe. The clip that is
* in view is played with sound; the previous one is paused and muted. RedGifs
* runs inside a cross-origin iframe (in shadow DOM), so the script talks to its
* own copy inside that iframe via postMessage.
*
* REQUIREMENT: allow autoplay-with-sound for these sites, or the browser blocks
* the unmuted play() and clips stay paused. In Brave/Chrome:
* brave://settings/content/autoplay -> Allowed -> add [*.]reddit.com and [*.]redgifs.com
* See the README for details.
*/
(() => {
// ---------- config ----------
const VOLUME = 0.6; // 0–1, volume of the active clip
const THRESHOLD = 0.6; // fraction of the clip that must be visible …
const VIEWPORT_FILL = 0.5; // … OR fraction of screen height it fills (for tall/portrait clips)
const PAUSE_WHEN_HIDDEN = true; // pause everything when the tab is hidden / screen is locked
const DEBUG = false; // set true for a small status line (helps when troubleshooting an install)
// ---------- status box (only when DEBUG) ----------
const box = (() => {
if (!DEBUG) return { el: null, set() {} };
const el = document.createElement('div');
el.style.cssText = 'position:fixed;z-index:2147483647;right:6px;bottom:6px;max-width:46vw;font:11px/1.35 monospace;background:#000c;color:#0f0;padding:4px 6px;border-radius:4px;white-space:pre-wrap;pointer-events:none';
(document.body || document.documentElement).appendChild(el);
const lines = {};
return { el, set(k, v) { if (lines[k] === v) return; lines[k] = v; el.textContent = Object.entries(lines).map(([a, b]) => a + ': ' + b).join('\n'); } };
})();
const inIframe = window.top !== window;
const onlyBox = recs => box.el && recs.every(r => r.target === box.el || box.el.contains(r.target));
// Keep a <video> in the desired state even when the player pauses/mutes itself
// (loop, quality change). If the user clicks in the player, the next pause is
// treated as intentional until the next explicit 'play' command.
const makeKeeper = (getState, report) => {
let userPaused = false, lastClick = 0;
document.addEventListener('pointerdown', () => { lastClick = Date.now(); }, true);
const hook = new WeakSet();
const enforce = (v) => {
const want = (PAUSE_WHEN_HIDDEN && document.hidden) ? 'pause' : getState(v);
if (want === 'play') {
if (userPaused) { report('paused (you clicked)'); return; }
if (v.muted || v.volume === 0) { v.muted = false; v.volume = VOLUME; }
if (v.paused) v.play().then(() => report('PLAY ok, muted=' + v.muted)).catch(e => report('PLAY FAIL ' + e.name + ': ' + e.message));
else report('playing, muted=' + v.muted);
} else {
if (!v.muted) v.muted = true;
if (!v.paused) v.pause();
report('paused');
}
};
const attach = (v) => {
if (hook.has(v)) return; hook.add(v);
v.addEventListener('pause', () => { if (getState(v) === 'play') { if (Date.now() - lastClick < 1500) userPaused = true; else setTimeout(() => enforce(v), 50); } });
v.addEventListener('volumechange', () => { if (getState(v) === 'play' && !userPaused && (v.muted || v.volume === 0)) setTimeout(() => enforce(v), 50); });
v.addEventListener('ended', () => { if (getState(v) === 'play' && !userPaused) v.play().catch(() => {}); });
};
return { enforce, attach, reset() { userPaused = false; } };
};
// ---------- Inside the RedGifs player (iframe on Reddit, or a /watch page) ----------
if (location.hostname.endsWith('redgifs.com')) {
const standalone = location.pathname.startsWith('/watch/');
let last = standalone ? 'play' : 'pause';
const report = (msg) => { box.set('rg', msg); if (inIframe) window.parent.postMessage({ rrgAck: msg }, '*'); };
const keeper = makeKeeper(() => last, report);
let armed = standalone; // in an iframe: touch nothing until the Reddit page sends a command
// After Reddit removes RedGifs iframes that have played sound, the clips that follow stall (seen in
// Chrome 154): they still count as playing, but neither sound nor picture moves until the page is
// reloaded. Sound routed through an AudioContext in the clip's own iframe is not affected, so the
// clips we play with sound go that way.
let ctx = null, on = false; // on: what we last asked the context to be
const wired = new WeakSet();
const wire = () => {
if (!on || ctx.state !== 'running') return; // not (yet) allowed to start: clips stay on the normal path
for (const v of document.querySelectorAll('video')) {
// only same-origin (blob:) media may be routed; anything else would come out silent
if (wired.has(v) || !v.currentSrc.startsWith('blob:')) continue;
wired.add(v);
try { ctx.createMediaElementSource(v).connect(ctx.destination); } catch (e) {}
}
};
const ownAudio = () => {
if (!inIframe) return;
try { ctx = ctx || new AudioContext(); } catch (e) { return; }
if (!on) { on = true; ctx.resume().then(wire, () => {}); }
wire();
};
const apply = () => {
if (!armed) return;
if (last === 'play' && !(PAUSE_WHEN_HIDDEN && document.hidden)) ownAudio();
else if (ctx && on) { on = false; ctx.suspend().catch(() => {}); }
const vids = document.querySelectorAll('video');
if (!vids.length) { report('no <video> yet'); return; }
for (const v of vids) { keeper.attach(v); keeper.enforce(v); }
};
window.addEventListener('message', e => {
if (e.data && e.data.rrgCmd) { armed = true; last = e.data.rrgCmd; if (last === 'play') keeper.reset(); apply(); }
});
new MutationObserver(recs => { if (!onlyBox(recs)) apply(); }).observe(document.documentElement, { childList: true, subtree: true });
setInterval(apply, 1000); // fallback if the player changes state without a DOM event
document.addEventListener('visibilitychange', () => { if (document.hidden) apply(); else if (last === 'play') { keeper.reset(); apply(); } });
report('script loaded, waiting for ' + (standalone ? 'nothing (watch)' : 'reddit'));
apply();
// Commands sent before this script finished loading are lost (Reddit creates the
// iframe lazily, and the userscript manager can inject at document-idle = sometimes
// AFTER load) -> ask the parent for the current state once we're ready.
if (inIframe) window.parent.postMessage({ rrgHello: true }, '*');
return;
}
// ---------- On Reddit: drive playback from what is in view ----------
box.set('script', 'loaded on reddit (ON)');
window.addEventListener('message', e => {
if (!e.data) return;
if (e.data.rrgAck) box.set('iframe reply', e.data.rrgAck);
if (e.data.rrgHello) { const f = iframes.find(el => el.isConnected && el.contentWindow === e.source); if (f) control(f, f === active ? 'play' : 'pause'); }
});
let active = null;
const findVideo = el => el.tagName === 'VIDEO' ? el
: (el.shadowRoot && el.shadowRoot.querySelector('video')) || el.querySelector('video');
// keeper only keeps the ACTIVE Reddit clip going; the others are left paused
const keeper = makeKeeper(v => (active && findVideo(active) === v) ? 'play' : 'pause', msg => box.set('reddit-video', msg));
const control = (el, cmd) => {
if (el.tagName === 'IFRAME') {
box.set('sending', cmd + ' -> iframe ' + (el.src || '').slice(0, 50));
el.contentWindow && el.contentWindow.postMessage({ rrgCmd: cmd }, '*'); return;
}
const v = findVideo(el);
if (!v) { box.set('sending', cmd + ' -> ' + el.tagName + ' (no <video> found)'); return; }
if (cmd === 'play') { keeper.reset(); keeper.attach(v); keeper.enforce(v); }
else { v.pause(); v.muted = true; box.set('sending', 'pause -> ' + el.tagName); }
};
const visibleEnough = en => en.intersectionRatio >= THRESHOLD
|| (en.isIntersecting && en.intersectionRect.height >= VIEWPORT_FILL * window.innerHeight);
const io = new IntersectionObserver(entries => {
for (const en of entries) {
if (visibleEnough(en)) {
if (active && active !== en.target) control(active, 'pause');
if (active !== en.target) { active = en.target; box.set('active', active.tagName.toLowerCase()); control(active, 'play'); }
} else if (en.target === active) {
control(active, 'pause'); active = null; box.set('active', '-');
}
}
}, { threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] });
const SEL = 'iframe[src*="redgifs.com"], shreddit-player, shreddit-player-2, video';
const counts = { iframe: 0, player: 0, video: 0 };
const seen = new WeakSet(); // elements already handed to the IntersectionObserver
const roots = new WeakSet(); // shadow roots already being watched
const iframes = []; // registered RedGifs iframes (for the hello lookup)
const register = (el) => {
if (seen.has(el)) return; seen.add(el); io.observe(el);
counts[el.tagName === 'IFRAME' ? 'iframe' : el.tagName === 'VIDEO' ? 'video' : 'player']++;
if (el.tagName === 'IFRAME') { iframes.push(el); el.addEventListener('load', () => control(el, el === active ? 'play' : 'pause')); }
box.set('tracking', `redgifs-iframes ${counts.iframe}, reddit-players ${counts.player}, video ${counts.video}`);
};
// Scan ONE subtree — cost is proportional to that subtree, never the whole page. Registers
// matching elements and hooks any shadow roots so their internal changes arrive as events
// instead of forcing a full-document re-walk (that O(DOM) re-walk on every mutation was what
// made the page lag more and more the longer you scrolled).
const process = (node) => {
if (!node) return;
if (node.nodeType === 1) { // an element
if (node.matches(SEL)) register(node);
if (node.shadowRoot) hookRoot(node.shadowRoot);
} else if (node.nodeType !== 11) return; // allow shadow roots / fragments (11); skip text etc.
if (node.querySelectorAll) {
node.querySelectorAll(SEL).forEach(register);
node.querySelectorAll('*').forEach(el => { if (el.shadowRoot) hookRoot(el.shadowRoot); });
}
};
// Reddit builds the RedGifs embed inside shadow DOM, which a document-level observer can't
// see — so watch each shadow root directly. Only fires on real changes, so it stays cheap.
const hookRoot = (root) => {
if (roots.has(root)) return; roots.add(root);
process(root);
new MutationObserver(recs => { for (const r of recs) r.addedNodes.forEach(process); }).observe(root, { childList: true, subtree: true });
};
document.addEventListener('visibilitychange', () => { if (active) control(active, document.hidden ? 'pause' : 'play'); });
process(document.documentElement); // initial sweep
// A few catch-up sweeps in the first seconds cover injection-timing races (the userscript
// manager may run before Reddit finished its first paint). Startup only, so no ongoing cost.
[400, 1200, 3000].forEach(t => setTimeout(() => process(document.documentElement), t));
// Re-assert play on the active RedGifs iframe (postMessage has no delivery guarantee, and an
// iframe may have reloaded). No DOM walk here, so this stays cheap however far you scroll.
setInterval(() => { if (active && active.tagName === 'IFRAME') control(active, 'play'); }, 1500);
// React only to what was ADDED — never re-walk the whole (growing) document.
new MutationObserver(recs => { if (onlyBox(recs)) return; for (const r of recs) r.addedNodes.forEach(process); }).observe(document.documentElement, { childList: true, subtree: true });
})();
}
// ---- RedGifs lookup for the fullscreen player (runs in its hidden RedGifs frame) ----
// api.redgifs.com only answers pages on www.redgifs.com, so the player asks this frame for the clip's file.
// Only the player's origin is answered, and only with a public media URL.
if (location.hostname === 'www.redgifs.com' && window.top !== window) (function () {
var PLAYER_ORIGIN = "https://bensindunk123.github.io";
var token = null;
function auth() {
if (token) return Promise.resolve(token);
return fetch('https://api.redgifs.com/v2/auth/temporary').then(function (r) { return r.json(); })
.then(function (j) { token = j && j.token; return token; });
}
function info(id, retry) {
return auth().then(function (t) {
return fetch('https://api.redgifs.com/v2/gifs/' + id, { headers: { Authorization: 'Bearer ' + t } });
}).then(function (r) {
if (r.status === 401 && retry) { token = null; return info(id, false); }
return r.ok ? r.json() : null;
});
}
window.addEventListener('message', function (e) {
var d = e.data;
if (e.origin !== PLAYER_ORIGIN || !d || d.type !== 'glide-rg-resolve' || !/^[A-Za-z0-9]{1,80}$/.test(d.id || '')) return;
info(d.id.toLowerCase(), true).then(function (j) {
var u = j && j.gif && j.gif.urls;
return (u && (u.hd || u.sd)) || null;
}).catch(function () { return null; }).then(function (url) {
try { e.source.postMessage({ type: 'glide-rg-url', id: d.id, url: url }, PLAYER_ORIGIN); } catch (_) {}
});
});
try { window.parent.postMessage({ type: 'glide-rg-ready' }, PLAYER_ORIGIN); } catch (e) {}
})();
(function () {
'use strict';
if (!/^(www|old|sh)\.reddit\.com$/.test(location.hostname)) return;
// ---- media normalization (generated from src/media.js) ----
// lib/media.js
function decode(s) {
return typeof s === 'string' ? s.replace(/&/g, '&') : s;
}
// RedGifs id from a post url (watch/ifr links), lowercase as the API wants it; null otherwise.
function redgifsId(url) {
const m = /redgifs\.com\/(?:watch|ifr)\/([A-Za-z0-9]+)/i.exec(url || '');
return m ? m[1].toLowerCase() : null;
}
function pickPreview(p) {
const u = p?.preview?.images?.[0]?.source?.url;
return u ? decode(u) : null;
}
function base(p) {
return {
id: p.name,
title: p.title ?? '',
permalink: redditPermalink(p.permalink),
subreddit: p.subreddit_name_prefixed ?? (p.subreddit ? `r/${p.subreddit}` : ''),
author: p.author ?? '',
nsfw: !!p.over_18,
// Interaction state carried from Reddit so the viewer can show the right bookmark
// (saved) and remember the current vote (likes: true=up, false=down, null=none) for the
// swipe-to-vote toggle. dir is the numeric vote used by the /api/vote endpoint (1/0/-1).
saved: !!p.saved,
dir: p.likes === true ? 1 : (p.likes === false ? -1 : 0),
archived: !!p.archived, // Reddit auto-archives after ~6 months: voting is closed (the viewer greys the up/down buttons)
};
}
// Extracts the "strong" media from a post (gallery / video / image / gif). NOT the link-preview
// fallback (handled separately in normalizePost). `b` = base (title/permalink/sub) to keep, useful for
// crossposts, where the media comes from the PARENT post but the metadata stays that of the original post.
function extractMedia(p, b) {
// Reddit gallery
if (p.is_gallery && p.gallery_data && p.media_metadata) {
const images = [];
for (const item of p.gallery_data.items ?? []) {
const meta = p.media_metadata[item.media_id];
if (!meta || meta.status !== 'valid' || !meta.s) continue;
if (meta.e === 'AnimatedImage') {
const src = decode(meta.s.gif || meta.s.mp4);
if (src) images.push({ type: 'gif', src });
} else {
const src = decode(meta.s.u);
if (src) images.push({ type: 'image', src });
}
}
return images.length ? { ...b, type: 'gallery', images } : null;
}
// Reddit video (v.redd.it)
if (p.is_video && p.media?.reddit_video) {
const rv = p.media.reddit_video;
return { ...b, type: 'video', hls: decode(rv.hls_url || null), mp4: decode(rv.fallback_url || null), poster: pickPreview(p) };
}
// Video preview (redgifs/gfycat). We PREFER the MP4 (clean duration => scrub + progress bar
// OK, and "pause that loops" holds): the HLS of these previews returns a duration of Infinity and an
// empty seekable range to Safari, which breaks scrub/bar. These previews are almost always muted, so
// we don't lose any sound. HLS fallback only when there is no MP4.
if (p.preview?.reddit_video_preview) {
const rv = p.preview.reddit_video_preview;
const mp4 = decode(rv.fallback_url || null);
// redgifs: the viewer swaps this silent preview for RedGifs' own file (with sound) — see resolveVideo.
return { ...b, type: 'video', hls: mp4 ? null : decode(rv.hls_url || null), mp4, poster: pickPreview(p), redgifs: redgifsId(p.url_overridden_by_dest || p.url) };
}
// Direct media by URL
const url = p.url_overridden_by_dest || p.url || '';
const path = url.split('?')[0].toLowerCase();
if (path.endsWith('.gifv')) return { ...b, type: 'video', hls: null, mp4: url.replace(/\.gifv(\?.*)?$/i, '.mp4$1'), poster: pickPreview(p) };
if (path.endsWith('.gif')) return { ...b, type: 'gif', src: url };
if (/\.(jpe?g|png|webp|bmp)$/.test(path)) return { ...b, type: 'image', src: url };
if (p.post_hint === 'image') return { ...b, type: 'image', src: pickPreview(p) || url };
return null;
}
function normalizePost(child) {
if (!child || child.kind !== 't3' || !child.data) return null;
const p = child.data;
const b = base(p);
// 1) Direct media from the post.
const direct = extractMedia(p, b);
if (direct) return direct;
// 2) CROSSPOST: the real media is in the PARENT post (reposted). We take it, keeping the
// title / permalink / subreddit of the original post (b). Otherwise a video crosspost would show as a
// still image (its preview) instead of the real video, common in Home/Popular.
const xp = p.crosspost_parent_list && p.crosspost_parent_list[0];
if (xp) { const m = extractMedia(xp, b); if (m) return m; }
// 3) Fallback: external link (article, etc.) WITH a Reddit preview image (makes the "link" subs
// work: r/entertainment, r/news, etc.). We skip text posts (is_self).
if (!p.is_self) {
const prev = pickPreview(p);
if (prev) return { ...b, type: 'image', src: prev };
}
return null; // nothing visual
}
function normalizeSaved(children) {
return (children || []).map(normalizePost).filter(Boolean);
}
// ---------------------------------------------------------------------------------------------
// HTML feed (shreddit). Since 11-2026 Reddit answers the .json listings with 403 "blocked by network
// security" on some connections, while the site's own HTML feed still works. The launcher pulls a
// plain object out of each <shreddit-post> (see shredditInfo in build.mjs) and this turns it into the
// same item shape as normalizePost, so the viewer does not know the difference.
// info = { id, title, permalink, subreddit, author, nsfw, type, href, hls, mp4, poster, gallery[], images[], embed }
// ---------------------------------------------------------------------------------------------
// Full-size i.redd.it url for a preview.redd.it / i.redd.it image url (".../slug-v0-<id>.jpeg?width=640" ->
// "https://i.redd.it/<id>.jpeg"); null when it is not a Reddit-hosted image.
function fullRedditImage(url) {
const m = /^https:\/\/(?:preview|i)\.redd\.it\/(?:[^/?#]*-)?([a-z0-9]{8,})\.(jpe?g|png|webp|gif)(?:[?#]|$)/i.exec(url || '');
return m ? `https://i.redd.it/${m[1]}.${m[2].toLowerCase()}` : null;
}
// A preview that is worth showing: not an avatar/icon, not the NSFW-blurred placeholder.
function clearPreview(images) {
return (images || []).find((u) => u && !/[?&]blur=/.test(u) && !/styles\.redditmedia\.com|redditstatic\.com|\/avatars?\/|snoovatar|communityIcon|emoji/i.test(u)) || null;
}
// Only a site-relative path ("/r/..."): never "//other.host" or "@other.host" tricks.
function redditPermalink(p) {
return typeof p === 'string' && /^\/(?![/\\])/.test(p) ? 'https://www.reddit.com' + p : 'https://www.reddit.com/';
}
function normalizeShreddit(i) {
if (!i || !i.id) return null;
const b = {
id: i.id,
title: i.title || '',
permalink: redditPermalink(i.permalink),
subreddit: i.subreddit || '',
author: i.author || '',
nsfw: !!i.nsfw,
saved: false,
dir: 0,
archived: false,
};
const href = i.href || '';
const poster = i.poster || clearPreview(i.images);
// RedGifs (direct link, or a crosspost whose embed iframe points there): the viewer fetches the file with sound.
const rg = redgifsId(href) || redgifsId(i.embed || '');
if (rg) return { ...b, type: 'video', hls: null, mp4: null, poster, redgifs: rg };
// Gallery: every distinct image, full size.
if (i.gallery && i.gallery.length) {
const seen = new Set();
const images = [];
for (const u of i.gallery) {
const full = fullRedditImage(u) || u;
if (!full || seen.has(full)) continue;
seen.add(full);
images.push({ type: /\.gif$/i.test(full) ? 'gif' : 'image', src: full });
}
if (images.length) return { ...b, type: 'gallery', images };
}
// Reddit video (v.redd.it: HLS + mp4) or gif played as mp4.
if (i.hls || i.mp4) return { ...b, type: 'video', hls: i.hls || null, mp4: i.mp4 || null, poster };
// Direct media by url.
const path = href.split('?')[0].toLowerCase();
if (path.endsWith('.gifv')) return { ...b, type: 'video', hls: null, mp4: href.replace(/\.gifv(\?.*)?$/i, '.mp4$1'), poster };
if (path.endsWith('.gif')) return { ...b, type: 'gif', src: href };
if (/\.(jpe?g|png|webp|bmp)$/.test(path)) return { ...b, type: 'image', src: href };
if (i.type === 'image') {
const src = fullRedditImage(poster) || poster;
if (src) return { ...b, type: 'image', src };
}
// External link / crosspost with a usable preview picture (text posts have none).
if (i.type !== 'text' && poster) return { ...b, type: 'image', src: poster };
return null;
}
var VIEWER_URL = "https://bensindunk123.github.io/glide-for-reddit/";
var VIEWER_ORIGIN = "https://bensindunk123.github.io";
// ---- reading Reddit with the user's session + opening the viewer ----
function fetchJson(url) {
return fetch(url, { credentials: 'include', cache: 'no-store', headers: { Accept: 'application/json' } }).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
// --- HTML feed fallback (11-2026) ---
// Reddit answers the .json listings with 403 "blocked by network security" on some connections
// (some connections, logged in or not), while the site's own HTML feed (shreddit partials) works.
// loadSource tries .json first; on failure it switches to these: the same listing read from HTML,
// one page at a time, following the "more posts" partial for the next page.
var htmlMode = false; // once .json has failed, skip straight to HTML for the rest of the session
function fetchDoc(url) {
return fetch(url, { credentials: 'include', cache: 'no-store' }).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
}).then(function (t) { return new DOMParser().parseFromString(t, 'text/html'); });
}
// .json listing url -> first HTML page. Subreddit listings go straight to the 25-post partial.
function htmlFirstUrl(jsonUrl) {
var u = new URL(jsonUrl, location.origin);
var path = u.pathname.replace(/\.json$/, '/').replace(/\/\/+/g, '/');
var t = u.searchParams.get('t');
var m = /^\/r\/([^\/]+)\/(hot|new|top|best|rising)\/$/.exec(path);
if (m) return 'https://www.reddit.com/svc/shreddit/community-more-posts/' + m[2] + '/?name=' + encodeURIComponent(m[1]) + (t ? '&t=' + t.toUpperCase() : '');
u.pathname = path; u.searchParams.delete('raw_json'); u.searchParams.delete('limit'); u.searchParams.delete('after');
return u.toString();
}
function attrs(el, names) { var o = {}; names.forEach(function (n) { o[n] = el.getAttribute(n) || ''; }); return o; }
// <shreddit-post> -> plain info object for normalizeShreddit (media.js).
function shredditInfo(p) {
var a = attrs(p, ['id', 'post-title', 'permalink', 'subreddit-prefixed-name', 'author', 'post-type', 'content-href']);
var pl = p.querySelector('shreddit-player-2, shreddit-player');
var src = pl ? (pl.getAttribute('src') || '') : '';
var mp4 = null;
try {
var pk = pl && JSON.parse(pl.getAttribute('packaged-media-json') || 'null');
var perms = (pk && pk.playbackMp4s && pk.playbackMp4s.permutations) || [];
perms.forEach(function (x) { var s = x && x.source; if (s && s.url && (!mp4 || (s.bitrateBps || 0) > mp4.b)) mp4 = { u: s.url, b: s.bitrateBps || 0 }; });
} catch (e) {}
var imgSrc = function (i) { return i.getAttribute('src') || i.getAttribute('data-lazy-src') || ''; };
var gal = p.querySelector('gallery-carousel');
var rg = /redgifs\.com\/(?:ifr|watch)\/[A-Za-z0-9]+/.exec(p.innerHTML);
return {
id: a.id, title: a['post-title'], permalink: a.permalink, subreddit: a['subreddit-prefixed-name'], author: a.author,
nsfw: p.hasAttribute('nsfw'), type: a['post-type'], href: a['content-href'],
hls: /\.m3u8/i.test(src) ? src : null,
mp4: mp4 ? mp4.u : (src && !/\.m3u8/i.test(src) ? src : null),
poster: (pl && pl.getAttribute('poster')) || null,
gallery: gal ? Array.prototype.map.call(gal.querySelectorAll('img'), imgSrc).filter(Boolean) : [],
images: Array.prototype.map.call(p.querySelectorAll('img'), imgSrc).filter(Boolean),
embed: rg ? 'https://www.' + rg[0] : '',
};
}
// Only ever fetch www.reddit.com: hrefs/srcs come from page HTML ("//other.host", "https://other.host/comments/").
function redditUrl(h) {
try { var u = new URL(h, 'https://www.reddit.com'); return u.origin === 'https://www.reddit.com' ? u.toString() : null; } catch (e) { return null; }
}
function nextPartial(doc) {
var els = doc.querySelectorAll('faceplate-partial[src], [src*="after="], [src*="cursor="]');
for (var k = 0; k < els.length; k++) {
var s = els[k].getAttribute('src') || '';
if (/more-posts|\/search\//.test(s) && /[?&](after|cursor)=/.test(s)) return redditUrl(s);
}
return null;
}
// One HTML page -> { items, next }. Search pages only list links, so each hit's own page is read
// (6 at a time) to get its media.
async function htmlPage(url) {
var doc = await fetchDoc(url);
var posts = Array.prototype.filter.call(doc.querySelectorAll('shreddit-post'), function (p) { return !p.parentElement || !p.parentElement.closest('shreddit-post'); });
if (!posts.length && /\/search\//.test(url)) {
var seen = {}, links = [];
Array.prototype.forEach.call(doc.querySelectorAll('a[href*="/comments/"]'), function (el) {
var h = redditUrl((el.getAttribute('href') || '').split('?')[0]);
if (h && !seen[h]) { seen[h] = 1; links.push(h); }
});
for (var k = 0; k < links.length; k += 6) {
var docs = await Promise.all(links.slice(k, k + 6).map(function (h) { return fetchDoc(h).catch(function () { return null; }); }));
docs.forEach(function (d) { var p = d && d.querySelector('shreddit-post'); if (p) posts.push(p); });
}
}
var items = posts.map(function (p) { return normalizeShreddit(shredditInfo(p)); }).filter(Boolean);
return { items: items, next: nextPartial(doc) };
}
function getUsername() {
return fetchJson('https://www.reddit.com/api/me.json').then(function (me) {
var name = (me && me.data && me.data.name) || me.name;
if (me && me.data && me.data.modhash) cachedModhash = me.data.modhash;
if (!name) throw new Error('not_logged_in');
return name;
});
}
// Login check IN THE BACKGROUND, run once when the script loads. Lets us decide
// SYNCHRONOUSLY (on click) whether to open the viewer: if we know the user is
// logged out, we open no window (otherwise a stuck black tab). cachedName = name or null.
// We also grab the modhash here: it's the CSRF token Reddit requires for vote/save (sent as
// the X-Modhash header / uh param on those POSTs, alongside the session cookie).
var cachedName = null;
var cachedModhash = null;
var meChecked = false;
var meCheck = fetch('https://www.reddit.com/api/me.json', { credentials: 'include', headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
cachedName = (d && d.data && d.data.name) || null;
cachedModhash = (d && d.data && d.data.modhash) || null;
return cachedName;
})
.catch(function () { return null; });
meCheck.then(function () { meChecked = true; });
// --- multi-source state ---
// viewerWin : reference to the opened viewer tab (target of the postMessages).
// resolvedName : resolved username (reused by source reloads).
// currentGen : generation counter. Every source load (default OR rss-load
// from the viewer) increments it; postItems and the pagination loop check
// "gen === currentGen" before sending => we stop emitting the pages of a stale
// source as soon as another is requested (anti-mixing when switching quickly).
var viewerWin = null;
var resolvedName = null;
var resolvedModhash = null; // CSRF modhash for vote/save (resolved on launch, from me.json)
var currentGen = 0;
// Small dark toast near the button (white text, ~5 s then auto-removed). Used for the
// "not logged in" case: we warn ON the Reddit page instead of opening an empty tab.
function showToast(text) {
var t = document.createElement('div');
t.textContent = text;
t.style.cssText = 'position:fixed;bottom:64px;right:16px;z-index:2147483647;max-width:280px;background:rgba(0,0,0,.85);color:#fff;border-radius:10px;padding:10px 14px;font:600 13px -apple-system,system-ui,sans-serif;line-height:1.35;box-shadow:0 4px 16px rgba(0,0,0,.4)';
document.body.appendChild(t);
setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5000);
}
// Adds limit/raw_json + (optional) the after cursor to a base URL.
function withParams(base, after) {
var u = base + (base.indexOf('?') === -1 ? '?' : '&') + 'limit=100&raw_json=1';
if (after) u += '&after=' + encodeURIComponent(after);
return u;
}
// Normalizes a feed sort received from the viewer into a valid API key (default 'hot').
function normSort(sort) { return (sort === 'new' || sort === 'top' || sort === 'best') ? sort : 'hot'; }
// Default sort on a source's first load: Home -> best; other feeds (Popular, subreddit,
// custom feeds) -> hot; saved/upvoted/u:<name> -> null (ordered client-side, no server sort).
function defaultSort(src) {
if (!src) return null;
return src.kind === 'feed' ? 'hot' : null; // feeds (Home, Popular, subreddit) -> Hot; saved/upvoted/u: -> null
}
// Maps a source id (+ sort for feeds) -> { url(base without params), cap(max number of pages) }.
// Ids: 'saved' (default) & 'upvoted' (kind saved), 'home' (kind feed), 'feed:<path>' (multireddit; path
// starts with /, kind feed). The sort only makes sense for feeds:
// • home -> /<sort>.json (hot/new/top)
// • feed:<path> -> <path><sort>.json
// • 'top' adds &t=all (all-time top).
// (The sort/&t parameter is carried here via the base URL; withParams then adds
// limit/raw_json/after — without overwriting any &t=all already present.)
function sourceSpec(id, name, sort) {
var s = normSort(sort);
var tAll = (s === 'top') ? '?t=all' : '';
if (id === 'home') {
return { url: 'https://www.reddit.com/' + s + '.json' + tAll, cap: 5 };
}
if (id && id.indexOf('feed:') === 0) {
var path = id.slice(5); // e.g. /user/<name>/m/<feed>/
return { url: 'https://www.reddit.com' + path + s + '.json' + tAll, cap: 5 };
}
if (id && id.indexOf('search:') === 0) {
// Search results (the page's query, see detectContext): the id already carries the query string.
// Reddit's search sorts are relevance/new/top ('hot' => relevance).
return { url: 'https://www.reddit.com' + id.slice(7) + '&sort=' + (s === 'hot' ? 'relevance' : s) + (s === 'top' ? '&t=all' : ''), cap: 5 };
}
if (id === 'upvoted') {
// upvoted: a "your account" listing like saved (finite, ordered client-side, kind saved).
return { url: 'https://www.reddit.com/user/' + encodeURIComponent(name) + '/upvoted.json', cap: 50 };
}
if (id && id.indexOf('user:') === 0) {
// u/<name> (contextual button): posts SUBMITTED by this profile, ordered client-side like
// saved (kind saved: newest/oldest/shuffle, no server sort). name is not used.
return { url: 'https://www.reddit.com/user/' + encodeURIComponent(id.slice(5)) + '/submitted.json', cap: 50 };
}
// default: saved (finite, ordered client-side: no server sort)
return { url: 'https://www.reddit.com/user/' + encodeURIComponent(name) + '/saved.json', cap: 50 };
}
// Sends a batch of items to the viewer (postMessage), TAGGED by sourceId. Retries a few
// times (the page takes a moment to register its listener) — without breaking anything if it is
// already ready. Generation guard: if a more recent load has started (stale gen),
// we do not send the batch (avoids mixing two sources when switching quickly).
function postItems(win, sourceId, items, gen) {
if (!items.length || gen !== currentGen) return;
var payload = { type: 'rss-items', sourceId: sourceId, items: items };
[0, 300, 800, 1600].forEach(function (t) {
setTimeout(function () { if (gen === currentGen) { try { win.postMessage(payload, VIEWER_ORIGIN); } catch (e) {} } }, t);
});
}
// Tells the viewer that ALL pages of a source have been received (end of pagination).
// Essential for saved Oldest/Shuffle: the viewer waits for this 'rss-done' before ordering
// the whole set. Same retries/generation guard as postItems.
function postDone(win, sourceId, gen) {
if (gen !== currentGen) return;
var payload = { type: 'rss-done', sourceId: sourceId };
[0, 300, 800, 1600].forEach(function (t) {
setTimeout(function () { if (gen === currentGen) { try { win.postMessage(payload, VIEWER_ORIGIN); } catch (e) {} } }, t);
});
}
// Loads a source (saved/home/feed:<path>): maps id(+sort)->URL+cap, paginates by 'after',
// normalizes (same normalizeSaved: these are all t3 children), and streams one batch
// per page, tagged by sourceId, then 'rss-done' at the end. 'sort' is only used for
// feeds (hot/new/top). The 'gen' guard stops sending as soon as a more recent load has started
// (source change on the viewer side).
async function loadSource(id, sort, gen) {
var spec = sourceSpec(id, resolvedName, sort);
var after = null, pages = 0;
if (!htmlMode) {
try {
var first = await fetchJson(withParams(spec.url, null));
} catch (e) {
htmlMode = true; // .json blocked: read the HTML feed instead (see htmlPage)
}
}
if (htmlMode) {
var next = htmlFirstUrl(spec.url), seenIds = {};
while (next && pages < spec.cap) {
if (gen !== currentGen) return;
var pg;
try { pg = await htmlPage(next); }
catch (e) {
// The partial endpoint refused: try the subreddit's plain page once (it embeds the first posts + the next partial).
var alt = /community-more-posts\/(\w+)\/\?name=([^&]+)/.exec(next);
if (!alt || pages > 0) throw e;
pg = await htmlPage('https://www.reddit.com/r/' + alt[2] + '/' + alt[1] + '/');
}
if (gen !== currentGen) return;
var fresh = pg.items.filter(function (it) { if (seenIds[it.id]) return false; seenIds[it.id] = 1; return true; });
if (fresh.length) postItems(viewerWin, id, fresh, gen);
next = pg.next;
pages++;
}
postDone(viewerWin, id, gen);
return;
}
do {
if (gen !== currentGen) return; // another source was requested: we give up
var data = pages === 0 ? first : await fetchJson(withParams(spec.url, after));
if (gen !== currentGen) return;
var items = normalizeSaved((data && data.data && data.data.children) || []);
postItems(viewerWin, id, items, gen); // first batch => start; following ones => addItems
after = (data && data.data && data.data.after) || null;
pages++;
} while (after && pages < spec.cap);
postDone(viewerWin, id, gen); // all pages sent: the viewer can order the complete set
}
// Gets the list of the logged-in user's custom feeds (multireddits).
// Returns an array of {id:'feed:<path>', label:<display_name>} objects. Tolerant: on
// failure (or no feed), returns [] (we keep at least Saved + Home).
async function fetchFeeds() {
try {
var arr = await fetchJson('https://www.reddit.com/api/multi/mine.json?raw_json=1');
if (!Array.isArray(arr)) return [];
return arr.map(function (m) {
var d = m && m.data;
if (!d || !d.path) return null;
return { id: 'feed:' + d.path, label: d.display_name || d.name || d.path };
}).filter(Boolean);
} catch (e) {
return [];
}
}
// Sends a reply (vote/save outcome) back to the viewer tab.
function replyToViewer(payload) {
if (viewerWin) { try { viewerWin.postMessage(payload, VIEWER_ORIGIN); } catch (e) {} }
}
// Resolves the modhash (CSRF token) needed by vote/save. Uses the one grabbed at launch; if it's
// missing (e.g. the background check failed), fetches me.json once more. Tolerant: '' on failure.
function ensureModhash() {
if (resolvedModhash) return Promise.resolve(resolvedModhash);
return fetchJson('https://www.reddit.com/api/me.json')
.then(function (me) { resolvedModhash = (me && me.data && me.data.modhash) || ''; return resolvedModhash; })
.catch(function () { return ''; });
}
// POSTs a form to Reddit with the session cookie + modhash (X-Modhash header AND uh param, which
// is what the legacy vote/save endpoints expect). Returns the fetch promise.
function postForm(url, body, uh) {
return fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Modhash': uh || '' },
body: body,
});
}
// Vote on a post (id = fullname t3_xxx; dir = 1 up / 0 none / -1 down). Replies rss-vote-result
// so the viewer can confirm or revert (prevDir) its optimistic UI.
function doVote(id, dir, prevDir) {
ensureModhash().then(function (uh) {
var body = 'id=' + encodeURIComponent(id) + '&dir=' + encodeURIComponent(dir) + '&uh=' + encodeURIComponent(uh) + '&api_type=json';
return postForm('https://www.reddit.com/api/vote', body, uh);
}).then(function (r) {
replyToViewer({ type: 'rss-vote-result', id: id, dir: dir, prevDir: prevDir, ok: !!(r && r.ok) });
}).catch(function () {
replyToViewer({ type: 'rss-vote-result', id: id, dir: dir, prevDir: prevDir, ok: false });
});
}
// Save / unsave a post (id = fullname). Replies rss-save-result (the viewer reverts on failure).
function doSave(id, saved) {
ensureModhash().then(function (uh) {
var url = saved ? 'https://www.reddit.com/api/save' : 'https://www.reddit.com/api/unsave';
var body = 'id=' + encodeURIComponent(id) + '&uh=' + encodeURIComponent(uh);
return postForm(url, body, uh);
}).then(function (r) {
replyToViewer({ type: 'rss-save-result', id: id, saved: saved, ok: !!(r && r.ok) });
}).catch(function () {
replyToViewer({ type: 'rss-save-result', id: id, saved: saved, ok: false });
});
}
// Listens for the viewer's requests (different origin). rss-load (re)loads a source; rss-vote /
// rss-save perform the authed Reddit action on this origin (the viewer can't). e.source ===
// viewerWin guarantees the message really comes from OUR viewer tab.
window.addEventListener('message', function (e) {
var d = e.data;
if (!d || !d.type) return;
// Only OUR viewer tab may ask for loads/votes/saves (frames on the Reddit page cannot).
if (!viewerWin || e.source !== viewerWin || e.origin !== VIEWER_ORIGIN) return;
if (d.type === 'rss-load' && d.id) { currentGen++; loadSource(d.id, d.sort, currentGen); return; }
if (d.type === 'rss-vote' && d.id) { doVote(d.id, d.dir, d.prevDir); return; }
if (d.type === 'rss-save' && d.id) { doSave(d.id, d.saved); return; }
});
// primary (optional): source placed AT THE TOP of the dropdown and opened immediately (r/ and u/ buttons).
// {id,label,kind}; without it, we open Home by default (the launch button's behavior).
async function launch(btn, primary) {
// OPTIONAL LOGIN: we no longer require being logged in. We always open the viewer (within the tap gesture);
// logged out => public sources (Popular + the page's subreddit/profile), logged in => additionally
// the personal sources (saved/upvoted/home/feeds).
var orig = btn.textContent;
// 1) Open the viewer SYNCHRONOUSLY, within the tap gesture. We keep the reference
// (viewerWin): it is the target of all the postMessages and the expected sender of the
// rss-load requests.
// Cache-bust (?v=timestamp): forces the browser to load the LATEST viewer version on every
// launch. Without it, the cached HTML (GitHub Pages ~10 min, aggressive Safari) hides viewer
// updates (e.g. a sound fix). The origin stays the same => the postMessages still work.
var US_BUILD = '1.0.0'; // userscript version, passed to the viewer (?us=) for the version badge (cache diag)
var win = window.open(VIEWER_URL + '?v=' + Date.now() + '&us=' + US_BUILD, '_blank');
if (!win) {
if (btn && btn.id === 'rss-launch') { btn.textContent = '\u2192 Allow pop-ups, then retry'; setTimeout(function () { btn.textContent = orig; }, 3000); }
else { showToast('Allow pop-ups, then retry.'); }
return;
}
viewerWin = win;
// No "busy" state (… + disabled) on the button: the viewer already shows "Loading…", and
// above all the Reddit tab goes to the background when the viewer opens in the foreground => iOS
// suspends this script, so the restore (finally) did not run and left a frozen "…" + a stuck
// button. We leave the launch button intact and clickable (fixes the "…" stuck on return).
try {
// 2) Resolve the name WITHOUT blocking: logged in => name, logged out => null. (cachedName comes from
// the background check; if not done yet, we try now and tolerate failure.)
var name = cachedName;
if (name === null && !meChecked) { try { name = await getUsername(); } catch (e) { name = null; } }
resolvedName = name;
resolvedModhash = cachedModhash; // for vote/save (ensureModhash re-fetches if still null)
// 3) Sources depending on the login state. LOGGED IN: Home + Upvoted + Saved + personal feeds (kind
// drives the viewer's order/sort button). LOGGED OUT: PUBLIC only (Popular).
var POPULAR = { id: 'feed:/r/popular/', label: 'Popular', kind: 'feed' };
var sources, current;
if (name) {
var feeds = await fetchFeeds();
sources = [{ id: 'home', label: 'Home', kind: 'feed' }, { id: 'upvoted', label: 'Upvoted', kind: 'saved' }, { id: 'saved', label: 'Saved', kind: 'saved' }].concat(feeds);
current = 'home';
} else {
sources = [POPULAR];
current = POPULAR.id;
}
// Contextual source (page's r/ or u/): AT THE TOP + current. Public => works logged in OR NOT.
if (primary && primary.id) {
current = primary.id;
if (!sources.some(function (s) { return s.id === primary.id; })) sources = [primary].concat(sources);
}
[0, 300, 800, 1600].forEach(function (t) {
setTimeout(function () { try { win.postMessage({ type: 'rss-sources', sources: sources, current: current }, VIEWER_ORIGIN); } catch (e) {} }, t);
});
// 4) Load the current source via the generalized loader (gen guard), with its default sort.
currentGen++;
var cur = null;
for (var i = 0; i < sources.length; i++) { if (sources[i].id === current) { cur = sources[i]; break; } }
await loadSource(current, defaultSort(cur), currentGen);
} catch (e) {
// Real NETWORK error (the "logged out" case is now handled, it is no longer an error).
try { win.postMessage({ type: 'rss-error', reason: String(e.message) }, VIEWER_ORIGIN); } catch (_) {}
}
}
// Detects the current Reddit page (read ON the button CLICK, because Reddit is a SPA): /r/<sub> =>
// source of THIS subreddit (feed, sort hot/new/top); /user|u/<name> outside a multireddit (/m/) => posts
// submitted by THIS profile (ordered client-side). Returned as 'primary' to launch (placed at the top of
// the dropdown + played). Returns null elsewhere (=> Home by default). (The 'tag' field is unused.)
function detectContext() {
var p = location.pathname || '';
// Search results page (/r/<sub>/search/?q=… or /search/?q=…): play what the search found.
var q = new URLSearchParams(location.search).get('q');
var ms = p.match(/^\/r\/([A-Za-z0-9_]+)\/search\/?$/);
if (ms && q) return { tag: 's/', id: 'search:/r/' + ms[1] + '/search.json?q=' + encodeURIComponent(q) + '&restrict_sr=1&include_over_18=on', label: 'r/' + ms[1] + ': ' + q, kind: 'feed' };
if (/^\/search\/?$/.test(p) && q) return { tag: 's/', id: 'search:/search.json?q=' + encodeURIComponent(q) + '&include_over_18=on', label: 'Search: ' + q, kind: 'feed' };
var m = p.match(/^\/r\/([A-Za-z0-9_]+)(?:\/|$)/);
if (m) return { tag: 'r/', id: 'feed:/r/' + m[1] + '/', label: 'r/' + m[1], kind: 'feed' };
m = p.match(/^\/(?:user|u)\/([A-Za-z0-9_-]+)(?:\/|$)/);
if (m && p.indexOf('/m/') === -1) return { tag: 'u/', id: 'user:' + m[1], label: 'u/' + m[1], kind: 'saved' };
return null;
}
// ---- draggable launch button: a grip handle (two dot columns) + magnetic snap to the 4 corners ----
// Only the grip is a drag zone; the rest of the capsule stays clickable (launch). While dragging,
// dashed outlines mark the 4 corners and the nearest one is highlighted; releasing snaps to it.
// The chosen corner is remembered in localStorage (reddit.com origin). Pointer events => touch (iPad)
// and mouse alike; touch-action:none on the grip stops the page from scrolling during a drag.
var BTN_MARGIN = 16;
function loadCorner() { try { return localStorage.getItem('rss_btn_corner') || 'br'; } catch (e) { return 'br'; } }
function saveCorner(c) { try { localStorage.setItem('rss_btn_corner', c); } catch (e) {} }
// The two TOP corners must sit BELOW Reddit's sticky top nav bar. We measure the tallest
// top-pinned, near-full-width fixed/sticky bar; if none is found (e.g. it lives in a shadow
// root) we fall back to ~56px (new Reddit's header height). BTN_MARGIN is then added.
function topInset() {
var bar = 0;
try {
var els = document.body.querySelectorAll('*');
for (var i = 0; i < els.length && i < 4000; i++) {
var e = els[i], cs = window.getComputedStyle(e);
if (cs.position !== 'fixed' && cs.position !== 'sticky') continue;
var r = e.getBoundingClientRect();
if (r.top <= 1 && r.height > 0 && r.height < 160 && r.width >= window.innerWidth * 0.6 && r.bottom > bar) bar = r.bottom;
}
} catch (e) {}
return Math.round(bar || 56) + BTN_MARGIN;
}
function applyCorner(b, c) {
b.style.top = b.style.bottom = b.style.left = b.style.right = 'auto';
var m = BTN_MARGIN + 'px', t = topInset() + 'px';
if (c === 'tl') { b.style.top = t; b.style.left = m; }
else if (c === 'tr') { b.style.top = t; b.style.right = m; }
else if (c === 'bl') { b.style.bottom = m; b.style.left = m; }
else { b.style.bottom = m; b.style.right = m; }
}
// The grip: two columns of three white dots, with a thin divider on its left. Built via DOM
// (no innerHTML) to stay safe under Reddit's Trusted Types.
function makeGrip() {
var g = document.createElement('span');
g.setAttribute('aria-label', 'Drag to move');
g.style.cssText = 'display:inline-flex;align-self:stretch;align-items:center;gap:3px;margin-left:3px;padding:0 7px;border-left:0.5px solid rgba(255,255,255,.45);cursor:grab;touch-action:none';
for (var col = 0; col < 2; col++) {
var c = document.createElement('span');
c.style.cssText = 'display:inline-flex;flex-direction:column;gap:3px';
for (var i = 0; i < 3; i++) {
var d = document.createElement('span');
d.style.cssText = 'width:3px;height:3px;border-radius:50%;background:rgba(255,255,255,.85)';
c.appendChild(d);
}
g.appendChild(c);
}
return g;
}
function enableDrag(b, grip) {
var phs = null, ox = 0, oy = 0, started = false, moving = false;
function nearest() {
var r = b.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
return (cy < window.innerHeight / 2 ? 't' : 'b') + (cx < window.innerWidth / 2 ? 'l' : 'r');
}
function showPh() {
var r = b.getBoundingClientRect();
var w = Math.round(r.width), h = Math.round(r.height), m = BTN_MARGIN + 'px', ti = topInset() + 'px';
var pos = { tl: 'top:' + ti + ';left:' + m, tr: 'top:' + ti + ';right:' + m, bl: 'bottom:' + m + ';left:' + m, br: 'bottom:' + m + ';right:' + m };
phs = [];
['tl', 'tr', 'bl', 'br'].forEach(function (k) {
var el = document.createElement('div');
el.setAttribute('data-corner', k);
el.style.cssText = 'position:fixed;z-index:2147483646;box-sizing:border-box;width:' + w + 'px;height:' + h + 'px;border:2px dashed rgba(255,69,0,.55);border-radius:999px;background:rgba(255,69,0,.06);pointer-events:none;' + pos[k];
document.body.appendChild(el);
phs.push(el);
});
}
function highlightPh(c) {
if (!phs) return;
phs.forEach(function (el) {
var on = el.getAttribute('data-corner') === c;
el.style.borderStyle = on ? 'solid' : 'dashed';
el.style.borderColor = on ? '#FF4500' : 'rgba(255,69,0,.55)';
el.style.background = on ? 'rgba(255,69,0,.18)' : 'rgba(255,69,0,.06)';
});
}
function hidePh() {
if (!phs) return;
phs.forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); });
phs = null;
}
grip.addEventListener('click', function (e) { e.stopPropagation(); });
grip.addEventListener('pointerdown', function (e) {
e.preventDefault(); e.stopPropagation();
var r = b.getBoundingClientRect();
ox = e.clientX - r.left; oy = e.clientY - r.top;
started = true; moving = false;
grip.style.cursor = 'grabbing';
try { grip.setPointerCapture(e.pointerId); } catch (_) {}
});
grip.addEventListener('pointermove', function (e) {
if (!started) return;
if (!moving) { moving = true; showPh(); }
var r = b.getBoundingClientRect();
var x = Math.max(8, Math.min(e.clientX - ox, window.innerWidth - r.width - 8));
var y = Math.max(8, Math.min(e.clientY - oy, window.innerHeight - r.height - 8));
b.style.top = y + 'px'; b.style.left = x + 'px'; b.style.right = 'auto'; b.style.bottom = 'auto';
highlightPh(nearest());
});
function end(e) {
if (!started) return;
started = false;
grip.style.cursor = 'grab';
try { grip.releasePointerCapture(e.pointerId); } catch (_) {}
if (moving) {
var c = nearest();
applyCorner(b, c);
saveCorner(c);
// Swallow the click that the browser may fire right after the drag (so we don't launch).
b._rssDragged = true;
setTimeout(function () { b._rssDragged = false; }, 50);
}
hidePh();
moving = false;
}
grip.addEventListener('pointerup', end);
grip.addEventListener('pointercancel', end);
}
// A SINGLE launch button (draggable; default bottom-right). detectContext() is read ON CLICK:
// on a subreddit/profile page, that subreddit/profile is placed AT THE TOP of the dropdown and played
// immediately; elsewhere => Home by default. (Reddit is a SPA: we re-read the context on every click.)
function addButton() {
if (document.getElementById('rss-launch') || !document.body) return;
var b = document.createElement('button');
b.id = 'rss-launch';
b.title = 'Fullscreen player with sound for what you see here (subreddit, search, profile, Home)';
b.style.cssText = 'position:fixed;z-index:2147483647;display:inline-flex;align-items:center;justify-content:center;gap:7px;background:#FF4500;color:#fff;border:0;border-radius:999px;padding:10px 8px 10px 13px;font:600 14px -apple-system,system-ui,sans-serif;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none';
// Play icon (white) TO THE LEFT of the text. Built via DOM (createElementNS) rather than
// innerHTML: robust if Reddit enforces Trusted Types (innerHTML='...' would throw there).
var SVGNS = 'http://www.w3.org/2000/svg';
var logo = document.createElementNS(SVGNS, 'svg');
logo.setAttribute('viewBox', '0 0 24 24');
logo.setAttribute('width', '11'); logo.setAttribute('height', '11');
logo.setAttribute('fill', '#fff'); logo.setAttribute('aria-hidden', 'true');
logo.style.flex = 'none';
var lp = document.createElementNS(SVGNS, 'path');
lp.setAttribute('d', 'M7 4v16l13-8z');
logo.appendChild(lp);
var label = document.createElement('span'); label.textContent = 'Fullscreen';
var grip = makeGrip();
b.appendChild(logo); b.appendChild(label); b.appendChild(grip);
b.addEventListener('click', function () { if (b._rssDragged) return; launch(b, detectContext()); });
document.body.appendChild(b);
applyCorner(b, loadCorner());
enableDrag(b, grip);
}
addButton();
if (document.body) new MutationObserver(addButton).observe(document.body, { childList: true });
})();