☰

Glide for Reddit

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).

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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(/&amp;/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 });
})();