☰

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

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==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 });
})();