rip++

enhances the fishtank.rip chat experience and adds a live stox ticker, inline image/video/social network embeds and so much more.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         rip++
// @namespace    https://fishtank.rip
// @version      1.1.2
// @description  enhances the fishtank.rip chat experience and adds a live stox ticker, inline image/video/social network embeds and so much more.
// @author       nmhrr
// @license      MIT
// @match        https://fishtank.rip/*
// @match        https://www.fishtank.rip/*
// @match        https://fishtank.live/*
// @match        https://www.fishtank.live/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      api.fxtwitter.com
// @connect      ws.fishtank.live
// @connect      a.4cdn.org
// @connect      i.4cdn.org
// @connect      discord.com
// @connect      cdn.discordapp.com
// @connect      api.fishtank.live
// @connect      www.tiktok.com
// @connect      catbox.moe
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── Shared msgpack encoder/decoder ──
  function _mpEnc(v) {
    if (v === null || v === undefined) return [0xc0];
    if (typeof v === 'boolean') return [v ? 0xc3 : 0xc2];
    if (typeof v === 'number') return (v >= 0 && v <= 127) ? [v] : [0xcc, v & 0xff];
    if (typeof v === 'string') { const b = new TextEncoder().encode(v); return b.length < 32 ? [0xa0|b.length,...b] : [0xd9,b.length,...b]; }
    if (typeof v === 'object') { const e = Object.entries(v); const h = e.length<16?[0x80|e.length]:[0xde,e.length>>8,e.length&0xff]; return [...h,...e.flatMap(([k,vv])=>[..._mpEnc(k),..._mpEnc(vv)])]; }
    return [0xc0];
  }
  function _mpDec(buf) {
    const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf); let pos = 0;
    function rd() {
      const byte = b[pos++];
      if (byte <= 0x7f) return byte; if (byte >= 0xe0) return byte-256;
      if ((byte&0xe0)===0xa0) { const l=byte&0x1f; return new TextDecoder().decode(b.slice(pos,pos+=l)); }
      if ((byte&0xf0)===0x90) { const l=byte&0xf; return Array.from({length:l},rd); }
      if ((byte&0xf0)===0x80) { const l=byte&0xf; const o={}; for(let i=0;i<l;i++){const k=rd();o[k]=rd();} return o; }
      if (byte===0xc0) return null; if (byte===0xc2) return false; if (byte===0xc3) return true;
      if (byte===0xcc) return b[pos++];
      if (byte===0xcd) { const v=(b[pos]<<8)|b[pos+1]; pos+=2; return v; }
      if (byte===0xce||byte===0xcf) { pos+=byte===0xce?4:8; return 0; }
      if (byte===0xd0) return b[pos++]-256;
      if (byte===0xd1) { const v=((b[pos]<<8)|b[pos+1])-65536; pos+=2; return v; }
      if (byte===0xcb) { const v=new DataView(b.buffer,b.byteOffset).getFloat64(pos,false); pos+=8; return v; }
      if (byte===0xd9) { const l=b[pos++]; return new TextDecoder().decode(b.slice(pos,pos+=l)); }
      if (byte===0xda) { const l=(b[pos]<<8)|b[pos+1]; pos+=2; return new TextDecoder().decode(b.slice(pos,pos+=l)); }
      if (byte===0xdb) { const l=(b[pos]<<16)|(b[pos+1]<<8)|b[pos+2]; pos+=4; return new TextDecoder().decode(b.slice(pos,pos+=l)); }
      if (byte===0xdc) { const l=(b[pos]<<8)|b[pos+1]; pos+=2; return Array.from({length:l},rd); }
      if (byte===0xdd) { pos+=4; return []; }
      if (byte===0xde) { const l=(b[pos]<<8)|b[pos+1]; pos+=2; const o={}; for(let i=0;i<l;i++){const k=rd();o[k]=rd();} return o; }
      if (byte===0xdf) { pos+=4; return {}; }
      return null;
    }
    return rd();
  }

  // ── On fishtank.live: connect to the socket (correct origin), relay TTS via GM_setValue ──
  // fishtank.rip cannot connect directly — the WS server rejects non-fishtank.live origins.
  if (location.hostname.includes('fishtank.live')) {
    try {
      const token = sessionStorage.getItem('fishtank-token') || '';
      if (!token) return;
      GM_setValue('ft_live_token', token);

      let _relaySeq = 0;
      const _seenIds = new Set();
      let _relayRoom = '';
      const connectBytes = new Uint8Array(_mpEnc({ type:0, nsp:'/', data:{ token } }));

      function _relay() {
        const ws = new WebSocket('wss://ws.fishtank.live/socket.io/?EIO=4&transport=websocket');
        ws.binaryType = 'arraybuffer';
        ws.onopen = () => ws.send(connectBytes.buffer);
        ws.onmessage = e => {
          if (typeof e.data === 'string') { if (e.data === '2') ws.send('3'); return; }
          try {
            const pkt = _mpDec(new Uint8Array(e.data));
            if (!pkt || pkt.type !== 2 || !Array.isArray(pkt.data)) return;
            const [event, payload] = pkt.data;
            if (event === 'chat:room' && typeof payload === 'string') { _relayRoom = payload; return; }
            if (event !== 'tts:update' || !payload || typeof payload !== 'object') return;
            const { id, displayName, message, voice, cost, status, audioUrl } = payload;
            if (!['playing','played'].includes(status)) return;
            if (!displayName || !message) return;
            if (id && _seenIds.has(id)) return;
            if (id) { _seenIds.add(id); if (_seenIds.size > 300) _seenIds.clear(); }
            _relaySeq++;
            GM_setValue('ft_tts_relay', JSON.stringify({ seq: _relaySeq, displayName, message, voice: voice||'', cost: cost||0, id: id||'', room: _relayRoom, audioUrl: audioUrl||'' }));
          } catch {}
        };
        ws.onclose = () => setTimeout(_relay, 5000);
        ws.onerror = () => ws.close();
      }
      _relay();
    } catch(e) {}
    return;
  }

  // Only run on fishtank.rip (or test pages)
  const isFishtank = location.hostname.includes('fishtank.rip');
  const isTest     = !isFishtank && !!document.querySelector('#ft-test-page');
  if (!isFishtank && !isTest) return;

  // ============================================================
  // SETTINGS — persisted to localStorage
  // ============================================================
  const STORAGE_KEY = 'ft-enhancer-v2';

  function loadSettings() {
    try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } catch { return {}; }
  }
  function saveSettings(s) { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); }

  let CFG = Object.assign({
    filterBadges:  [],        // badge filenames that hide whole messages
    hideBadges:    [],        // badge filenames whose icons are hidden
    hiddenCams:    [],        // camera tile labels to hide from the cam grid
    camLayout:     'default', // 'default' | 'above-chat' | 'below-player'
    stoxLayout:    'above-chat-header', // 'hidden' | 'above-chat' | 'below-cams' | 'below-player' | 'above-chat-header'
    stoxFontSize:  11,        // px, 8–18
    stoxSpeed:     1.0,       // multiplier (higher = faster)
    twitterScale:  70,        // % of natural width (420px base)
    youtubeScale:  100,       // % of 400×225
    igScale:       100,       // % of 328px wide Instagram
    redditScale:   100,       // % of 328px wide Reddit
    hideHeader:    true,      // hide the site's sticky top nav bar
    collapseCams:  false,     // auto-click "collapse cams" on load
    showTts:       true,      // inject TTS messages from fishtank.live into chat
  }, loadSettings());

  // Admin and Moderator messages must never be filterable — strip any persisted values
  CFG.filterBadges = CFG.filterBadges.filter(f => f !== 'admin.png' && f !== 'mod.png');

  const WELCOMED_KEY = 'ft-enhancer-welcomed';

  // ============================================================
  // BADGE DEFINITIONS
  // ============================================================
  const BADGE_DEFS = [
    { file: 'admin.png',    label: 'Admin' },
    { file: 'mod.png',      label: 'Moderator' },
    { file: 'vip.png',      label: 'VIP' },
    { file: 'new.svg',      label: 'New' },
    { file: 'autistic.svg', label: 'Autistic' },
    { file: 'og.gif',       label: 'OG' },
    { file: 'faggot.svg',   label: 'Faggot' },
    { file: 'tranny.svg',   label: 'Tranny' },
    { file: 'negro.jpeg',   label: 'Nigger' },
    { file: 'troll.jpeg',   label: 'Troll' },
    { file: 'rich.gif',     label: 'Rich' },
    { file: 'india.svg',    label: 'Poojeet' },
    { file: 'blacked.jpg',  label: 'Blacked' },
    { file: 'isis.png',     label: 'ISIS' },
    { file: 'juden.jpg',    label: 'Jewish' },
    { file: 'superjew.gif', label: 'Super Jew' },
    { file: 'fbi.svg',      label: 'Fed' },
    { file: 'meow.png',     label: 'Meow' },
    { file: 'mexico.svg',  label: 'Mexican' }
  ];


  // ============================================================
  // STYLES
  // ============================================================
  GM_addStyle(`

    /* ── Links ── */
    .ft-link { color: #4fc3f7 !important; text-decoration: underline; word-break: break-all; }
    .ft-link:hover { color: #81d4fa !important; }

    /* ── Greentext ── */
    .ft-green { color: #789922; }

    /* ── Embed toggle ── */
    .ft-toggle {
      background: none; border: 1px solid #3a3a3a; border-radius: 3px;
      color: #888; cursor: pointer; font-size: 10px;
      margin-left: 4px; padding: 1px 5px; vertical-align: middle; line-height: 1.4;
    }
    .ft-toggle:hover { background: #2a2a2a; color: #ccc; }

    /* ── Embed container (inline, inside message) ── */
    .ft-embed { margin: 3px 0 3px 8px; padding-left: 8px; border-left: 2px solid #2a2a2a; }

    /* ── Outer embed (sibling AFTER .message — Vue-safe) ── */
    /* No border here — .ft-embed inside already provides the left bar */
    .ft-outer-embed {
      margin: 0 0 2px 0; padding: 0;
    }
    .ft-msg-hidden ~ .ft-outer-embed { display: none !important; }

    /* ── Images / videos ── */
    .ft-img {
      display: block; max-height: 220px; max-width: 100%;
      border-radius: 4px; cursor: zoom-in; margin-top: 2px;
    }
    .ft-img.ft-full { max-height: none; max-width: 85%; cursor: zoom-out; }
    .ft-video { display: block; max-width: 100%; max-height: 280px; border-radius: 4px; margin-top: 2px; }
    .ft-audio { display: block; width: 100%; max-width: 360px; margin-top: 2px; accent-color: #9c27b0; }
    .ft-streamable   { display: block; border: none; border-radius: 6px; width: 400px; height: 225px; max-width: 100%; margin-top: 2px; }
    .ft-yt-frame     { display: block; border: none; border-radius: 6px; width: 400px; height: 225px; max-width: 100%; margin-top: 2px; }
    .ft-ig-frame     { display: block; border: none; border-radius: 6px; width: 328px; max-width: 100%; min-height: 420px; margin-top: 2px; background: transparent; }
    .ft-reddit-frame { display: block; border: none; border-radius: 6px; width: 328px; height: 320px; max-width: 100%; margin-top: 2px; background: transparent; }

    /* ── TikTok card ── */
    .ft-tt-card {
      background: #111; border: 1px solid #222; border-radius: 8px;
      padding: 8px 12px; max-width: 320px; font-size: 12px; color: #ccc;
      margin-top: 4px; display: flex; gap: 10px; align-items: flex-start;
      word-break: break-word;
    }
    .ft-tt-card img { width: 90px; height: 120px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
    .ft-tt-card .ft-tt-meta { flex: 1; min-width: 0; }
    .ft-tt-card .ft-tt-title { color: #e7e9ea; font-weight: 600; margin-bottom: 4px; }
    .ft-tt-card .ft-tt-author { color: #888; font-size: 11px; margin-bottom: 6px; }

    /* ── Twitter ── */
    .ft-tw-card {
      background: #0f0f0f; border: 1px solid #2f3336; border-radius: 12px;
      padding: 12px 14px; font-size: 13px; margin-top: 4px; box-sizing: border-box;
    }
    .ft-tw-card .ft-tw-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 8px; }
    .ft-tw-card .ft-tw-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
    .ft-tw-card .ft-tw-names  { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
    .ft-tw-card .ft-tw-name   { font-weight: 700; color: #e7e9ea; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .ft-tw-card .ft-tw-handle { color: #71767b; font-size: 12px; }
    .ft-tw-card .ft-tw-body   { color: #e7e9ea; white-space: pre-wrap; word-break: break-word; margin-bottom: 10px; line-height: 1.4; }
    .ft-tw-card .ft-tw-images { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
    .ft-tw-card .ft-tw-images img  { max-width: 100%; border-radius: 8px; display: block; object-fit: cover; max-height: 300px; }
    .ft-tw-card .ft-tw-images video { max-width: 100%; border-radius: 8px; display: block; max-height: 300px; background: #000; }
    .ft-tw-card .ft-tw-stats  { display: flex; align-items: center; gap: 14px; color: #71767b; font-size: 12px; border-top: 1px solid #2f3336; padding-top: 8px; flex-wrap: wrap; }
    .ft-tw-card .ft-tw-stat   { display: flex; align-items: center; gap: 3px; }
    .ft-tw-card .ft-tw-date   { margin-left: auto; font-size: 11px; color: #555; white-space: nowrap; }

    /* ── 4chan ── */
    .ft-4chan-card {
      background: #1a1a1a; border: 1px solid #34a853; border-radius: 6px;
      padding: 8px 12px; max-width: 500px; font-size: 12px; margin-top: 4px; overflow: hidden;
    }
    .ft-4chan-card .ft-4chan-hdr  { color: #34a853; font-weight: 700; margin-bottom: 4px; }
    .ft-4chan-card .ft-4chan-body { color: #ccc; white-space: pre-wrap; word-break: break-word; max-height: 160px; overflow: hidden; }
    .ft-4chan-card .ft-4chan-thumb { float: left; max-height: 90px; max-width: 90px; border-radius: 3px; margin: 0 8px 4px 0; }
    .ft-4chan-card .ft-4chan-foot  { clear: both; margin-top: 6px; }

    /* ── Kiwifarms ── */
    .ft-kf-card {
      background: #121a00; border: 1px solid #3a4f00; border-radius: 6px;
      padding: 8px 12px; max-width: 420px; font-size: 12px; color: #b8cc55;
      margin-top: 4px; word-break: break-word;
    }
    .ft-kf-card b { display: block; margin-bottom: 4px; }

    /* ── Loading / error ── */
    .ft-loading { color: #666; font-size: 11px; padding: 3px 0; font-style: italic; }
    .ft-error   { color: #f55; font-size: 11px; padding: 3px 0; }

    /* ── Filter button in chat header ── */
    .ft-filter-btn {
      background: none; border: 1px solid #444; border-radius: 4px;
      color: #888; cursor: pointer;
      font-size: inherit; line-height: inherit; font-family: inherit;
      padding: 0 5px; margin-left: 4px; vertical-align: middle;
    }
    .ft-filter-btn:hover { color: #fff; border-color: #888; }

    /* ── Filter modal — matches the site's native dialog style ── */
    #ft-filter-backdrop {
      position: fixed; inset: 0; z-index: 9998;
      background: rgba(0,0,0,.55); display: none;
    }
    #ft-filter-backdrop.ft-open { display: block; }

    #ft-filter-panel {
      position: fixed; top: 50%; left: 50%;
      transform: translate(-50%, -50%);
      z-index: 9999;
      width: calc(100vw - 2rem); max-width: 32rem;
      max-height: calc(100dvh - 2rem);
      background: #111; border-radius: 8px;
      box-shadow: 0 20px 60px rgba(0,0,0,.8);
      outline: 1px solid #252525;
      display: none; flex-direction: column; overflow: hidden;
      font-size: 13px; color: #ccc; font-family: inherit;
    }
    #ft-filter-panel.ft-open { display: flex; }

    .ft-modal-hdr {
      display: flex; align-items: center;
      padding: 14px 20px; min-height: 56px; flex-shrink: 0;
      border-bottom: 1px solid #1e1e1e;
    }
    .ft-modal-hdr h3 {
      margin: 0; font-size: 16px; font-weight: 600; color: #e7e9ea;
    }
    .ft-modal-body {
      flex: 1; padding: 16px 20px; overflow-y: auto;
    }
    .ft-modal-ftr {
      display: flex; align-items: center;
      padding: 12px 20px; flex-shrink: 0;
      border-top: 1px solid #1e1e1e;
    }
    .ft-fp-close {
      background: none; border: 1px solid #333; border-radius: 6px;
      color: #ccc; cursor: pointer; font-size: 13px;
      padding: 5px 14px; font-family: inherit;
    }
    .ft-fp-close:hover { background: #1e1e1e; color: #fff; border-color: #555; }
    .ft-fp-title {
      font-size: 10px; font-weight: 700; letter-spacing: .06em;
      color: #555; text-transform: uppercase; margin: 14px 0 6px;
    }
    .ft-fp-title:first-child { margin-top: 0; }
    .ft-fp-label {
      display: flex; align-items: center; gap: 7px;
      padding: 3px 0; cursor: pointer; user-select: none;
    }
    .ft-fp-label:hover { color: #fff; }
    .ft-fp-label input { cursor: pointer; accent-color: #4fc3f7; }
    .ft-fp-sep { border: none; border-top: 1px solid #1e1e1e; margin: 12px 0; }


    /* Hidden messages */
    .ft-msg-hidden { display: none !important; }

    /* ── Deleted / banned messages ── */
    .ft-banned-notice { color: #e53935 !important; font-weight: 700; font-size: 11px; margin-left: 4px; }
    .ft-msg-banned .cursor-pointer { color: #e53935 !important; }
    .ft-msg-banned { opacity: 0.85; }

    /* ── Discord invite card ── */
    .ft-dc-card {
      background: #1e1f22; border: 1px solid #2b2d31; border-radius: 8px;
      padding: 10px 14px; max-width: 380px; font-size: 12px; color: #dbdee1;
      margin-top: 4px; display: flex; align-items: center; gap: 10px;
      font-family: inherit;
    }
    .ft-dc-card .ft-dc-name { font-weight: 700; font-size: 13px; }
    .ft-dc-card .ft-dc-sub  { color: #949ba4; font-size: 11px; margin-top: 2px; }
    .ft-dc-card img.ft-dc-icon { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }

    /* ── Font inheritance for all injected elements ── */
    #ft-filter-panel, .ft-tw-card, .ft-4chan-card, .ft-kf-card,
    .ft-dc-card, .ft-loading, .ft-error, .ft-embed {
      font-family: inherit;
    }


    /* ── Cam labels ── */
    .ft-cam-label-small {
      font-size: 9px !important;
      padding: 1px 4px !important;
      line-height: 1.3 !important;
    }

    /* ── Cam name "tune" button (appears inline in chat messages) ── */
    .ft-tune-btn {
      background: none; border: 1px solid #2a4a5a; border-radius: 3px;
      color: #4fc3f7; cursor: pointer; font-size: 10px;
      margin-left: 3px; padding: 1px 5px; vertical-align: middle; line-height: 1.4;
      font-family: inherit;
    }
    .ft-tune-btn:hover { background: #0d2230; border-color: #4fc3f7; color: #81d4fa; }

    /* ── Hidden cam tiles (toggled via filter panel) ── */
    .ft-cam-tile-hidden { display: none !important; }

    /* ── Cam strip — scrollable mode ── */
    .ft-cam-scroll {
      overflow-x: auto !important; overflow-y: hidden;
      scrollbar-width: thin; scrollbar-color: #333 transparent;
    }
    .ft-cam-scroll::-webkit-scrollbar { height: 4px; }
    .ft-cam-scroll::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
    .ft-cam-scroll-inner { flex-wrap: nowrap !important; min-width: max-content; }


    /* ── Stox ticker ── */
    #ft-stox-ticker {
      width: 100%; overflow: hidden; background: #080808;
      border-top: 1px solid #1a1a1a; border-bottom: 1px solid #1a1a1a;
      padding: 5px 0; font-size: 11px; font-family: inherit;
      white-space: nowrap; user-select: none; z-index: 100;
      cursor: default;
    }
    .ft-stox-inner {
      display: inline-flex; align-items: center;
      animation: ft-stox-scroll 60s linear infinite;
    }
    .ft-stox-inner:hover { animation-play-state: paused; }
    @keyframes ft-stox-scroll {
      from { transform: translateX(0); }
      to   { transform: translateX(-50%); }
    }
    .ft-stox-track  { display: inline-flex; align-items: center; padding-right: 40px; }
    .ft-stox-item   { display: inline-flex; align-items: center; gap: 4px; padding: 0 10px; }
    .ft-stox-name   { font-weight: 700; color: #e7e9ea; letter-spacing: .03em; }
    .ft-stox-price  { color: #aaa; }
    .ft-stox-chg    { font-size: 10px; }
    .ft-stox-sep    { color: #2a2a2a; padding: 0 4px; }
    .ft-stox-loading { color: #444; padding: 0 16px; font-style: italic; }

    /* ── Filter panel range sliders ── */
    .ft-fp-range {
      display: flex; align-items: center; gap: 8px;
      padding: 3px 0; user-select: none;
    }
    .ft-fp-range span:first-child { flex: 0 0 90px; font-size: 12px; }
    .ft-fp-range input[type=range] {
      flex: 1; cursor: pointer; accent-color: #4fc3f7; height: 4px;
    }
    .ft-fp-range-val {
      color: #888; font-size: 11px; min-width: 36px; text-align: right; font-variant-numeric: tabular-nums;
    }

    /* ── Header visibility ── */
    .ft-hide-header header[data-slot="root"],
    .ft-hide-header header,
    .ft-hide-header nav[class*="top-0"],
    .ft-hide-header [class*="sticky"][class*="top-0"]:not(#ft-stox-ticker):not([id^="ft-"]) { display: none !important; }
    .ft-hide-header { --ui-header-height: 0px !important; }

    /* ── Catbox upload modal ── */
    #ft-catbox-backdrop { position: fixed; inset: 0; z-index: 9998; background: rgba(0,0,0,.6); display: none; }
    #ft-catbox-backdrop.ft-open { display: block; }
    #ft-catbox-panel {
      position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
      z-index: 9999; width: calc(100vw - 2rem); max-width: 22rem;
      background: #111; border-radius: 8px; outline: 1px solid #252525;
      box-shadow: 0 20px 60px rgba(0,0,0,.8);
      display: none; flex-direction: column; overflow: hidden;
      font-size: 13px; color: #ccc; font-family: inherit;
    }
    #ft-catbox-panel.ft-open { display: flex; }
    .ft-cb-status { font-size: 12px; color: #888; padding: 0 20px 10px; min-height: 18px; word-break: break-all; }
    .ft-cb-result { font-size: 12px; color: #4fc3f7; padding: 0 20px 10px; word-break: break-all; cursor: pointer; }
    .ft-cb-input  {
      background: #1a1a1a; border: 1px solid #333; border-radius: 6px;
      color: #e7e9ea; font-size: 12px; font-family: inherit;
      padding: 6px 10px; width: 100%; box-sizing: border-box; outline: none;
    }
    .ft-cb-input:focus { border-color: #4fc3f7; }
    .ft-cb-btn {
      background: #1a1a1a; border: 1px solid #333; border-radius: 6px;
      color: #ccc; cursor: pointer; font-size: 12px; font-family: inherit;
      padding: 6px 14px; white-space: nowrap;
    }
    .ft-cb-btn:hover { background: #252525; border-color: #555; color: #fff; }
    .ft-cb-btn.primary { border-color: #4fc3f7; color: #4fc3f7; }
    .ft-cb-btn.primary:hover { background: #0d2230; }

    /* ── Filter panel radio labels ── */
    .ft-fp-radio {
      display: flex; align-items: center; gap: 7px;
      padding: 2px 0; cursor: pointer; user-select: none;
    }
    .ft-fp-radio:hover { color: #fff; }
    .ft-fp-radio input { cursor: pointer; accent-color: #4fc3f7; }

    /* ── Badge icons in filter panel ── */
    .ft-fp-badge-icon {
      width: 16px; height: 16px; object-fit: contain;
      vertical-align: middle; margin: 0 4px 1px 2px;
      border-radius: 2px; flex-shrink: 0;
    }

    /* ── Lock message font size — prevents site :has() rules from shrinking messages when an embed is present ── */
    #message-list > .message { font-size: 0.875rem !important; line-height: 1.5rem !important; }

    /* ── TTS messages injected from fishtank.live ── */
    .ft-tts-msg { border-left: 2px solid #9c27b0 !important; padding-left: 6px !important; margin: 1px 0 !important; }
    .ft-tts-badge {
      display: inline-block; background: #4a148c; color: #e1bee7;
      font-size: 9px; font-weight: 700; border-radius: 3px;
      padding: 0 4px; margin-right: 4px; vertical-align: middle;
      letter-spacing: .04em;
    }
    .ft-tts-name  { color: #ce93d8; font-weight: 600; }
    .ft-tts-cost  { color: #888; font-size: 10px; margin-left: 4px; }
    .ft-tts-text  { color: #e0e0e0; }

    /* ── Golden TTS (mints / jet) ── */
    .ft-tts-msg.ft-tts-gold {
      border-left: 2px solid #FFD700 !important;
      background: linear-gradient(90deg, rgba(255,215,0,0.10) 0%, transparent 55%) !important;
    }
    .ft-tts-msg.ft-tts-gold .ft-tts-badge {
      background: linear-gradient(135deg, #b8860b, #FFD700, #b8860b);
      color: #1a1000; text-shadow: none;
    }
    .ft-tts-msg.ft-tts-gold .ft-tts-name {
      background: linear-gradient(90deg, #FFD700, #FFA500, #FFD700);
      -webkit-background-clip: text; -webkit-text-fill-color: transparent;
      background-clip: text;
    }

    /* ── Save button in filter panel footer ── */
    .ft-fp-save {
      background: none; border: 1px solid #4fc3f7; border-radius: 6px;
      color: #4fc3f7; cursor: pointer; font-size: 13px;
      padding: 5px 14px; font-family: inherit; margin-left: auto;
    }
    .ft-fp-save:hover { background: #0d2230; }
  `);

  // Dynamic badge-icon-hide stylesheet
  const badgeHideStyle = document.createElement('style');
  document.head.appendChild(badgeHideStyle);

  function applyBadgeHideCSS() {
    badgeHideStyle.textContent = CFG.hideBadges
      .map(f => `img.chat-icon[src*="${f}"] { display: none !important; }`)
      .join('\n');
  }
  applyBadgeHideCSS();

  // Dynamic stylesheet for user-adjustable embed sizes
  const embedScaleStyle = document.createElement('style');
  document.head.appendChild(embedScaleStyle);
  function applyEmbedScales() {
    const tw = (CFG.twitterScale  ?? 70)  / 100;
    const yt = (CFG.youtubeScale  ?? 100) / 100;
    const ig = (CFG.igScale       ?? 100) / 100;
    const rd = (CFG.redditScale   ?? 100) / 100;
    embedScaleStyle.textContent = [
      `.ft-tw-scale-wrap { max-width:${Math.round(420 * tw)}px !important; }`,
      `.ft-yt-frame      { width:${Math.round(400 * yt)}px !important; height:${Math.round(225 * yt)}px !important; }`,
      `.ft-ig-frame      { width:${Math.round(328 * ig)}px !important; min-height:${Math.round(420 * ig)}px !important; }`,
      `.ft-reddit-frame  { width:${Math.round(328 * rd)}px !important; height:${Math.round(320 * rd)}px !important; }`,
    ].join('\n');
  }
  applyEmbedScales();

  // Apply stox font-size live (speed is applied per-render in renderStoxTicker)
  function applyStoxStyle() {
    stoxTicker.style.fontSize = (CFG.stoxFontSize ?? 11) + 'px';
  }

  // ============================================================
  // FILTER PANEL
  // ============================================================
  function closeFilterPanel() {
    document.getElementById('ft-filter-panel')?.classList.remove('ft-open');
    document.getElementById('ft-filter-backdrop')?.classList.remove('ft-open');
  }

  function buildFilterPanel() {
    if (document.getElementById('ft-filter-panel')) return;

    // Backdrop — click outside to close
    const backdrop = document.createElement('div');
    backdrop.id = 'ft-filter-backdrop';
    backdrop.onclick = closeFilterPanel;
    document.body.appendChild(backdrop);

    const panel = document.createElement('div');
    panel.id = 'ft-filter-panel';
    panel.setAttribute('role', 'dialog');
    panel.setAttribute('aria-modal', 'true');
    panel.innerHTML = `
      <div class="ft-modal-hdr">
        <h3>RIP++ Settings</h3>
      </div>

      <div class="ft-modal-body">
        <div class="ft-fp-title">Hide messages sent by users with selected badges</div>
        ${BADGE_DEFS.filter(b => b.file !== 'admin.png' && b.file !== 'mod.png').map(b => `
          <label class="ft-fp-label">
            <input type="checkbox" data-filter-badge="${b.file}"
              ${CFG.filterBadges.includes(b.file) ? 'checked' : ''}>
            <img src="/assets/chat/${b.file}" class="ft-fp-badge-icon" alt="">
            ${b.label}
          </label>`).join('')}

        <hr class="ft-fp-sep">

        <div class="ft-fp-title">Hide badge icons</div>
        ${BADGE_DEFS.map(b => `
          <label class="ft-fp-label">
            <input type="checkbox" data-hide-badge="${b.file}"
              ${CFG.hideBadges.includes(b.file) ? 'checked' : ''}>
            <img src="/assets/chat/${b.file}" class="ft-fp-badge-icon" alt="">
            ${b.label}
          </label>`).join('')}

        <hr class="ft-fp-sep">

        <div class="ft-fp-title">Hide camera tiles</div>
        <div id="ft-cam-hide-list"></div>

        <hr class="ft-fp-sep">

        <div class="ft-fp-title">Stox ticker position</div>
        ${[
          ['hidden',            'Hidden'],
          ['above-chat',        'Above cam strip'],
          ['below-cams',        'Below cam strip'],
          ['above-chat-header', 'Between cams and chatbox'],
          ['below-player',      'Below player'],
        ].map(([v, label]) => `
          <label class="ft-fp-radio">
            <input type="radio" name="ft-stox-layout" value="${v}"
              ${(CFG.stoxLayout || 'hidden') === v ? 'checked' : ''}>
            ${label}
          </label>`).join('')}

        <hr class="ft-fp-sep">

        <div class="ft-fp-title">Stox ticker tweaks</div>
        <label class="ft-fp-range">
          <span>Font size</span>
          <input type="range" min="8" max="18" step="1" value="${CFG.stoxFontSize ?? 11}" data-ft-range="stoxFontSize">
          <span class="ft-fp-range-val">${CFG.stoxFontSize ?? 11}px</span>
        </label>
        <label class="ft-fp-range">
          <span>Scroll speed</span>
          <input type="range" min="0.25" max="4" step="0.25" value="${CFG.stoxSpeed ?? 1}" data-ft-range="stoxSpeed">
          <span class="ft-fp-range-val">${CFG.stoxSpeed ?? 1}×</span>
        </label>

        <hr class="ft-fp-sep">

        <div class="ft-fp-title">Embed sizes</div>
        ${[
          ['twitterScale', 'Twitter',   CFG.twitterScale ?? 70,  '%'],
          ['youtubeScale', 'YouTube',   CFG.youtubeScale ?? 100, '%'],
          ['igScale',      'Instagram', CFG.igScale      ?? 100, '%'],
          ['redditScale',  'Reddit',    CFG.redditScale  ?? 100, '%'],
        ].map(([key, label, val, unit]) => `
          <label class="ft-fp-range">
            <span>${label}</span>
            <input type="range" min="30" max="150" step="5" value="${val}" data-ft-range="${key}">
            <span class="ft-fp-range-val">${val}${unit}</span>
          </label>`).join('')}

        <hr class="ft-fp-sep">

        <div class="ft-fp-title">Interface</div>
        <label class="ft-fp-label">
          <input type="checkbox" data-ft-toggle="hideHeader" ${CFG.hideHeader ? 'checked' : ''}>
          Hide site navigation bar
        </label>
        <label class="ft-fp-label">
          <input type="checkbox" data-ft-toggle="collapseCams" ${CFG.collapseCams ? 'checked' : ''}>
          Auto-collapse cams on load
        </label>
        <label class="ft-fp-label">
          <input type="checkbox" data-ft-toggle="showTts" ${CFG.showTts !== false ? 'checked' : ''}>
          Show TTS messages in chat
        </label>
        <div style="font-size:10px;color:#555;margin-top:4px;padding-left:20px">
          Works automatically. Visit fishtank.live once if no TTS appears (saves auth token).
        </div>
      </div>

      <div class="ft-modal-ftr" style="gap:8px">
        <button class="ft-fp-close">Close</button>
        <button class="ft-fp-save">Save settings</button>
      </div>
    `;

    panel.querySelector('.ft-fp-close').onclick = closeFilterPanel;

    panel.querySelector('.ft-fp-save').onclick = () => {
      saveSettings(CFG);
      const btn = panel.querySelector('.ft-fp-save');
      const orig = btn.textContent;
      btn.textContent = 'Saved ✓';
      btn.style.borderColor = '#4caf50';
      btn.style.color = '#4caf50';
      setTimeout(() => { btn.textContent = orig; btn.style.borderColor = ''; btn.style.color = ''; }, 1500);
    };

    // Live-update range sliders (fires on every drag tick)
    panel.addEventListener('input', e => {
      const el = e.target;
      if (!el.dataset.ftRange) return;
      const key = el.dataset.ftRange;
      const num = parseFloat(el.value);
      CFG[key] = num;
      // Update displayed value label
      const valEl = el.closest('label')?.querySelector('.ft-fp-range-val');
      if (valEl) {
        if (key === 'stoxFontSize') valEl.textContent = num + 'px';
        else if (key === 'stoxSpeed') valEl.textContent = num + '×';
        else valEl.textContent = num + '%';
      }
      saveSettings(CFG);
      if (key.endsWith('Scale')) applyEmbedScales();
      if (key.startsWith('stox')) { applyStoxStyle(); renderStoxTicker(); }
    });

    panel.addEventListener('change', e => {
      const el = e.target;

      // Layout radios
      if (el.name === 'ft-cam-layout') {
        CFG.camLayout = el.value; saveSettings(CFG); deferredApplyCamLayout(); return;
      }
      if (el.name === 'ft-stox-layout') {
        CFG.stoxLayout = el.value; saveSettings(CFG); placeStoxTicker(); return;
      }

      // Interface toggles
      if (el.dataset.ftToggle === 'hideHeader') {
        CFG.hideHeader = el.checked; saveSettings(CFG); applyHeaderVisibility(); return;
      }
      if (el.dataset.ftToggle === 'collapseCams') {
        CFG.collapseCams = el.checked; saveSettings(CFG);
        if (el.checked) applyCamCollapse();
        return;
      }
      if (el.dataset.ftToggle === 'showTts') {
        CFG.showTts = el.checked; saveSettings(CFG); return;
      }
      if (el.dataset.hideCam) {
        const cam = el.dataset.hideCam;
        if (el.checked) { if (!CFG.hiddenCams.includes(cam)) CFG.hiddenCams.push(cam); }
        else            { CFG.hiddenCams = CFG.hiddenCams.filter(c => c !== cam); }
        saveSettings(CFG);
        hideCamTiles();
        return;
      }

      const val = el.dataset.filterBadge || el.dataset.hideBadge;
      if (!val) return;
      const arr = el.dataset.filterBadge ? 'filterBadges' : 'hideBadges';

      if (el.checked) { if (!CFG[arr].includes(val)) CFG[arr].push(val); }
      else             { CFG[arr] = CFG[arr].filter(v => v !== val); }

      saveSettings(CFG);
      if (el.dataset.hideBadge) applyBadgeHideCSS();
      refilterAll();
    });

    document.body.appendChild(panel);

    // Escape key closes modal
    document.addEventListener('keydown', e => {
      if (e.key === 'Escape') closeFilterPanel();
    });
  }

  function refreshCamHideSection() {
    const container = document.getElementById('ft-cam-hide-list');
    if (!container) return;
    // Merge any new cams into CAM_NAMES first, then list all known cams
    syncCamNamesFromDom();
    container.innerHTML = '';
    CAM_NAMES.forEach(cam => {
      const label = document.createElement('label');
      label.className = 'ft-fp-label';
      const cb = document.createElement('input');
      cb.type = 'checkbox';
      cb.dataset.hideCam = cam;
      cb.checked = CFG.hiddenCams.includes(cam);
      label.appendChild(cb);
      label.appendChild(document.createTextNode(' ' + cam));
      container.appendChild(label);
    });
  }

  function toggleFilterPanel() {
    buildFilterPanel();
    const open = document.getElementById('ft-filter-panel').classList.toggle('ft-open');
    document.getElementById('ft-filter-backdrop').classList.toggle('ft-open', open);
    if (open) refreshCamHideSection();
  }

  // Inject a plain chat-style message (no badge, no username) into the message list.
  // Used for the first-run welcome notice — does NOT scroll into view so it sits
  // naturally in the backlog rather than forcing itself to the bottom.
  function injectWelcomeMessage(list) {
    const msgEl = document.createElement('div');
    msgEl.className = 'message text-sm/6 pl-1 pb-[1px]';

    const textSpan = document.createElement('span');
    textSpan.className = 'ml-0 wrap-break-word hyphens-auto';
    textSpan.style.color = '#888';
    textSpan.style.fontStyle = 'italic';

    const WELCOME_TEXT =
      'rip++ is active. Click the "++" button in the chat header to configure settings and filters.';
    textSpan.textContent = WELCOME_TEXT;

    msgEl.appendChild(textSpan);
    list.appendChild(msgEl);
    // Mark as seen so this only fires once across all sessions
    localStorage.setItem(WELCOMED_KEY, '1');
  }

  // ============================================================
  // CATBOX UPLOADER
  // ============================================================
  function catboxDoUpload(formData, statusEl, resultEl) {
    statusEl.textContent = 'Uploading…';
    resultEl.textContent = '';
    GM_xmlhttpRequest({
      method: 'POST',
      url: 'https://catbox.moe/user/api.php',
      data: formData,
      onload(res) {
        const url = res.responseText.trim();
        if (url.startsWith('https://files.catbox.moe/') || url.startsWith('http://files.catbox.moe/')) {
          statusEl.textContent = 'Done! Click URL to copy:';
          resultEl.textContent = url;
          resultEl.onclick = () => { navigator.clipboard?.writeText(url); resultEl.textContent = url + '  ✓ copied'; };
        } else {
          statusEl.textContent = '⚠ Upload failed: ' + url.slice(0, 120);
        }
      },
      onerror() { statusEl.textContent = '⚠ Network error during upload'; },
    });
  }

  function buildCatboxModal() {
    if (document.getElementById('ft-catbox-panel')) return;

    const backdrop = document.createElement('div');
    backdrop.id = 'ft-catbox-backdrop';
    backdrop.onclick = closeCatboxModal;
    document.body.appendChild(backdrop);

    const panel = document.createElement('div');
    panel.id = 'ft-catbox-panel';
    panel.setAttribute('role', 'dialog');
    panel.setAttribute('aria-modal', 'true');
    panel.innerHTML = `
      <div class="ft-modal-hdr">
        <h3>Upload to catbox.moe</h3>
      </div>
      <div class="ft-modal-body" style="display:flex;flex-direction:column;gap:10px">
        <div style="display:flex;gap:6px">
          <input class="ft-cb-input" id="ft-cb-url" type="url" placeholder="Paste image URL…">
          <button class="ft-cb-btn primary" id="ft-cb-url-btn">Upload URL</button>
        </div>
        <div style="display:flex;gap:6px;align-items:center">
          <button class="ft-cb-btn" id="ft-cb-file-btn">Choose File…</button>
          <input type="file" id="ft-cb-file-input" accept="image/*,video/*" style="display:none">
          <span id="ft-cb-file-name" style="font-size:11px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1"></span>
        </div>
        <button class="ft-cb-btn" id="ft-cb-clip-btn">📋 Paste image from clipboard</button>
        <div class="ft-cb-status" id="ft-cb-status"></div>
        <div class="ft-cb-result" id="ft-cb-result" title="Click to copy"></div>
      </div>
      <div class="ft-modal-ftr">
        <button class="ft-fp-close" id="ft-catbox-close">Close</button>
      </div>
    `;
    document.body.appendChild(panel);

    const statusEl = panel.querySelector('#ft-cb-status');
    const resultEl = panel.querySelector('#ft-cb-result');

    panel.querySelector('#ft-catbox-close').onclick = closeCatboxModal;
    document.addEventListener('keydown', e => { if (e.key === 'Escape') closeCatboxModal(); });

    // Upload by URL
    panel.querySelector('#ft-cb-url-btn').onclick = () => {
      const url = panel.querySelector('#ft-cb-url').value.trim();
      if (!url) { statusEl.textContent = 'Please enter a URL.'; return; }
      const fd = new FormData();
      fd.append('reqtype', 'urlupload');
      fd.append('url', url);
      catboxDoUpload(fd, statusEl, resultEl);
    };

    // Upload file from disk
    const fileInput = panel.querySelector('#ft-cb-file-input');
    panel.querySelector('#ft-cb-file-btn').onclick = () => fileInput.click();
    fileInput.onchange = () => {
      const file = fileInput.files[0];
      if (!file) return;
      panel.querySelector('#ft-cb-file-name').textContent = file.name;
      const fd = new FormData();
      fd.append('reqtype', 'fileupload');
      fd.append('fileToUpload', file);
      catboxDoUpload(fd, statusEl, resultEl);
    };

    // Paste from clipboard
    panel.querySelector('#ft-cb-clip-btn').onclick = async () => {
      try {
        const items = await navigator.clipboard.read();
        for (const item of items) {
          const imgType = item.types.find(t => t.startsWith('image/'));
          if (imgType) {
            const blob = await item.getType(imgType);
            const ext  = imgType.split('/')[1] || 'png';
            const file = new File([blob], `paste.${ext}`, { type: imgType });
            const fd = new FormData();
            fd.append('reqtype', 'fileupload');
            fd.append('fileToUpload', file);
            catboxDoUpload(fd, statusEl, resultEl);
            return;
          }
        }
        statusEl.textContent = '⚠ No image found in clipboard.';
      } catch { statusEl.textContent = '⚠ Clipboard access denied. Try uploading a file instead.'; }
    };
  }

  function openCatboxModal()  {
    buildCatboxModal();
    document.getElementById('ft-catbox-panel')?.classList.add('ft-open');
    document.getElementById('ft-catbox-backdrop')?.classList.add('ft-open');
    // Reset state
    const s = document.getElementById('ft-cb-status'); if (s) s.textContent = '';
    const r = document.getElementById('ft-cb-result'); if (r) r.textContent = '';
    const u = document.getElementById('ft-cb-url');    if (u) u.value = '';
    const n = document.getElementById('ft-cb-file-name'); if (n) n.textContent = '';
  }
  function closeCatboxModal() {
    document.getElementById('ft-catbox-panel')?.classList.remove('ft-open');
    document.getElementById('ft-catbox-backdrop')?.classList.remove('ft-open');
  }

  // Inject "filters" button into every chat header we find.
  // Target: the same row as "• sfx off • settings / useful stuff • expand cams"
  // which lives in a small-text element inside the chat header div.
  const injectedHeaders = new WeakSet();
  function injectFilterButton(chatHeader) {
    if (injectedHeaders.has(chatHeader)) return;
    injectedHeaders.add(chatHeader);

    const btn = document.createElement('button');
    btn.className = 'ft-filter-btn';
    btn.textContent = '++';
    btn.title = 'rip++ settings & filters';
    btn.onclick = toggleFilterPanel;

    // Walk all descendants looking for the row that contains "sfx" or "settings"
    // text — that's the inline links bar. Fall back to any .text-sm/.text-gray-400.
    let target = null;
    for (const el of chatHeader.querySelectorAll('*')) {
      if (el.children.length === 0) continue; // skip pure leaf nodes
      const t = el.textContent;
      if (t.includes('sfx') || t.includes('settings') || t.includes('expand cams')) {
        // Prefer the most specific (deepest) match
        target = el;
      }
    }
    if (!target) target = chatHeader.querySelector('.text-sm, .text-gray-400');
    if (target) target.appendChild(btn);
    else chatHeader.appendChild(btn);

    // Catbox upload button — same style as ++, placed right after it
    const catboxBtn = document.createElement('button');
    catboxBtn.className = 'ft-filter-btn';
    catboxBtn.textContent = 'image upload';
    catboxBtn.title = 'Upload image to catbox.moe';
    catboxBtn.onclick = openCatboxModal;
    if (target) target.appendChild(catboxBtn);
    else chatHeader.appendChild(catboxBtn);

  }

  // ============================================================
  // MESSAGE FILTERING
  // ============================================================

  function badgeFileFromSrc(src) {
    // Works for absolute or relative src, with or without query string
    return (src || '').split('/').pop().split('?')[0];
  }

  function shouldHideMessage(msgEl) {
    // Badge filter
    if (CFG.filterBadges.length) {
      for (const img of msgEl.querySelectorAll('img.chat-icon')) {
        if (CFG.filterBadges.includes(badgeFileFromSrc(img.getAttribute('src')))) return true;
      }
    }

    return false;
  }

  function applyFilterToMessage(msgEl) {
    const hide = shouldHideMessage(msgEl);
    msgEl.classList.toggle('ft-msg-hidden', hide);
    // Also hide/show any outer embed siblings we placed after this message
    let sib = msgEl.nextElementSibling;
    while (sib?.classList?.contains('ft-outer-embed')) {
      sib.style.display = hide ? 'none' : '';
      sib = sib.nextElementSibling;
    }
  }

  function refilterAll() {
    document.querySelectorAll('#message-list .message').forEach(applyFilterToMessage);
  }

  // ============================================================
  // GREENTEXT + URL LINKIFICATION
  // ============================================================
  // Also captures bare discord.gg/CODE links (no https:// prefix)
  const URL_RE = /\bhttps?:\/\/[^\s<>"'`\])\u2019\u201d]+|\bdiscord\.gg\/[a-zA-Z0-9-]+/g;

  const IMG_EXT = /\.(jpe?g|png|gif|webp|bmp|avif)(\?[^?\s]*)?$/i;
  const VID_EXT = /\.(mp4|webm|mov|ogv)(\?[^?\s]*)?$/i;
  const AUD_EXT = /\.(mp3|ogg|oga|wav|flac|aac|opus|m4a)(\?[^?\s]*)?$/i;

  const IMG_HOSTS = [
    /^https?:\/\/files\.catbox\.moe\//,
    /^https?:\/\/i\.imgur\.com\//,
    /^https?:\/\/i\.ibb\.co\//,
    /^https?:\/\/i\.postimg\.cc\//,
    /^https?:\/\/cdn\.discordapp\.com\/attachments\//,
    /^https?:\/\/media\.discordapp\.net\/attachments\//,
    /^https?:\/\/pbs\.twimg\.com\/media\//,
    /^https?:\/\/i\.gyazo\.com\//,
    /^https?:\/\/i\.redd\.it\//,
    /^https?:\/\/image\.prntscr\.com\//,
  ];
  const VID_HOSTS = [
    /^https?:\/\/files\.catbox\.moe\/[^?]+\.(mp4|webm|mov)$/i,
    /^https?:\/\/v\.redd\.it\//,
  ];

  function urlType(url) {
    if (AUD_EXT.test(url)) return 'audio';
    if (VID_EXT.test(url) || VID_HOSTS.some(r => r.test(url))) return 'video';
    try {
      if (/streamable\.com\/[a-z0-9]+/i.test(url) && !/\.\w{2,4}$/.test(new URL(url).pathname)) return 'streamable';
    } catch { /* ignore */ }
    if (IMG_EXT.test(url) || IMG_HOSTS.some(r => r.test(url))) return 'image';
    if (/(?:twitter\.com|x\.com)\/.+\/status\/\d+/.test(url)) return 'twitter';
    if (/boards\.(?:4chan|4channel)\.org\/[^/]+\/thread\/\d+/.test(url)) return '4chan';
    if (/kiwifarms\.(net|st)/.test(url)) return 'kiwifarms';
    if (/discord(?:\.gg|\.com\/invite)\/[a-zA-Z0-9-]+/.test(url)) return 'discord-invite';
    if (/(?:youtube\.com\/(?:watch[?&]v=|shorts\/|live\/)|youtu\.be\/)[\w-]{11}/.test(url)) return 'youtube';
    if (/tiktok\.com\/@[^/]+\/video\/\d+|vm\.tiktok\.com\/\w+|tiktok\.com\/t\/\w+/.test(url)) return 'tiktok';
    if (/instagram\.com\/(?:p|reel)\/([\w-]+)/.test(url)) return 'instagram';
    if (/reddit\.com\/r\/[^/]+\/comments\/[a-z0-9]+/.test(url)) return 'reddit';
    if (/^https?:\/\/(?:www\.)?imgur\.com\/(?!a\/|gallery\/)([a-zA-Z0-9]+)\b/.test(url)) return 'imgur';
    return null;
  }

  // Build a text node OR a green span for a plain-text segment
  function textOrGreen(segment, forceGreen) {
    const green = forceGreen || /^\s*>/.test(segment);
    if (!green) return document.createTextNode(segment);
    const s = document.createElement('span');
    s.className = 'ft-green';
    s.textContent = segment;
    return s;
  }

  function linkifyTextNode(node) {
    const text = node.textContent;
    URL_RE.lastIndex = 0;
    const hits = [...text.matchAll(URL_RE)];

    // Is this text node "greentext"? (starts with >, allowing leading whitespace)
    const isGreen = /^\s*>/.test(text);

    if (!hits.length && !isGreen) return; // nothing to do

    const frag = document.createDocumentFragment();
    let cursor = 0;

    for (const hit of hits) {
      const url = hit[0];
      const start = hit.index;
      if (start > cursor) frag.appendChild(textOrGreen(text.slice(cursor, start), isGreen));
      frag.appendChild(buildLinkEl(url, isGreen));
      cursor = start + url.length;
    }
    if (cursor < text.length) frag.appendChild(textOrGreen(text.slice(cursor), isGreen));

    node.parentNode.replaceChild(frag, node);
  }

  // ============================================================
  // EMBED LOGIC
  // ============================================================
  // Builds only the inline <a> link. Embeds are inserted after the .message
  // element by processMessage() so Vue re-renders can't orphan the async content.
  function buildLinkEl(rawUrl, parentIsGreen) {
    // Normalise protocol-less captures (e.g. bare discord.gg/CODE)
    const url = /^https?:\/\//i.test(rawUrl) ? rawUrl : 'https://' + rawUrl;

    const wrapper = document.createElement('span');
    wrapper.dataset.ftSkip = '1';

    const a = document.createElement('a');
    a.href = url;
    a.textContent = /https?:\/\/(?:cdn|media)\.discordapp\.(?:com|net)\//i.test(url)
      ? 'Discord media link'
      : shortUrl(url);
    a.target = '_blank';
    a.rel = 'noopener noreferrer';
    a.className = 'ft-link';
    wrapper.appendChild(a);
    return wrapper;
  }

  // msgEl → [outerEmbedEl, …]  — used to clean up when a message is removed
  const outerEmbedMap = new WeakMap();

  // Insert one outer embed div after msgEl for a given URL+type.
  function insertOuterEmbed(msgEl, url, type) {
    // Don't double-insert for the same URL on the same message
    if (msgEl.dataset.ftEmbeds?.includes(url)) return;
    msgEl.dataset.ftEmbeds = (msgEl.dataset.ftEmbeds || '') + url + ' ';

    const outer = document.createElement('div');
    outer.className = 'ft-outer-embed';
    outer.dataset.ftSkip = '1';

    const embedDiv = document.createElement('div');
    embedDiv.className = 'ft-embed';
    embedDiv.style.display = 'block';
    renderEmbed(embedDiv, url, type);

    const btn = makeBtn('hide');
    btn.onclick = () => {
      const hiding = embedDiv.style.display !== 'none';
      embedDiv.style.display = hiding ? 'none' : 'block';
      btn.textContent = hiding ? 'show' : 'hide';
    };

    // Place [hide] inline next to the link in the message itself.
    // Fall back to the outer embed div if the link can't be found.
    const linkEl = [...msgEl.querySelectorAll('a[href]')].find(a => a.href === url);
    if (linkEl?.parentElement) {
      linkEl.parentElement.appendChild(btn);
    } else {
      outer.appendChild(btn);
    }

    outer.appendChild(embedDiv);

    // Insert after the message (or after the last outer embed already there)
    let anchor = msgEl;
    while (anchor.nextElementSibling?.classList?.contains('ft-outer-embed')) {
      anchor = anchor.nextElementSibling;
    }
    anchor.after(outer);

    // Track so we can remove it when the parent message is evicted
    if (!outerEmbedMap.has(msgEl)) outerEmbedMap.set(msgEl, []);
    outerEmbedMap.get(msgEl).push(outer);
  }

  function renderEmbed(c, url, type) {
    switch (type) {
      case 'audio':     renderAudio(c, url);     break;
      case 'image':     renderImage(c, url);     break;
      case 'video':     renderVideo(c, url);     break;
      case 'streamable':renderStreamable(c, url);break;
      case 'twitter':   renderTwitter(c, url);   break;
      case '4chan':     render4chan(c, url);      break;
      case 'kiwifarms':      renderKiwifarms(c, url);      break;
      case 'discord-invite': renderDiscordInvite(c, url); break;
      case 'youtube':        renderYoutube(c, url);        break;
      case 'tiktok':         renderTiktok(c, url);         break;
      case 'instagram':      renderInstagram(c, url);      break;
      case 'reddit':         renderReddit(c, url);         break;
      case 'imgur':          renderImgur(c, url);          break;
    }
  }

  function renderImage(c, url) {
    // 4chan CDN blocks cross-origin hotlinks — fetch via GM_xmlhttpRequest to bypass
    if (/^https?:\/\/i\.4cdn\.org\//i.test(url)) {
      const ph = mkLoading('Loading image…');
      c.appendChild(ph);
      GM_xmlhttpRequest({
        method: 'GET', url, responseType: 'blob',
        onload(res) {
          ph.remove();
          try {
            const objUrl = URL.createObjectURL(res.response);
            const img = document.createElement('img');
            img.className = 'ft-img'; img.loading = 'lazy'; img.alt = '';
            img.onclick = () => img.classList.toggle('ft-full');
            img.onerror = () => { URL.revokeObjectURL(objUrl); img.replaceWith(mkErr('Image failed to load')); };
            img.src = objUrl;
            c.appendChild(img);
          } catch { c.appendChild(mkErr('Image failed to load')); }
        },
        onerror() { ph.remove(); c.appendChild(mkErr('Image failed to load')); },
      });
      return;
    }
    const img = document.createElement('img');
    img.className = 'ft-img'; img.loading = 'lazy'; img.alt = '';
    img.onclick = () => img.classList.toggle('ft-full');
    img.onerror = () => img.replaceWith(mkErr('Image failed to load'));
    img.src = url;
    c.appendChild(img);
  }

  function renderVideo(c, url) {
    const v = document.createElement('video');
    v.className = 'ft-video'; v.controls = true; v.muted = true;
    v.loop = false; v.preload = 'metadata';
    v.onerror = () => v.replaceWith(mkErr('Video failed to load'));
    v.src = url;
    c.appendChild(v);
  }

  function renderAudio(c, url) {
    const a = document.createElement('audio');
    a.className = 'ft-audio'; a.controls = true; a.preload = 'metadata';
    a.onerror = () => a.replaceWith(mkErr('Audio failed to load'));
    a.src = url;
    c.appendChild(a);
  }

  function renderStreamable(c, url) {
    const m = url.match(/streamable\.com\/([a-z0-9]+)/i);
    if (!m) { c.appendChild(mkErr('Bad Streamable URL')); return; }
    const f = document.createElement('iframe');
    f.className = 'ft-streamable';
    f.src = `https://streamable.com/e/${m[1]}`;
    f.allowFullscreen = true;
    c.appendChild(f);
  }

  function renderTwitter(c, url) {
    const m = url.match(/(?:twitter\.com|x\.com)\/([^/?#]+)\/status\/(\d+)/);
    if (!m) { c.appendChild(mkErr('Bad tweet URL')); return; }
    const [, screenName, id] = m;

    c.appendChild(mkLoading('Loading tweet…'));
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://api.fxtwitter.com/${screenName}/status/${id}`,
      headers: { 'User-Agent': 'fishtank-enhancer/1.0 (Tampermonkey)' },
      onload(res) {
        c.innerHTML = '';
        try {
          const d = JSON.parse(res.responseText);
          if (d.code !== 200 || !d.tweet) throw new Error(d.message || 'not found');
          const t = d.tweet;

          const scaleWrap = document.createElement('div');
          scaleWrap.className = 'ft-tw-scale-wrap';

          const card = document.createElement('div');
          card.className = 'ft-tw-card';

          // ── Header: avatar + display name + @handle ──
          const header = document.createElement('div');
          header.className = 'ft-tw-header';

          if (t.author?.avatar_url) {
            const avatar = document.createElement('img');
            avatar.className = 'ft-tw-avatar';
            avatar.src = t.author.avatar_url;
            avatar.alt = '';
            header.appendChild(avatar);
          }

          const names = document.createElement('div');
          names.className = 'ft-tw-names';
          const nameEl = document.createElement('span');
          nameEl.className = 'ft-tw-name';
          nameEl.textContent = t.author?.name ?? screenName;
          const handleEl = document.createElement('span');
          handleEl.className = 'ft-tw-handle';
          handleEl.textContent = '@' + (t.author?.screen_name ?? screenName);
          names.appendChild(nameEl);
          names.appendChild(handleEl);
          header.appendChild(names);
          card.appendChild(header);

          // ── Tweet body ──
          const body = document.createElement('div');
          body.className = 'ft-tw-body';
          body.textContent = t.text ?? '';
          card.appendChild(body);

          // ── Media: t.media.all is the full array (photos + videos + gifs) ──
          const mediaItems = t.media?.all ?? [];
          if (mediaItems.length) {
            const imgWrap = document.createElement('div');
            imgWrap.className = 'ft-tw-images';
            mediaItems.forEach(item => {
              if (item.type === 'video' || item.type === 'gif') {
                // Pick highest-bitrate mp4 from formats; fall back to top-level url
                const mp4 = (item.formats ?? [])
                  .filter(f => f.container === 'mp4')
                  .sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0];
                const src = mp4?.url ?? item.url;
                if (!src) return;
                const vid = document.createElement('video');
                vid.src = src;
                vid.poster = item.thumbnail_url ?? '';
                vid.controls = true;
                vid.playsInline = true;
                vid.preload = 'none';
                imgWrap.appendChild(vid);
              } else {
                const src = item.url ?? '';
                if (!src) return;
                const img = document.createElement('img');
                img.src = src;
                img.alt = item.altText ?? item.alt_text ?? '';
                img.loading = 'lazy';
                imgWrap.appendChild(img);
              }
            });
            if (imgWrap.children.length) card.appendChild(imgWrap);
          }

          // ── Stats row: replies, retweets, likes, views, date ──
          // Engagement fields are top-level on the tweet object, not nested
          const stats = document.createElement('div');
          stats.className = 'ft-tw-stats';

          function mkStat(icon, val) {
            if (!val) return;
            const s = document.createElement('span');
            s.className = 'ft-tw-stat';
            s.textContent = `${icon} ${Number(val).toLocaleString()}`;
            stats.appendChild(s);
          }
          mkStat('💬', t.replies);
          mkStat('🔁', t.retweets);
          mkStat('❤️', t.likes);
          mkStat('👁', t.views);

          if (t.created_timestamp) {
            const date = document.createElement('span');
            date.className = 'ft-tw-date';
            date.textContent = new Date(t.created_timestamp * 1000).toLocaleDateString();
            stats.appendChild(date);
          }
          card.appendChild(stats);

          scaleWrap.appendChild(card);
          c.appendChild(scaleWrap);
        } catch { c.appendChild(mkErr('Could not load tweet')); }
      },
      onerror() { c.innerHTML = ''; c.appendChild(mkErr('Tweet fetch failed')); },
    });
  }

  function render4chan(c, url) {
    const m = url.match(/boards\.(?:4chan|4channel)\.org\/([^/]+)\/thread\/(\d+)(?:[^#]*(?:#p(\d+))?)?/);
    if (!m) { c.appendChild(mkErr('Bad 4chan URL')); return; }
    const [, board, threadId, postNo] = m;

    c.appendChild(mkLoading('Loading 4chan post…'));
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://a.4cdn.org/${board}/thread/${threadId}.json`,
      onload(res) {
        c.innerHTML = '';
        try {
          const posts = JSON.parse(res.responseText).posts;
          const post = (postNo && posts.find(p => String(p.no) === postNo)) || posts[0];

          const tmp = document.createElement('div');
          tmp.innerHTML = post.com || '(no text)';
          const body = tmp.textContent.slice(0, 350) + (tmp.textContent.length > 350 ? '…' : '');

          const thumb = (post.tim && post.ext)
            ? `<img class="ft-4chan-thumb" src="https://i.4cdn.org/${board}/${post.tim}s.jpg" loading="lazy">` : '';

          const card = document.createElement('div');
          card.className = 'ft-4chan-card';
          card.innerHTML = `
            <div class="ft-4chan-hdr">/${board}/ &mdash; No.${post.no}${post.sub ? ` — ${esc(post.sub)}` : ''}</div>
            <div>${thumb}<div class="ft-4chan-body">${esc(body)}</div></div>
            <div class="ft-4chan-foot"><a href="${esc(url)}" target="_blank" rel="noopener" class="ft-link">Open on 4chan ↗</a></div>`;
          c.appendChild(card);
        } catch { c.appendChild(mkErr('Could not parse 4chan post')); }
      },
      onerror() { c.innerHTML = ''; c.appendChild(mkErr('4chan fetch failed')); },
    });
  }

  function renderKiwifarms(c, url) {
    let label = url;
    try {
      const u = new URL(url);
      const tm = u.pathname.match(/\/threads\/([^./]+)(?:\.(\d+))?/);
      const pm = u.hash.match(/#post-(\d+)/);
      if (tm) {
        label = 'Thread: ' + tm[1].replace(/-/g, ' ');
        if (pm) label += ` — Post #${pm[1]}`;
      }
    } catch { /* ignore */ }

    const card = document.createElement('div');
    card.className = 'ft-kf-card';
    card.innerHTML = `<b>🥝 Kiwifarms</b>${esc(label)}<br>
      <a href="${esc(url)}" target="_blank" rel="noopener" class="ft-link" style="margin-top:4px;display:inline-block">Open ↗</a>`;
    c.appendChild(card);
  }

  function renderDiscordInvite(c, url) {
    const m = url.match(/discord(?:\.gg|\.com\/invite)\/([a-zA-Z0-9-]+)/);
    if (!m) { c.appendChild(mkErr('Bad Discord invite URL')); return; }
    const code = m[1];

    c.appendChild(mkLoading('Loading invite…'));
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://discord.com/api/v9/invites/${code}?with_counts=true`,
      onload(res) {
        c.innerHTML = '';
        try {
          const d = JSON.parse(res.responseText);
          const guild = d.guild;
          if (!guild) throw new Error('no guild');

          const iconEl = guild.icon
            ? `<img class="ft-dc-icon" src="https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64" loading="lazy">`
            : `<div style="width:40px;height:40px;background:#5865f2;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0">🎮</div>`;

          const online  = d.approximate_presence_count;
          const members = d.approximate_member_count;

          const card = document.createElement('div');
          card.className = 'ft-dc-card';
          card.innerHTML = `
            ${iconEl}
            <div>
              <div class="ft-dc-name">${esc(guild.name)}</div>
              <div class="ft-dc-sub">${online !== undefined
                ? `<span style="color:#3ba55c">● ${online.toLocaleString()} online</span>  ${members.toLocaleString()} members`
                : 'Discord Server'}</div>
              <a href="${esc(url)}" target="_blank" rel="noopener" class="ft-link" style="font-size:11px;margin-top:4px;display:inline-block">Join Server ↗</a>
            </div>`;
          c.appendChild(card);
        } catch {
          // Fallback minimal card
          const card = document.createElement('div');
          card.className = 'ft-dc-card';
          card.innerHTML = `<div style="font-size:22px;flex-shrink:0">🎮</div>
            <div><div class="ft-dc-name">Discord Server</div>
            <a href="${esc(url)}" target="_blank" rel="noopener" class="ft-link" style="font-size:11px">Join ↗</a></div>`;
          c.appendChild(card);
        }
      },
      onerror() {
        c.innerHTML = '';
        const card = document.createElement('div');
        card.className = 'ft-dc-card';
        card.innerHTML = `<div style="font-size:22px;flex-shrink:0">🎮</div>
          <div><div class="ft-dc-name">Discord Server</div>
          <a href="${esc(url)}" target="_blank" rel="noopener" class="ft-link" style="font-size:11px">Join ↗</a></div>`;
        c.appendChild(card);
      },
    });
  }

  // ── YouTube (youtube-nocookie.com iframe) ──────────────────────
  function youtubeId(url) {
    const short = url.match(/youtu\.be\/([\w-]{11})/);
    if (short) return short[1];
    const path  = url.match(/youtube\.com\/(?:shorts|live)\/([\w-]{11})/);
    if (path)  return path[1];
    const watch = url.match(/[?&]v=([\w-]{11})/);
    if (watch) return watch[1];
    return null;
  }

  function renderYoutube(c, url) {
    const id = youtubeId(url);
    if (!id) { c.appendChild(mkErr('Could not parse YouTube URL')); return; }
    const f = document.createElement('iframe');
    f.className = 'ft-yt-frame'; // 400×225, 16:9 — width/height overridden by embedScaleStyle
    f.src = `https://www.youtube-nocookie.com/embed/${id}`;
    f.allowFullscreen = true;
    f.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
    f.loading = 'lazy';
    c.appendChild(f);
  }

  // ── TikTok (oEmbed card via GM_xmlhttpRequest) ─────────────────
  function renderTiktok(c, url) {
    c.appendChild(mkLoading('Loading TikTok…'));
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`,
      onload(res) {
        c.innerHTML = '';
        try {
          const d = JSON.parse(res.responseText);
          const card = document.createElement('div');
          card.className = 'ft-tt-card';
          const thumb = d.thumbnail_url ? `<img src="${esc(d.thumbnail_url)}" loading="lazy" alt="">` : '';
          const title  = esc(d.title        || 'TikTok video');
          const author = esc(d.author_name  || '');
          card.innerHTML = `
            ${thumb}
            <div class="ft-tt-meta">
              <div class="ft-tt-title">${title}</div>
              ${author ? `<div class="ft-tt-author">@${author}</div>` : ''}
              <a href="${esc(url)}" target="_blank" rel="noopener" class="ft-link" style="font-size:11px">Open on TikTok ↗</a>
            </div>`;
          c.appendChild(card);
        } catch { c.appendChild(mkErr('Could not load TikTok info')); }
      },
      onerror() { c.innerHTML = ''; c.appendChild(mkErr('TikTok fetch failed')); },
    });
  }

  // ── Instagram (embed iframe — works for public posts/reels) ────
  function renderInstagram(c, url) {
    const m = url.match(/instagram\.com\/(p|reel)\/([\w-]+)/);
    if (!m) { c.appendChild(mkErr('Could not parse Instagram URL')); return; }
    const f = document.createElement('iframe');
    f.className = 'ft-ig-frame';
    f.src = `https://www.instagram.com/${m[1]}/${m[2]}/embed/captioned/`;
    f.scrolling = 'no';
    f.loading = 'lazy';
    f.allowTransparency = 'true';
    c.appendChild(f);
  }

  // ── Reddit (redditmedia embed iframe — no API key required) ────
  function renderReddit(c, url) {
    const m = url.match(/reddit\.com\/(r\/[^/]+\/comments\/[a-z0-9]+)/i);
    if (!m) { c.appendChild(mkErr('Could not parse Reddit URL')); return; }
    const f = document.createElement('iframe');
    f.className = 'ft-reddit-frame'; // 328px wide, independently scalable
    f.src = `https://www.redditmedia.com/${m[1]}?ref_source=embed&embed=true&theme=dark`;
    f.scrolling = 'no';
    f.loading = 'lazy';
    f.allowFullscreen = true;
    c.appendChild(f);
  }

  // ── Imgur page links (imgur.com/<id>) — resolved via oEmbed ───
  function renderImgur(c, url) {
    const ph = mkLoading('Loading Imgur…');
    c.appendChild(ph);
    GM_xmlhttpRequest({
      method: 'GET',
      url: 'https://api.imgur.com/oembed?url=' + encodeURIComponent(url),
      onload(r) {
        ph.remove();
        try {
          const data = JSON.parse(r.responseText);
          const imgUrl = data.url || data.thumbnail_url;
          if (!imgUrl) throw new Error('no url');
          const img = document.createElement('img');
          img.className = 'ft-img'; img.loading = 'lazy'; img.alt = '';
          img.onclick = () => img.classList.toggle('ft-full');
          img.onerror = () => img.replaceWith(mkErr('Imgur image failed to load'));
          img.src = imgUrl;
          c.appendChild(img);
        } catch { c.appendChild(mkErr('Could not load Imgur image')); }
      },
      onerror() { ph.remove(); c.appendChild(mkErr('Imgur fetch failed')); }
    });
  }

  // ============================================================
  // MESSAGE PROCESSING
  // ============================================================
  const PROCESSED = 'data-ft-proc';

  // Cache original username + text before Vue can overwrite it on deletion
  const msgCache = new WeakMap();

  function cacheMessage(msgEl) {
    if (msgCache.has(msgEl)) return;
    const usernameEl = msgEl.querySelector('.cursor-pointer');
    const username = usernameEl?.textContent?.trim() || '';
    if (!username || username === 'user') return; // already deleted

    const clone = msgEl.cloneNode(true);
    clone.querySelectorAll('img').forEach(el => el.remove());
    const cloneUser = clone.querySelector('.cursor-pointer');
    if (cloneUser) cloneUser.remove();
    const text = clone.textContent.replace(/^\s*:\s*/, '').trim();

    if (text && !text.includes('[deleted by a moderator]')) {
      msgCache.set(msgEl, { username, text });
    }
  }

  function applyBannedStyle(msgEl) {
    if (msgEl.dataset.ftBanned) return;
    msgEl.dataset.ftBanned = '1';
    msgEl.classList.add('ft-msg-banned');

    const cached = msgCache.get(msgEl);

    // Restore username in red
    const usernameEl = msgEl.querySelector('.cursor-pointer');
    if (usernameEl && cached) {
      usernameEl.textContent = cached.username;
      usernameEl.style.color = '#e53935';
    }

    // Find "[deleted by a moderator]" text nodes and replace with cached text in red
    if (cached) {
      const walker = document.createTreeWalker(msgEl, NodeFilter.SHOW_TEXT, {
        acceptNode(n) {
          if (n.parentElement?.closest('[data-ft-skip]')) return NodeFilter.FILTER_SKIP;
          return n.textContent.includes('[deleted by a moderator]')
            ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
        },
      });
      const hits = [];
      let n;
      while ((n = walker.nextNode())) hits.push(n);

      if (hits.length) {
        for (const node of hits) {
          const span = document.createElement('span');
          span.style.color = '#e53935';
          span.textContent = ': ' + cached.text;
          node.parentNode.replaceChild(span, node);
        }
      }
    } else {
      msgEl.style.color = '#e53935';
    }

    const notice = document.createElement('b');
    notice.className = 'ft-banned-notice';
    notice.textContent = ' (USER WAS BANNED FOR THIS POST)';
    msgEl.appendChild(notice);
  }

  function checkForDeletion(msgEl) {
    if (msgEl.dataset.ftBanned) return;
    if (msgEl.textContent.includes('[deleted by a moderator]')) {
      applyBannedStyle(msgEl);
    }
  }

  function processMessage(msgEl) {
    if (msgEl.hasAttribute(PROCESSED)) return;
    msgEl.setAttribute(PROCESSED, '1');

    cacheMessage(msgEl);
    applyFilterToMessage(msgEl);

    const walker = document.createTreeWalker(msgEl, NodeFilter.SHOW_TEXT, {
      acceptNode(n) {
        const p = n.parentElement;
        if (!p || p.tagName === 'A' || p.tagName === 'SCRIPT') return NodeFilter.FILTER_SKIP;
        if (p.closest('[data-ft-skip]')) return NodeFilter.FILTER_SKIP;
        const txt = n.textContent;
        if (!txt.includes('http') && !/^\s*>/.test(txt)) return NodeFilter.FILTER_SKIP;
        return NodeFilter.FILTER_ACCEPT;
      },
    });

    const nodes = [];
    let n;
    while ((n = walker.nextNode())) nodes.push(n);

    // Collect embeddable URLs BEFORE modifying the DOM (linkifyTextNode replaces nodes)
    const toEmbed = [];
    for (const node of nodes) {
      URL_RE.lastIndex = 0;
      for (const hit of node.textContent.matchAll(URL_RE)) {
        const url = /^https?:\/\//i.test(hit[0]) ? hit[0] : 'https://' + hit[0];
        const type = urlType(url);
        if (type) toEmbed.push({ url, type });
      }
    }

    // Also pick up URLs from native <a href> elements the site now renders directly
    // (the site changed to output <a href="..."> instead of raw text URLs)
    for (const a of msgEl.querySelectorAll('a[href]:not(.ft-link):not(.ft-cam-link):not([data-ft-skip])')) {
      const url = a.href;
      if (!url || url === '#') continue;
      const type = urlType(url);
      if (type && !toEmbed.some(e => e.url === url)) toEmbed.push({ url, type });
    }

    // Linkify text nodes (turns URLs into <a> links, applies greentext)
    nodes.forEach(linkifyTextNode);

    // Second pass: detect camera names and insert hyperlink + Tune button.
    // Must run AFTER linkifyTextNode so we get fresh text nodes in the DOM.
    const camWalker = document.createTreeWalker(msgEl, NodeFilter.SHOW_TEXT, {
      acceptNode(n) {
        const p = n.parentElement;
        if (!p || p.tagName === 'A' || p.tagName === 'SCRIPT') return NodeFilter.FILTER_SKIP;
        if (p.closest('[data-ft-skip]')) return NodeFilter.FILTER_SKIP;
        const low = n.textContent.toLowerCase();
        return CAM_SEARCH_SORTED.some(([s]) => {
          let i = 0;
          while (true) {
            const p = low.indexOf(s, i);
            if (p === -1) return false;
            if (isWordBound(low, p, s.length)) return true;
            i = p + 1;
          }
        }) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
      },
    });
    const camNodes = [];
    let cn;
    while ((cn = camWalker.nextNode())) camNodes.push(cn);

    // Collect cam names from all matched text nodes, then append one tune button
    // per unique cam at the END of the message (not inline next to each mention).
    const foundCams = [];
    camNodes.forEach(node => {
      const names = linkifyCamNames(node);
      if (names?.length) foundCams.push(...names);
    });
    const uniqueCams = [...new Set(foundCams)];
    if (uniqueCams.length) {
      // Append inside the text span so the button flows inline with message content
      const textSpan = msgEl.querySelector('span.wrap-break-word') || msgEl;
      for (const name of uniqueCams) {
        const btn = document.createElement('button');
        btn.className = 'ft-tune-btn';
        btn.textContent = 'tune';
        btn.title = `Switch to ${name}`;
        btn.dataset.ftSkip = '1';
        btn.onclick = e => { e.stopPropagation(); selectCam(name); };
        textSpan.appendChild(btn);
      }
    }

    // Insert embeds as siblings AFTER the .message element.
    // This keeps them outside Vue's control — re-renders can't orphan the
    // async GM_xmlhttpRequest callbacks.
    for (const { url, type } of toEmbed) insertOuterEmbed(msgEl, url, type);
  }

  // ============================================================
  // OBSERVER + CHAT HEADER INJECTION
  // ============================================================
  const hookedLists = new WeakSet();

  function hookMessageList(list) {
    if (hookedLists.has(list)) return;
    hookedLists.add(list);
    list.querySelectorAll('.message').forEach(processMessage);

    // Show once-ever welcome notice on first install
    if (!localStorage.getItem(WELCOMED_KEY)) injectWelcomeMessage(list);


    const scrollEl = list.parentElement;
    new MutationObserver(muts => {
      let realAdded = false;
      for (const m of muts) {
        // New messages
        for (const node of m.addedNodes)
          if (node.nodeType === 1) {
            if (node.classList?.contains('message')) {
              if (!node.classList.contains('ft-tts-msg')) { processMessage(node); realAdded = true; }
            } else node.querySelectorAll?.('.message').forEach(processMessage);
          }
        // Evicted messages — remove their orphaned outer embeds
        for (const node of m.removedNodes)
          if (node.nodeType === 1 && node.classList?.contains('message'))
            outerEmbedMap.get(node)?.forEach(el => el.remove());
        // Detect moderator deletions within an already-processed message
        const msgEl = m.target?.closest?.('.message');
        if (msgEl?.hasAttribute(PROCESSED)) checkForDeletion(msgEl);
      }
      // Auto-scroll to bottom when real messages arrive and user is near the bottom.
      // After MutationObserver fires, scrollHeight has grown but scrollTop hasn't changed,
      // so the gap (scrollHeight - scrollTop - clientHeight) equals the height of content
      // added below the previous viewport bottom — still within the ~80px threshold if
      // the user was at the bottom before the mutation.
      if (realAdded && scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 80) {
        scrollEl.scrollTop = scrollEl.scrollHeight;
      }
    }).observe(list, { childList: true, subtree: true });
  }

  function hookChatHeaders() {
    document.querySelectorAll('h2').forEach(h2 => {
      if (h2.textContent.trim() === 'chat') {
        const header = h2.closest('div');
        if (header) injectFilterButton(header);
      }
    });
  }

  // ============================================================
  // LAYOUT
  // ============================================================
  function applyHeaderVisibility() {
    const hide = !!CFG.hideHeader;
    document.body.classList.toggle('ft-hide-header', hide);

    // Adjust padding-top compensation on any element that offsets for the sticky header
    document.querySelectorAll('[style*="padding-top"]').forEach(el => {
      if (!el.style.paddingTop) return;
      const val = parseInt(el.style.paddingTop);
      if (val > 0 || el.dataset.ftPadRestored) {
        if (hide) {
          if (!el.dataset.ftOrigPad) el.dataset.ftOrigPad = el.style.paddingTop;
          el.style.paddingTop = '0';
          el.dataset.ftPadRestored = '1';
        } else if (el.dataset.ftOrigPad) {
          el.style.paddingTop = el.dataset.ftOrigPad;
          delete el.dataset.ftOrigPad;
          delete el.dataset.ftPadRestored;
        }
      }
    });
  }

  function fixLayout() {
    applyHeaderVisibility();
  }

  // Click the native "collapse cams" button if the setting is on and cams are
  // currently expanded (button text = "collapse cams"). Safe to call multiple
  // times — no-ops if already collapsed or setting is off.
  function applyCamCollapse() {
    if (!CFG.collapseCams) return;
    const btn = [...document.querySelectorAll('button')].find(
      b => b.textContent.trim().toLowerCase() === 'collapse cams'
    );
    btn?.click();
  }


  // ============================================================
  // CAM LABEL SHRINKING
  // ============================================================
  const labeledTiles = new WeakSet();

  function shrinkCamLabels() {
    document.querySelectorAll('video').forEach(vid => {
      // Skip the main large stream player
      const rect = vid.getBoundingClientRect();
      if (rect.width > 500 || rect.width === 0) return;

      // Find the tile container: nearest ancestor that wraps this cam only
      let tile = vid.parentElement;
      // Walk up max 4 levels looking for something 'relative' or small enough
      for (let i = 0; i < 4 && tile && tile !== document.body; i++) {
        if (tile.className && /relative|overflow/.test(tile.className)) break;
        tile = tile.parentElement;
      }
      if (!tile || labeledTiles.has(tile)) return;
      labeledTiles.add(tile);

      // Find leaf text nodes (cam name labels are short single-line elements)
      tile.querySelectorAll('span, p, div').forEach(el => {
        if (el.children.length > 0) return;
        if (el === tile) return;
        const txt = el.textContent.trim();
        if (txt.length > 0 && txt.length < 60) {
          el.classList.add('ft-cam-label-small');
        }
      });
    });
    syncCamNamesFromDom();
  }

  // ============================================================
  // UTILS
  // ============================================================
  function makeBtn(t) {
    const b = document.createElement('button');
    b.className = 'ft-toggle'; b.textContent = t; return b;
  }
  function mkLoading(t) {
    const d = document.createElement('div'); d.className = 'ft-loading'; d.textContent = t; return d;
  }
  function mkErr(t) {
    const d = document.createElement('div'); d.className = 'ft-error'; d.textContent = '⚠ ' + t; return d;
  }
  function esc(s) {
    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }
  function shortUrl(url) {
    try {
      const u = new URL(url);
      const p = u.pathname.length > 32 ? u.pathname.slice(0, 30) + '…' : u.pathname;
      return u.hostname + p;
    } catch { return url.slice(0, 50); }
  }

  // ============================================================
  // CAM TILE HIDING + NAME DETECTION
  // ============================================================

  // Seed list of known camera labels — used for chat-message cam-name detection
  // before the DOM is available. New cams discovered in the DOM are merged in automatically.
  const CAM_NAMES = [
    'Jungle Room',
    'Director Mode', 'Hallway Down', 'Hallway Up', 'Dining Room',
    'Market Alternate', 'Bar Alternate', 'Dorm Alternate',
    'North Korean TV', '24/7 cows',
    'Confessional', 'Cameraman', 'Bar PTZ',
    'Glassroom', 'Balcony', 'Corridor', 'Kitchen', 'Jacuzzi',
    'Market', 'Foyer', 'Closet', 'Dorm', 'Bar',
  ];
  // Extra aliases: [searchTerm, canonicalCamName] — "PTZ" alone → "Bar PTZ"
  const CAM_ALIASES = [['PTZ', 'Bar PTZ']];

  function buildCamSearchList() {
    return [
      ...CAM_NAMES.map(n => [n.toLowerCase(), n]),
      ...CAM_ALIASES.map(([s, c]) => [s.toLowerCase(), c]),
    ].sort((a, b) => b[0].length - a[0].length);
  }

  // Flat list of [searchTerm (lowercase), canonicalName], sorted longest-first
  // so multi-word names always win over their short prefixes.
  let CAM_SEARCH_SORTED = buildCamSearchList();

  // Read current cam tile labels directly from the DOM
  function getLiveCamLabels() {
    const labels = [];
    document.querySelectorAll('[class*="aspect-video"][class*="cursor-pointer"]').forEach(t => {
      const label = t.querySelector('span[class*="rounded-md"]')?.textContent?.trim();
      if (label) labels.push(label);
    });
    return [...new Set(labels)];
  }

  // Merge any DOM-discovered cam labels into CAM_NAMES / CAM_SEARCH_SORTED
  function syncCamNamesFromDom() {
    const known = new Set(CAM_NAMES.map(n => n.toLowerCase()));
    let changed = false;
    for (const label of getLiveCamLabels()) {
      if (!known.has(label.toLowerCase())) {
        CAM_NAMES.push(label);
        known.add(label.toLowerCase());
        changed = true;
      }
    }
    if (changed) CAM_SEARCH_SORTED = buildCamSearchList();
  }

  function hideCamTiles() {
    document.querySelectorAll('[class*="aspect-video"][class*="cursor-pointer"]').forEach(t => {
      const label = t.querySelector('span[class*="rounded-md"]')?.textContent?.trim();
      if (!label) return;
      t.classList.toggle('ft-cam-tile-hidden', CFG.hiddenCams.includes(label));
    });
  }

  // Returns true when the match at [pos, pos+len) sits on a word boundary —
  // i.e. not flanked by a letter or digit on either side (within the same text string).
  // Used as a fast pre-filter; linkifyCamNames uses the DOM-aware variant below.
  function isWordBound(text, pos, len) {
    const before = pos > 0                ? text[pos - 1]     : ' ';
    const after  = pos + len < text.length ? text[pos + len]  : ' ';
    return !/[a-zA-Z0-9]/.test(before) && !/[a-zA-Z0-9]/.test(after);
  }

  // DOM-aware word boundary: when the match sits at the very start or end of a text node,
  // peek at the adjacent sibling's text to catch cases where the DOM splits one visible
  // "word" across multiple nodes (e.g. "Poly" in one span + "market" as its own text node).
  function isWordBoundInNode(node, text, pos, len) {
    let before, after;
    if (pos > 0) {
      before = text[pos - 1];
    } else {
      // Walk back through preceding siblings / parent to find last printed character
      let n = node.previousSibling;
      while (n) {
        const t = n.nodeType === 3 ? n.textContent : (n.textContent || '');
        if (t.length) { before = t[t.length - 1]; break; }
        n = n.previousSibling;
      }
      before = before ?? ' ';
    }

    if (pos + len < text.length) {
      after = text[pos + len];
    } else {
      // Walk forward through following siblings to find first printed character
      let n = node.nextSibling;
      while (n) {
        const t = n.nodeType === 3 ? n.textContent : (n.textContent || '');
        if (t.length) { after = t[0]; break; }
        n = n.nextSibling;
      }
      after = after ?? ' ';
    }

    return !/[a-zA-Z0-9]/.test(before) && !/[a-zA-Z0-9]/.test(after);
  }

  // Detect cam names (case-insensitive) inside a text node and replace with link + Tune button.
  // Preserves the user's original casing in the link text; uses the canonical name for selectCam().
  function linkifyCamNames(node) {
    const text    = node.textContent;
    const textLow = text.toLowerCase();
    const hits    = [];

    for (const [searchLow, canonical] of CAM_SEARCH_SORTED) {
      let idx = 0;
      while (true) {
        const pos = textLow.indexOf(searchLow, idx);
        if (pos === -1) break;
        const end = pos + searchLow.length;
        // Use DOM-aware boundary check so "Polymarket" DOM-split as [Poly][market] is caught
        if (isWordBoundInNode(node, text, pos, searchLow.length)
            && !hits.some(h => pos >= h.start && end <= h.end)) {
          hits.push({ start: pos, end, name: canonical });
        }
        idx = pos + 1;
      }
    }
    if (!hits.length) return [];

    hits.sort((a, b) => a.start - b.start);

    const frag = document.createDocumentFragment();
    let cursor = 0;
    for (const { start, end, name } of hits) {
      if (start > cursor) frag.appendChild(document.createTextNode(text.slice(cursor, start)));

      const a = document.createElement('a');
      a.href = '#';
      a.className = 'ft-link ft-cam-link';
      a.textContent = text.slice(start, end); // preserve user's own casing
      a.dataset.ftSkip = '1';
      a.onclick = e => { e.preventDefault(); selectCam(name); };
      frag.appendChild(a);

      cursor = end;
    }
    if (cursor < text.length) frag.appendChild(document.createTextNode(text.slice(cursor)));
    node.parentNode.replaceChild(frag, node);
    return hits.map(h => h.name);
  }

  // ============================================================
  // CAM LAYOUT (strip repositioning)
  // ============================================================

  // The main scrollable content column (everything left of the fixed right sidebar)
  // Identified by its unique combination of Tailwind classes on fishtank.rip
  function findMainContent() {
    return document.querySelector('.flex.flex-col.flex-1.no-scrollbar') || null;
  }

  // Returns the visible cam section element that should be repositioned.
  // Desktop: the `div.pt-1.flex-shrink-0` direct child of main content (the cam grid).
  // Mobile:  the `lg:hidden` grid, which is the only visible child with cam tiles.
  function findCamStrip() {
    const content = findMainContent();
    if (!content) return null;
    // Desktop: look for the pt-1 + flex-shrink-0 direct child that is visible
    for (const child of content.children) {
      if (child.classList.contains('pt-1') && child.classList.contains('flex-shrink-0')
          && window.getComputedStyle(child).display !== 'none') return child;
    }
    // Mobile fallback: any visible direct child that contains cam tiles
    for (const child of content.children) {
      if (window.getComputedStyle(child).display !== 'none'
          && child.querySelector('[class*="aspect-video"]')) return child;
    }
    return null;
  }

  // Returns the player container: the aspect-video + w-full direct child of main content.
  function findPlayerSection() {
    const content = findMainContent();
    if (!content) return null;
    for (const child of content.children) {
      if (child.className && child.className.includes('aspect-video')
          && child.className.includes('w-full')) return child;
    }
    return null;
  }

  let _camStripEl       = null;  // currently moved strip element
  let _camStripHome     = null;  // { parent, next } — original position
  let _camLayoutApplied = false; // guard against re-applying the same mode
  let _camLayoutTimer   = null;

  function applyCamLayout() {
    const mode = CFG.camLayout || 'default';

    // Restore to home first if we moved it previously
    if (_camStripEl && _camStripHome) {
      _camStripEl.classList.remove('ft-cam-scroll');
      // Also remove from the inner flex wrapper if it got the nowrap class
      const prevInner = _camStripEl.querySelector('.ft-cam-scroll-inner');
      if (prevInner) prevInner.classList.remove('ft-cam-scroll-inner');
      try { _camStripHome.parent.insertBefore(_camStripEl, _camStripHome.next || null); }
      catch { /* parent may have been re-rendered — ignore */ }
      _camStripEl  = null;
      _camStripHome = null;
    }
    _camLayoutApplied = false;

    if (mode === 'default') return;

    const strip = findCamStrip();
    if (!strip) return;

    // Don't re-apply if it's already this strip in the right mode
    if (_camLayoutApplied && _camStripEl === strip) return;

    _camStripEl   = strip;
    _camStripHome = { parent: strip.parentElement, next: strip.nextSibling };

    // Outer element scrolls; inner flex wrapper prevents wrapping
    strip.classList.add('ft-cam-scroll');
    const innerFlex = strip.querySelector('.flex.flex-wrap');
    if (innerFlex) innerFlex.classList.add('ft-cam-scroll-inner');

    if (mode === 'below-player') {
      const section = findPlayerSection();
      if (section) {
        try { section.after(strip); } catch { /* ignore */ }
      }
    }
    // 'above-chat': keeps default position, scroll class already applied above

    _camLayoutApplied = true;
  }

  function deferredApplyCamLayout() {
    clearTimeout(_camLayoutTimer);
    _camLayoutTimer = setTimeout(applyCamLayout, 300);
  }

  let _stoxPlaceTimer = null;
  function deferredPlaceStoxTicker() {
    clearTimeout(_stoxPlaceTimer);
    _stoxPlaceTimer = setTimeout(placeStoxTicker, 500);
  }

  // ============================================================
  // STOX TICKER
  // ============================================================

  const stoxTicker = document.createElement('div');
  stoxTicker.id = 'ft-stox-ticker';
  stoxTicker.innerHTML = '<div class="ft-stox-inner"><span class="ft-stox-track"><span class="ft-stox-loading">Loading Stox…</span></span><span class="ft-stox-track" aria-hidden="true"></span></div>';

  let stoxData = [];

  function fetchStox() {
    GM_xmlhttpRequest({
      method: 'GET',
      url: 'https://api.fishtank.live/v1/stocks',
      onload(res) {
        try {
          let raw = JSON.parse(res.responseText);
          if      (Array.isArray(raw))         stoxData = raw;
          else if (Array.isArray(raw.data))    stoxData = raw.data;
          else if (Array.isArray(raw.stocks))  stoxData = raw.stocks;
          else stoxData = Object.values(raw).filter(v => v && typeof v === 'object');
          renderStoxTicker();
        } catch { /* keep old data */ }
      },
      onerror() { /* ignore */ },
    });
  }

  function renderStoxTicker() {
    if (!stoxData.length) return;

    const items = stoxData.map(s => {
      // Real API fields: tickerSymbol, currentPrice, today (today's opening price), lastHour, lastWeek
      const name  = esc(String(s.tickerSymbol || s.name || s.ticker || s.symbol || '?'));
      const price = parseFloat(s.currentPrice ?? s.current_price ?? s.price ?? 0) || 0;
      // `today` = today's opening price → compute % change from open to current
      const openPrice = parseFloat(s.today ?? NaN);
      const pctRaw = (!isNaN(openPrice) && openPrice !== 0)
        ? (price - openPrice) / openPrice * 100
        : NaN;
      const pct   = isNaN(pctRaw) ? NaN : pctRaw;
      const up    = isNaN(pct) ? null : pct >= 0;
      const clr   = isNaN(pct) ? '' : ` style="color:${up ? '#4caf50' : '#f44336'}"`;
      const arrow = isNaN(pct) ? '' : (up ? '▲' : '▼');
      const pctStr = isNaN(pct) ? '' : `${arrow} ${up ? '+' : ''}${pct.toFixed(2)}%`;
      const priceStr = price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });

      return `<span class="ft-stox-item"${clr}>` +
        `<span class="ft-stox-name">${name}</span>` +
        ` <span class="ft-stox-price">₣${priceStr}</span>` +
        (pctStr ? ` <span class="ft-stox-chg">${pctStr}</span>` : '') +
        `</span>`;
    }).join('<span class="ft-stox-sep">·</span>');

    const inner = stoxTicker.querySelector('.ft-stox-inner');
    // Two copies → seamless loop at -50% translateX
    inner.innerHTML =
      `<span class="ft-stox-track">${items}</span>` +
      `<span class="ft-stox-track" aria-hidden="true">${items}</span>`;

    // Apply font size
    stoxTicker.style.fontSize = (CFG.stoxFontSize ?? 11) + 'px';

    // Adjust speed proportional to content length (target ~80px/s), scaled by user multiplier
    requestAnimationFrame(() => {
      const trackW = inner.querySelector('.ft-stox-track')?.offsetWidth || 800;
      const speed  = Math.max(0.1, CFG.stoxSpeed ?? 1.0);
      inner.style.animationDuration = Math.max(5, trackW / (80 * speed)) + 's';
    });
  }

  let _stoxTickerTarget = null; // tracks where we last placed the ticker

  function placeStoxTicker() {
    const mode = CFG.stoxLayout || 'hidden';

    if (mode === 'hidden') {
      stoxTicker.remove();
      _stoxTickerTarget = null;
      return;
    }

    // Compute (parent, insertBefore-anchor) for the current mode.
    // Called twice: once before removal (for the guard) and once after (for the fresh insert).
    function getTarget() {
      if (mode === 'above-chat') {
        const strip = findCamStrip();
        return strip ? { parent: strip.parentElement, before: strip } : null;
      }
      if (mode === 'below-cams') {
        const strip = findCamStrip();
        return strip ? { parent: strip.parentElement, before: strip.nextSibling } : null;
      }
      if (mode === 'below-player') {
        const section = findPlayerSection();
        return section ? { parent: section.parentElement, before: section.nextSibling } : null;
      }
      if (mode === 'above-chat-header') {
        // Insert just before the element that contains the "chat" h2 heading —
        // i.e. sandwiched between the cam strip and the chat panel header.
        const chatH2 = [...document.querySelectorAll('h2')].find(h => h.textContent.trim() === 'chat');
        const chatContainer = chatH2?.closest('div');
        if (chatContainer) return { parent: chatContainer.parentElement, before: chatContainer };
        return null;
      }
      return null;
    }

    const t = getTarget();
    if (!t?.parent) return; // anchor not in DOM yet — will retry on next mutation

    // Guard: already in the right spot (handles case where ticker IS the computed anchor)
    const expectedBefore = t.before === stoxTicker ? stoxTicker.nextSibling : t.before;
    if (stoxTicker.parentElement === t.parent && stoxTicker.nextSibling === expectedBefore) return;

    // Remove first, then re-query so sibling references are fresh (ticker no longer shifts them)
    stoxTicker.remove();
    const t2 = getTarget();
    if (!t2?.parent) return;

    try { t2.parent.insertBefore(stoxTicker, t2.before || null); } catch { /* ignore */ }
    _stoxTickerTarget = t2.parent;

    // Render now if we already have data and the ticker content is stale
    if (stoxData.length && !stoxTicker.querySelector('.ft-stox-item')) renderStoxTicker();
  }

  // Click every tile matching label (desktop + mobile grids both exist in DOM)
  function selectCam(label) {
    const tiles = [...document.querySelectorAll('[class*="aspect-video"][class*="cursor-pointer"]')];
    tiles
      .filter(t => t.querySelector('span[class*="rounded-md"]')?.textContent?.trim() === label)
      .forEach(t => t.click());
  }

  // True if any tile with this label is currently selected (has border-white)
  function isCamSelected(label) {
    return [...document.querySelectorAll('[class*="aspect-video"][class*="cursor-pointer"]')]
      .some(t => t.querySelector('span[class*="rounded-md"]')?.textContent?.trim() === label
              && t.classList.contains('border-white'));
  }

  // ============================================================
  // FISHTANK.LIVE TTS BRIDGE — polls GM_setValue written by fishtank.live tab
  // ============================================================

  function injectTtsMessage(displayName, message, voice, cost, room, audioUrl) {
    if (CFG.showTts === false) return;
    const list = document.querySelector('#message-list');
    if (!list) return;

    const msgEl = document.createElement('div');
    const isGold = /^(mints|jet|mod)$/i.test(displayName.trim());
    msgEl.className = 'message text-sm/6 pl-1 pb-[1px] ft-tts-msg' + (isGold ? ' ft-tts-gold' : '');
    msgEl.setAttribute('data-ft-proc', '1');

    const inner = document.createElement('span');
    inner.className = 'ml-0 wrap-break-word hyphens-auto';

    const badge = document.createElement('span');
    badge.className = 'ft-tts-badge';
    badge.textContent = 'TTS';

    const nameEl = document.createElement('span');
    nameEl.className = 'ft-tts-name';
    nameEl.textContent = displayName;

    inner.appendChild(badge);
    inner.appendChild(nameEl);

    if (room && room.toLowerCase() !== 'global') {
      inner.appendChild(document.createTextNode(' to '));
      const isCam = CAM_NAMES.some(n => n.toLowerCase() === room.toLowerCase());
      if (isCam) {
        const roomLink = document.createElement('a');
        roomLink.href = '#';
        roomLink.className = 'ft-link ft-cam-link ft-tts-name';
        roomLink.textContent = room;
        roomLink.dataset.ftSkip = '1';
        roomLink.onclick = e => { e.preventDefault(); selectCam(room); };
        inner.appendChild(roomLink);
        const tuneBtn = document.createElement('button');
        tuneBtn.className = 'ft-tune-btn';
        tuneBtn.textContent = 'tune';
        tuneBtn.title = `Switch to ${room}`;
        tuneBtn.dataset.ftSkip = '1';
        tuneBtn.onclick = e => { e.stopPropagation(); selectCam(room); };
        inner.appendChild(tuneBtn);
      } else {
        const roomSpan = document.createElement('span');
        roomSpan.className = 'ft-tts-name';
        roomSpan.textContent = room;
        inner.appendChild(roomSpan);
      }
    }

    const textEl = document.createElement('span');
    textEl.className = 'ft-tts-text';
    textEl.textContent = ': ' + message;

    inner.appendChild(textEl);

    if (cost || voice || audioUrl) {
      const meta = document.createElement('span');
      meta.className = 'ft-tts-cost';
      const parts = [];
      if (cost) parts.push('₣' + cost);
      if (voice) parts.push(voice);
      meta.textContent = ' ' + parts.join(' · ');
      if (audioUrl) {
        const playBtn = document.createElement('span');
        playBtn.textContent = ' · play audio';
        playBtn.style.cursor = 'pointer';
        playBtn.style.textDecoration = 'underline';
        let aud = null;
        playBtn.onclick = () => {
          if (!aud) { aud = new Audio(audioUrl); aud.onended = () => { playBtn.textContent = ' · play audio'; }; }
          if (aud.paused) { aud.play(); playBtn.textContent = ' · stop audio'; }
          else { aud.pause(); aud.currentTime = 0; playBtn.textContent = ' · play audio'; }
        };
        meta.appendChild(playBtn);
      }
      inner.appendChild(meta);
    }

    msgEl.appendChild(inner);
    // The actual scroll container is list.parentElement (overflow-y-scroll); list itself does not scroll.
    // Insert second-to-last: Vue tracks its own last element and calls insertBefore(newEl, lastVueEl.nextSibling).
    // If TTS is the very last child, nextSibling IS TTS, so Vue always inserts before it (TTS stays last forever).
    // By placing TTS one before the end, Vue's last element has no nextSibling → Vue appends after it → TTS rises naturally.
    const scrollEl = list.parentElement;
    const atBottom = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 80;
    const lastEl = list.lastElementChild;
    if (lastEl) list.insertBefore(msgEl, lastEl);
    else list.appendChild(msgEl);
    if (atBottom) {
      // Scroll to show TTS at the visual bottom (lastEl scrolls off below).
      // Uses getBCR so it works regardless of offset parent chain.
      const delta = msgEl.getBoundingClientRect().bottom - scrollEl.getBoundingClientRect().bottom;
      scrollEl.scrollTop += delta;
    }
    // Vue never evicts injected elements; auto-remove after 60s so TTS doesn't pile up at the top.
    setTimeout(() => msgEl.remove(), 60000);
  }

  // Maps internal stream slugs (from tts:update payload) to human-readable room names
  const TTS_ROOM_SLUGS = {
    'dmrm-5':  'Dorm',          'dirc-5':  'Director Mode', 'cfsl-5':  'Confessional',
    'bkny-5':  'Balcony',       'foyr-5':  'Foyer',         'dmcl-5':  'Closet',
    'gsrm-5':  'Glassroom',     'brrr-5':  'Bar',           'codr-5':  'Corridor',
    'brpz-5':  'Bar PTZ',       'mrke2-5': 'Market Alternate', 'ktch-5': 'Kitchen',
    'brrr2-5': 'Bar Alternate', 'dmrm2-5': 'Dorm Alternate','jckz-5':  'Jacuzzi',
    'dnrm-5':  'Dining Room',   'mrke-5':  'Market',        'hwdn-5':  'Hallway Down',
    'hwup-5':  'Hallway Up',    'br4j-5':  'Jungle Room',
  };
  function resolveRoom(slug) { return TTS_ROOM_SLUGS[slug] || slug || ''; }

  function startTtsRelay() {
    const BASE = 'https://ws.fishtank.live/socket.io/';
    const HDRS = { 'Origin': 'https://fishtank.live', 'Referer': 'https://fishtank.live/' };
    const _seenIds = new Set();
    let _currentRoom = '';

    function onTts(displayName, message, voice, cost, id, room, audioUrl) {
      if (CFG.showTts === false) return;
      if (id && _seenIds.has(id)) return;
      if (id) { _seenIds.add(id); if (_seenIds.size > 300) _seenIds.clear(); }
      injectTtsMessage(displayName, message, voice, cost, room ?? _currentRoom, audioUrl);
    }

    let _sid = null;

    function reconnect(reason, delay = 5000) {
      _sid = null;
      delete HDRS['Cookie'];
      setTimeout(doHandshake, delay);
    }

    function doHandshake() {
      GM_xmlhttpRequest({
        method: 'GET',
        url: BASE + '?EIO=4&transport=polling',
        headers: HDRS,
        onload(r) {
          if (r.status === 429) { reconnect('handshake 429 rate-limited', 15000); return; }
          if (r.status !== 200) { reconnect('handshake ' + r.status); return; }
          const hdrs = r.responseHeaders || '';
          const cookies = [];
          for (const line of hdrs.split(/\r?\n/)) {
            const m = /^set-cookie:\s*([^;\r\n]+)/i.exec(line);
            if (m) cookies.push(m[1].trim());
          }
          if (cookies.length) HDRS['Cookie'] = cookies.join('; ');
          try {
            const json = JSON.parse(r.responseText.slice(1));
            _sid = json.sid;
            doAuth();
          } catch(e) { reconnect('handshake parse: ' + e); }
        },
        onerror() { reconnect('handshake onerror'); }
      });
    }

    function doAuth() {
      const rawToken = GM_getValue('ft_live_token', '');
      const token = (rawToken && rawToken !== 'null') ? rawToken : null;
      const pktObj = { type: 0, nsp: '/', data: token ? { token } : null };
      const mpArr = _mpEnc(pktObj);
      const mpBytes = new Uint8Array(mpArr);
      let bin = '';
      for (let i = 0; i < mpBytes.length; i++) bin += String.fromCharCode(mpBytes[i]);
      const body = 'b' + btoa(bin);
      GM_xmlhttpRequest({
        method: 'POST',
        url: BASE + '?EIO=4&transport=polling&sid=' + encodeURIComponent(_sid),
        headers: { ...HDRS, 'Content-Type': 'text/plain;charset=UTF-8' },
        data: body,
        onload(r) {
          if (r.status === 400) { reconnect('auth wrong-node', 500); return; }
          if (r.status !== 200) { reconnect('auth ' + r.status); return; }
          // Auth succeeded — try to upgrade the session to WebSocket so we escape
          // the load-balancer round-robin that causes poll 400s on every other request.
          tryWsUpgrade();
        },
        onerror() { reconnect('auth onerror'); }
      });
    }

    function tryWsUpgrade() {
      let upgraded = false;
      let _ws;
      try {
        _ws = new WebSocket(
          'wss://ws.fishtank.live/socket.io/?EIO=4&transport=websocket&sid=' + encodeURIComponent(_sid)
        );
        _ws.binaryType = 'arraybuffer';

        // If probe handshake isn't completed in 5 s, fall back to polling
        const timer = setTimeout(() => {
          if (!upgraded) { _ws.close(); doPoll(); }
        }, 5000);

        _ws.onopen = () => { _ws.send('2probe'); };

        _ws.onmessage = e => {
          if (!upgraded) {
            // Probe handshake: client sends "2probe", server replies "3probe", client sends "5"
            if (e.data === '3probe') { _ws.send('5'); upgraded = true; clearTimeout(timer); }
            return;
          }
          // Upgraded — handle live messages
          if (typeof e.data === 'string') {
            if (e.data === '2') _ws.send('3'); // EIO ping → pong
            return;
          }
          // Binary msgpack SIO packet — same handler as polling path
          try { processBinaryPkt(new Uint8Array(e.data)); } catch(e) {}
        };

        _ws.onerror = () => {
          clearTimeout(timer);
          if (!upgraded) doPoll();
        };

        _ws.onclose = () => {
          clearTimeout(timer);
          if (!upgraded) { doPoll(); return; }
          reconnect('ws closed');
        };
      } catch(err) {
        doPoll();
      }
    }

    function doPoll() {
      GM_xmlhttpRequest({
        method: 'GET',
        url: BASE + '?EIO=4&transport=polling&sid=' + encodeURIComponent(_sid),
        headers: HDRS,
        onload(r) {
          if (r.status === 429) { reconnect('poll 429', 15000); return; }
          if (r.status === 400) { reconnect('poll 400', 1000); return; }
          if (r.status !== 200) { reconnect('poll ' + r.status); return; }
          try { processPkt(r.responseText); } catch(e) {}
          if (_sid) doPoll();
        },
        onerror() { reconnect('poll onerror'); }
      });
    }

    function processBinaryPkt(bytes) {
      try {
        const pkt = _mpDec(bytes);
        if (!pkt) return;
        const { type: sioType, data } = pkt;
        if (sioType === 4) {
          reconnect('connect error');
        } else if (sioType === 2 && Array.isArray(data)) {
          const [event, payload] = data;
          if (event === 'chat:room' && typeof payload === 'string') {
            _currentRoom = payload;
          } else if (event === 'tts:update' && payload &&
                     ['playing','played'].includes(payload.status) &&
                     payload.displayName && payload.message) {
            const slug = payload.room || payload.chatRoom || payload.roomName || payload.area || _currentRoom;
            onTts(payload.displayName, payload.message, payload.voice, payload.cost, payload.id, resolveRoom(slug), payload.audioUrl);
          }
        }
      } catch(e) {}
    }

    function processPkt(text) {
      if (!text) return;
      const packets = text.split('\x1e');
      for (const raw of packets) {
        if (!raw) continue;
        const eioType = raw[0];
        if (eioType === 'b') {
          const bytes = Uint8Array.from(atob(raw.slice(1)), c => c.charCodeAt(0));
          processBinaryPkt(bytes);
        } else if (eioType === '1') {
          reconnect('server close');
          return;
        } else if (eioType === '2') {
          GM_xmlhttpRequest({
            method: 'POST',
            url: BASE + '?EIO=4&transport=polling&sid=' + encodeURIComponent(_sid),
            headers: { ...HDRS, 'Content-Type': 'text/plain;charset=UTF-8' },
            data: '3'
          });
        } else if (eioType === '4') {
          const sio = raw.slice(1);
          const sioType = sio[0];
          if (sioType === '2') {
            try {
              const json = JSON.parse(sio.slice(1));
              if (!Array.isArray(json)) continue;
              const [event, payload] = json;
              if (event === 'tts:update' && payload &&
                  ['playing','played'].includes(payload.status) &&
                  payload.displayName && payload.message) {
                const slug = payload.room || payload.chatRoom || payload.roomName || payload.area || _currentRoom;
                onTts(payload.displayName, payload.message, payload.voice, payload.cost, payload.id, resolveRoom(slug), payload.audioUrl);
              }
            } catch(e) {}
          }
        }
      }
    }

    doHandshake();

    // ── Relay fallback: catch events if fishtank.live is open in another tab ──
    // Seed from stored value so we don't replay the last TTS on every page refresh.
    let _relaySeq = (() => { try { return JSON.parse(GM_getValue('ft_tts_relay', '{}')).seq || 0; } catch { return 0; } })();
    setInterval(() => {
      if (CFG.showTts === false) return;
      try {
        const raw = GM_getValue('ft_tts_relay', '');
        if (!raw) return;
        const data = JSON.parse(raw);
        if (!data || data.seq <= _relaySeq) return;
        _relaySeq = data.seq;
        if (data.displayName && data.message) onTts(data.displayName, data.message, data.voice, data.cost, data.id, resolveRoom(data.room), data.audioUrl);
      } catch {}
    }, 750);
  }

  // ============================================================
  // INIT
  // ============================================================
  function init() {
    fixLayout();
    hookChatHeaders();
    document.querySelectorAll('#message-list').forEach(hookMessageList);
    shrinkCamLabels();
    hideCamTiles();
    applyCamLayout();
    applyEmbedScales();
    applyStoxStyle();
    fetchStox();
    placeStoxTicker();
    try { startTtsRelay(); } catch(e) { /* TTS unavailable */ }
    // Delay slightly — the collapse button may not be in DOM yet on first render
    setTimeout(applyCamCollapse, 500);
  }

  // Wait for Nuxt to render
  function waitAndInit() {
    if (document.querySelector('#message-list')) {
      init();
    } else {
      const obs = new MutationObserver(() => {
        if (document.querySelector('#message-list')) {
          obs.disconnect();
          init();
        }
      });
      obs.observe(document.body, { childList: true, subtree: true });
    }

    // Watch for newly added chat headers (Nuxt re-renders)
    new MutationObserver(() => {
      hookChatHeaders();
      document.querySelectorAll('#message-list').forEach(hookMessageList);
      fixLayout();
      shrinkCamLabels();
      hideCamTiles();
      deferredApplyCamLayout();
      deferredPlaceStoxTicker();
      // NOTE: applyCamCollapse is intentionally NOT called here — calling it on
      // every DOM mutation would immediately re-collapse whenever the user
      // clicks the native "expand cams" button. It is called from init() on
      // page-load and SPA navigation instead.
    }).observe(document.body, { childList: true, subtree: true });
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', waitAndInit);
  else waitAndInit();

  // SPA navigation
  let lastPath = location.pathname;
  setInterval(() => {
    if (location.pathname !== lastPath) { lastPath = location.pathname; setTimeout(waitAndInit, 700); }
  }, 500);

  // Refresh Stox data every 30 s
  setInterval(fetchStox, 30_000);

})();