CB Quick Silence + Image Hash Guard

Original Quick Silence + image dHash matching (upload refs) and optional audio queue on detection

// ==UserScript==
// @name         CB Quick Silence + Image Hash Guard
// @namespace    aravvn.tools
// @version      3.1.4
// @description  Original Quick Silence + image dHash matching (upload refs) and optional audio queue on detection
// @author       aravvn
// @license      CC-BY-NC-SA-4.0
// @match        https://*.chaturbate.com/*
// @match        https://*.testbed.cb.dev/*
// @run-at       document-idle
// @noframes
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      *.supabase.co
// @connect      supabase.co
// @connect      static-pub.highwebmedia.com
// ==/UserScript==

(() => {
  'use strict';

  // ===================== CONFIG (QS original) =====================
  const SUPABASE_URL = 'https://gbscowfidfdiaywktqal.supabase.co';
  const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imdic2Nvd2ZpZGZkaWF5d2t0cWFsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYxNDU0MzUsImV4cCI6MjA3MTcyMTQzNX0.BITW_rG0-goeo8VCgl-ZTSWfa8BDYsmT2xhIg-9055g';
  const SUPABASE_TABLE_USERS = 'auto_silence_users';
  const SUPABASE_TABLE_WORDS = 'trigger_words';
  const DEFAULT_POLL_MS = 300_000;

  const FALLBACK_USERS = [];
  const FALLBACK_WORDS = [];

  const CONFIG_DEFAULTS = {
    enableAutoSilence: true,
    enableHighlighting: true,
    showSilenceButtons: true,
    pollMs: DEFAULT_POLL_MS,
  };

  // ===================== CONFIG (Image Hash + Audio) ======================
  const HASH_DEFAULTS = {
    hashEnabled: true,            // turn hash-based detection on/off
    threshold: 10,                // Hamming distance 0..64
    includeMods: false,           // detect in mod/broadcaster messages (no autosilence)
    cooldownMs: 15000,            // per-image URL cooldown

    // Softer audio cue defaults
    beepOnMatch: true,
    beepVolume: 0.18,             // gentler default volume (0..1)
    beepOncePerMessage: true,
    beepToneHz: 660,              // soft "ping" tone (A5-ish)
    beepDuration: 0.12,           // seconds
    beepLowpassHz: 1200           // smooth the tone with a lowpass
  };

  const KEY_HASH_REFS = 'qs_img_hash_refs';     // [{id,name,mime,size,hashHex}]
  const KEY_HASH_CFG  = 'qs_img_hash_cfg';      // {...HASH_DEFAULTS}

  // ===================== CONSTANTS / SELECTORS ====================
  const EXCLUDE_CLASSES = ['broadcaster', 'mod']; // (QS original button exclusion)
  const SEL = {
    rootMessage: 'div[data-testid="chat-message"]',
    usernameContainer: 'div[data-testid="chat-message-username"]',
    username: 'span[data-testid="username"]',
    viewerUsername: '.user_information_header_username, [data-testid="user-information-username"]',
    msgImages: 'img[data-testid="emoticonImg"], img.emoticonImage, picture img',
    msgVideos: 'video',
    msgVideoSources: 'video source'
  };

  // ===================== LOG/UTIL =====================
  const DEBUG = true;
  const log = (...a) => DEBUG && console.log('[QS]', ...a);
  const getCfg = (k) => GM_getValue(k, CONFIG_DEFAULTS[k]);
  const setCfg = (k, v) => GM_setValue(k, v);

  const getHashCfg = () => {
    const cur = GM_getValue(KEY_HASH_CFG, null);
    if (!cur) { GM_setValue(KEY_HASH_CFG, HASH_DEFAULTS); return { ...HASH_DEFAULTS }; }
    return { ...HASH_DEFAULTS, ...cur };
  };
  const setHashCfg = (obj) => { try { GM_setValue(KEY_HASH_CFG, { ...getHashCfg(), ...obj }); } catch(e) { console.warn('[QS] GM_setValue failed:', e); } };

  const getHashRefs = () => GM_getValue(KEY_HASH_REFS, []);
  const setHashRefs = (arr) => GM_setValue(KEY_HASH_REFS, Array.isArray(arr) ? arr : []);

  function toast(msg) {
    const t = document.createElement('div');
    t.className = 'qs_toast';
    t.textContent = msg;
    document.body.appendChild(t);
    requestAnimationFrame(() => t.classList.add('show'));
    setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 180); }, 1600);
  }
  function stripTs(s) { return String(s || '').replace(/^[\[]\d{2}:\d{2}[]]\s*/, '').trim(); }
  function extractUsernameFromText(text) {
    const t = String(text || '').trim();
    const m = t.match(/([A-Za-z0-9_]+)\s*$/);
    if (m) return m[1];
    const cleaned = t.replace(/^\s*(\[[^\]]*?\]\s*)+/g, '').trim();
    const parts = cleaned.split(/\s+/);
    return parts[parts.length - 1] || '';
  }
  function getCookie(name) {
    return document.cookie.split('; ').reduce((r, v) => {
      const parts = v.split('=');
      return parts[0] === name ? decodeURIComponent(parts[1]) : r;
    }, '');
  }
  function waitForSelector(selector, timeout = 5000) {
    return new Promise((resolve) => {
      const found = document.querySelector(selector);
      if (found) return resolve(found);
      const obs = new MutationObserver(() => {
        const el = document.querySelector(selector);
        if (el) { obs.disconnect(); resolve(el); }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });
      setTimeout(() => { obs.disconnect(); resolve(null); }, timeout);
    });
  }

  // URLs/Rooms
  function getRoomFromURL() {
    const parts = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
    if (!parts[0]) return '';
    if (parts[0] === 'b') return parts[1] || '';
    if (['p','tags','api','auth','proxy'].includes(parts[0])) return '';
    return parts[0];
  }
  function isBroadcasterView() {
    const parts = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
    return parts[0] === 'b' && !!parts[1];
  }

  // ===================== DOM/CSS =====================
  const style = document.createElement('style');
  style.textContent = `
    .qs_sil_btn{display:inline-block;margin-left:6px;padding:0 6px;font:11px/18px system-ui,sans-serif;border-radius:4px;cursor:pointer;user-select:none;background:#d13b3b;color:#fff;border:0}
    .qs_sil_btn:hover{filter:brightness(1.06)}
    .qs_toast{position:fixed;right:12px;bottom:12px;z-index:2147483647;background:#151515;color:#e9e9ea;border:1px solid #3a3a3d;border-radius:8px;padding:8px 10px;font:12px system-ui,sans-serif;box-shadow:0 6px 18px rgba(0,0,0,.45);opacity:0;transform:translateY(8px);transition:all .2s ease}
    .qs_toast.show{opacity:1;transform:translateY(0)}
    .qs_flagged_user { border-left: 4px solid #f40 !important; background: rgba(255,80,80,0.08) !important; }
    .qs_flagged_word { border-left: 4px solid #f93 !important; background: rgba(255,160,60,0.08) !important; }

    /* Floating Settings Button + Panel */
    #qs_cfg_btn{position:fixed;left:12px;bottom:12px;z-index:2147483647;background:#2b2d31;color:#fff;border:1px solid #3a3a3d;border-radius:999px;padding:8px 10px;font:12px system-ui;cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.45)}
    #qs_cfg_btn:hover{filter:brightness(1.08)}
    #qs_cfg_panel{position:fixed;left:16px;bottom:56px;z-index:2147483647;width:min(560px,92vw);max-height:min(78vh,720px);display:none;flex-direction:column;background:#101014cc;border:1px solid #3a3a3d;border-radius:10px;color:#e9e9ea;backdrop-filter:blur(4px);box-shadow:0 10px 28px rgba(0,0,0,.55);overflow:hidden}
    #qs_cfg_panel header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid #2a2a2e;font:600 13px system-ui}
    #qs_cfg_panel .qs_body{padding:10px;overflow:auto;font:12px system-ui}
    #qs_cfg_panel .row{display:flex;align-items:center;gap:8px;margin:6px 0;flex-wrap:wrap}
    #qs_cfg_panel label{display:flex;align-items:center;gap:6px}
    #qs_cfg_panel input[type="number"], #qs_cfg_panel input[type="text"], #qs_cfg_panel input[type="range"]{background:#17181c;border:1px solid #2c2e34;border-radius:6px;color:#e9e9ea;padding:4px 6px}
    #qs_cfg_panel button{background:#2a2c31;border:1px solid #3a3a3d;border-radius:6px;color:#e9e9ea;font:12px/24px system-ui;padding:0 10px;cursor:pointer}
    #qs_cfg_panel button:hover{filter:brightness(1.08)}
    #qs_cfg_panel table{width:100%;border-collapse:collapse;margin-top:6px}
    #qs_cfg_panel th,#qs_cfg_panel td{border-bottom:1px solid #2a2a2e;padding:6px 4px;vertical-align:top}
    #qs_cfg_panel .muted{opacity:.7}
  `;
  document.head.appendChild(style);

  // ===================== Supabase helpers =====================
  function gmRequestJSON(method, url, headers = {}, data = null) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method,
        url,
        headers,
        data,
        responseType: 'json',
        onload: (res) => {
          const status = res.status;
          const text = res.responseText;
          if (status >= 200 && status < 300) {
            try { resolve(JSON.parse(text || 'null')); } catch { resolve(res.response ?? null); }
          } else reject(new Error(`HTTP ${res.status}: ${text?.slice(0, 200)}`));
        },
        onerror: () => reject(new Error('Network error')),
        ontimeout: () => reject(new Error('Timeout')),
      });
    });
  }
  const hasSupabase = () => SUPABASE_URL.startsWith('https://') && SUPABASE_ANON_KEY && SUPABASE_ANON_KEY.length > 20;
  function buildUrl(path, params = {}) {
    const url = new URL(`${SUPABASE_URL}/rest/v1/${path}`);
    const defaultParams = { select: '*', order: 'updated_at.desc', limit: '10000' };
    url.search = new URLSearchParams({ ...defaultParams, ...params }).toString();
    return url.toString();
  }
  async function sbGet(path, params = {}) {
    const url = buildUrl(path, params);
    return gmRequestJSON('GET', url, {
      apikey: SUPABASE_ANON_KEY,
      Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
      Accept: 'application/json',
    });
  }

  async function fetchUsers() {
    const rows = await sbGet(SUPABASE_TABLE_USERS, { select: 'username,is_active,updated_at', 'is_active': 'eq.true' });
    return rows.filter(r => r && r.username).map(r => String(r.username).toLowerCase().trim()).filter(Boolean);
  }
  async function fetchWords() {
    const rows = await sbGet(SUPABASE_TABLE_WORDS, { select: 'word,is_active,updated_at', 'is_active': 'eq.true' });
    return rows.filter(r => r && r.word).map(r => String(r.word).toLowerCase().trim()).filter(Boolean);
  }
  async function fetchUsersDetailed() {
    const rows = await sbGet(SUPABASE_TABLE_USERS, { select: '*', order: 'updated_at.desc', limit: '10000', 'is_active': 'eq.true' });
    const norm = (v) => (v == null ? '' : String(v));
    return (rows || []).map(r => ({
      username: norm(r.username).toLowerCase(),
      note: norm(r.note || r.comment || r.description || ''),
      reason: norm(r.reason || ''),
      source: norm(r.source || r.origin || ''),
      is_active: (typeof r.is_active === 'boolean') ? r.is_active : true,
      created_at: r.created_at || r.inserted_at || '',
      updated_at: r.updated_at || r.modified_at || r.changed_at || '',
    })).filter(e => e.username);
  }

  // ===================== State =====================
  let AUTO_SILENCE_USERS = [...FALLBACK_USERS.map(s => s.toLowerCase())];
  let TRIGGER_WORDS = [...FALLBACK_WORDS.map(s => s.toLowerCase())];
  let pollTimer = null;

  function applyNewLists(users, words) {
    let changed = false;
    if (users) { AUTO_SILENCE_USERS = Array.from(new Set([...FALLBACK_USERS.map(s => s.toLowerCase()), ...users])); changed = true; }
    if (words) { TRIGGER_WORDS = Array.from(new Set([...FALLBACK_WORDS.map(s => s.toLowerCase()), ...words])); changed = true; }
    if (changed) rescanExistingForHighlights();
  }

  async function syncFromSupabaseOnce() {
    if (!hasSupabase()) return;
    try {
      const [users, words] = await Promise.all([fetchUsers(), fetchWords()]);
      applyNewLists(users, words);
      GM_setValue('sb_last_ok', Date.now());
    } catch (e) {
      log('Supabase sync failed:', e.message || e);
      const lastOk = GM_getValue('sb_last_ok', 0);
      if (!lastOk) toast('⚠️ Supabase unreachable, using fallback lists');
    }
  }

  function startPolling() {
    if (pollTimer) clearInterval(pollTimer);
    const ms = Math.max(10_000, Number(getCfg('pollMs')) || DEFAULT_POLL_MS);
    pollTimer = setInterval(syncFromSupabaseOnce, ms);
  }

  // LOCAL silence log
  const SILENCE_DB_KEY = 'qs_silenced_users_db';
  function readSilenceDB() {
    const raw = GM_getValue(SILENCE_DB_KEY, {});
    return (raw && typeof raw === 'object') ? raw : {};
  }
  function writeSilenceDB(obj) { GM_setValue(SILENCE_DB_KEY, obj); }
  function upsertSilenced(entry) {
    const db = readSilenceDB();
    const key = String(entry.username || '').toLowerCase();
    if (!key) return;
    const prev = db[key] || {};
    db[key] = {
      username: entry.username,
      firstTs: prev.firstTs || entry.ts,
      ts: entry.ts,
      note: entry.note || prev.note || '',
      mode: entry.mode || prev.mode || 'auto',
      reason: entry.reason || prev.reason || '',
      room: entry.room || prev.room || '',
      count: (prev.count || 0) + 1,
    };
    writeSilenceDB(db);
  }

  // ===================== CB actions / logic =====================
  async function fetchIsPrivileged(viewer, room) {
    const url = `${location.origin}/api/ts/chatmessages/user_info/${encodeURIComponent(viewer)}/?room=${encodeURIComponent(room)}`;
    try {
      const res = await fetch(url, { credentials: 'include', headers: { 'Accept': 'application/json' } });
      const data = await res.json();
      return !!(data?.user?.is_mod || data?.user?.is_broadcaster);
    } catch { return false; }
  }

  // IMPORTANT: never include /b/ in roomsilence URL
  async function postSilence(username) {
    const room = currentRoom || getRoomFromURL();
    if (!room) return false;
    const url = `${location.origin}/roomsilence/${encodeURIComponent(username)}/${encodeURIComponent(room)}/`;
    const csrf = getCookie('csrftoken') || getCookie('CSRF-TOKEN') || getCookie('XSRF-TOKEN');
    const headers = {
      'X-Requested-With': 'XMLHttpRequest',
      'Accept': '*/*',
      'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
      ...(csrf ? { 'X-CSRFToken': csrf, 'X-CSRF-Token': csrf } : {}),
      'Referer': `${location.origin}/${encodeURIComponent(room)}/`
    };
    const body = 'source=chat';
    const res = await fetch(url, { method: 'POST', credentials: 'include', headers, body, redirect: 'manual' });
    return res.ok;
  }

  function getUsernameFromRoot(root) {
    const span = root.querySelector(SEL.username) || root.querySelector(`${SEL.usernameContainer} ${SEL.username}`);
    if (span && span.textContent) return extractUsernameFromText(stripTs(span.textContent));
    const cont = root.querySelector(SEL.usernameContainer);
    return extractUsernameFromText(cont?.textContent || '');
  }

  let running = false;
  let currentRoom = '';
  let isPrivileged = false;
  let viewerNameLc = '';

  function shouldExcludeByClass(nameWrap) {
    return EXCLUDE_CLASSES.some(cls => nameWrap.classList.contains(cls));
  }

  // ===================== AUDIO CUE (soft) =====================
  let audioCtx = null;
  function getAudioCtx() {
    if (!audioCtx) {
      try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
      catch { /* ignore */ }
    }
    return audioCtx;
  }
  function playBeep() {
    const cfg = getHashCfg();
    if (!cfg.beepOnMatch) return;
    const ctx = getAudioCtx();
    if (!ctx) return;

    const now = ctx.currentTime;
    const dur = Math.max(0.05, Math.min(1.0, Number(cfg.beepDuration) || 0.12)); // seconds
    const tone = Math.max(200, Math.min(4000, Number(cfg.beepToneHz) || 660));
    const lowpassHz = Math.max(200, Math.min(8000, Number(cfg.beepLowpassHz) || 1200));
    const vol = Math.max(0, Math.min(1, Number(cfg.beepVolume) || 0.18));

    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.setValueAtTime(lowpassHz, now);

    // Envelope (gentle): quick attack, slow release
    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.linearRampToValueAtTime(vol, now + 0.02);
    gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, vol * 0.002), now + dur);

    // Soft sine tone
    osc.type = 'sine';
    osc.frequency.setValueAtTime(tone, now);

    osc.connect(lp);
    lp.connect(gain);
    gain.connect(ctx.destination);

    osc.start(now);
    osc.stop(now + dur + 0.03);
  }

  // ===================== IMAGE HASHING (dHash) =====================
  function gmFetchArrayBuffer(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        responseType: 'arraybuffer',
        onload: (res) => {
          if (res.status >= 200 && res.status < 300) {
            const ct = res.responseHeaders?.match(/content-type:\s*([^\r\n]+)/i)?.[1]?.trim() || '';
            resolve({ buf: res.response, contentType: ct });
          } else reject(new Error(`HTTP ${res.status}`));
        },
        onerror: () => reject(new Error('Network error')),
        ontimeout: () => reject(new Error('Timeout')),
      });
    });
  }
  function guessMime(url, hdrCt) {
    const u = (url || '').toLowerCase();
    if (hdrCt) return hdrCt.split(';')[0].trim();
    if (u.includes('.webp')) return 'image/webp';
    if (u.includes('.gif'))  return 'image/gif';
    if (u.includes('.png'))  return 'image/png';
    if (u.includes('.jpg') || u.includes('.jpeg')) return 'image/jpeg';
    return 'image/*';
  }
  async function computeDHashFromBlob(blob, size = 8) {
    const bitmap = await createImageBitmap(blob);
    const w = size + 1, h = size;
    const cnv = ('OffscreenCanvas' in window) ? new OffscreenCanvas(w, h) : document.createElement('canvas');
    cnv.width = w; cnv.height = h;
    const ctx = cnv.getContext('2d', { willReadFrequently: true });
    ctx.drawImage(bitmap, 0, 0, w, h);
    const img = ctx.getImageData(0, 0, w, h).data;

    const gray = new Array(w * h);
    for (let i = 0, p = 0; i < img.length; i += 4, p++) {
      const r = img[i], g = img[i+1], b = img[i+2];
      gray[p] = (0.299 * r + 0.587 * g + 0.114 * b) | 0;
    }

    let bits = 0n;
    for (let y = 0; y < h; y++) {
      for (let x = 0; x < size; x++) {
        const left  = gray[y * w + x];
        const right = gray[y * w + (x + 1)];
        const bit = left > right ? 1n : 0n;
        bits = (bits << 1n) | bit;
      }
    }
    return bits.toString(16).padStart(16, '0');
  }
  function hammingHex(aHex, bHex) {
    const a = BigInt('0x' + aHex);
    const b = BigInt('0x' + bHex);
    let x = a ^ b, cnt = 0;
    while (x) { cnt += Number(x & 1n); x >>= 1n; }
    return cnt;
  }
  function collectImages(root) {
    const urls = new Set();
    root.querySelectorAll(SEL.msgImages).forEach(i => i.src && urls.add(i.src));
    root.querySelectorAll(SEL.msgVideos).forEach(v => v.poster && urls.add(v.poster));
    root.querySelectorAll(SEL.msgVideoSources).forEach(s => s.src && urls.add(s.src));
    return [...urls];
  }

  const processedGlobal = new Map(); // url -> ts
  const msgUrlSeen = new WeakMap();  // Element -> Set<string>
  const isCooldownGlobal = (src, ms) => processedGlobal.has(src) && (Date.now() - processedGlobal.get(src)) < ms;
  const markGlobal = (src) => processedGlobal.set(src, Date.now());
  const msgHasSeen = (root, url) => !!(msgUrlSeen.get(root)?.has(url));
  function msgMarkSeen(root, url) { let s = msgUrlSeen.get(root); if (!s) { s = new Set(); msgUrlSeen.set(root, s); } s.add(url); }

  async function hashUrlToHex(url) {
    const { buf, contentType } = await gmFetchArrayBuffer(url);
    const mime = guessMime(url, contentType);
    const blob = new Blob([buf], { type: mime || 'image/*' });
    return await computeDHashFromBlob(blob, 8);
  }

  async function analyzeImagesForMessage(root) {
    const hashCfg = getHashCfg();
    if (!hashCfg.hashEnabled) return;
    if (!(root instanceof HTMLElement)) return;

    if (root.getAttribute('data-qs-hash-scanned') === '1') return;

    const urls = collectImages(root);
    if (!urls.length) { root.setAttribute('data-qs-hash-scanned', '1'); return; }

    let beepedForThisMessage = false;

    for (const url of urls) {
      if (!/^https?:\/\//i.test(url)) continue;
      if (isCooldownGlobal(url, hashCfg.cooldownMs)) continue;
      if (msgHasSeen(root, url)) continue;

      markGlobal(url);
      msgMarkSeen(root, url);

      try {
        const username = getUsernameFromRoot(root);
        if (!username) continue;

        const usernameLcTmp = username.toLowerCase();
        if (viewerNameLc && usernameLcTmp === viewerNameLc) continue;

        const nameWrap = root.querySelector(SEL.usernameContainer);
        if (!nameWrap) continue;

        const isTargetModOrBroadcaster =
          nameWrap.classList.contains('mod') || nameWrap.classList.contains('broadcaster');

        if (isTargetModOrBroadcaster && !hashCfg.includeMods) continue;

        const myHash = await hashUrlToHex(url);
        const refs = getHashRefs();
        if (!refs.length) continue;

        let best = { ref: null, dist: Infinity };
        for (const r of refs) {
          const d = hammingHex(myHash, r.hashHex);
          if (d < best.dist) best = { ref: r, dist: d };
        }

        const thr = hashCfg.threshold;
        log(`[QS][HASH] @${username} ${myHash} → best=${best.dist} (thr=${thr}) ${best.ref ? `vs ${best.ref.name}` : ''}`);

        if (best.dist <= thr) {
          if (!beepedForThisMessage || !hashCfg.beepOncePerMessage) {
            playBeep();
            beepedForThisMessage = true;
            root.setAttribute('data-qs-hash-beeped', '1');
          }

          if (isPrivileged && getCfg('enableAutoSilence') && !isTargetModOrBroadcaster) {
            const ok = await postSilence(username);
            toast(ok ? `🤫 Auto-silenced @${username} (image hash)` : `⚠️ Auto-silence failed @${username}`);
            root.setAttribute('data-qs-silenced', ok ? '1' : '0');
            if (ok) {
              upsertSilenced({ username, note: 'image-hash', mode: 'auto', reason: `hash≤${thr}`, room: currentRoom, ts: Date.now() });
            }
          } else if (getCfg('enableHighlighting')) {
            root.classList.add('qs_flagged_word');
            root.setAttribute('data-qs-silenced', '0');
          }
        }
      } catch (e) {
        console.warn('[QS][HASH] analyze error', e);
      }
    }

    root.setAttribute('data-qs-hash-scanned', '1');
  }

  // ===================== QS core (text/users) =====================
  function mountButtonForMessage(root) {
    if (!(root instanceof HTMLElement) || root.dataset.qsBtn === '1') return;
    const nameWrap = root.querySelector(SEL.usernameContainer);
    if (!nameWrap) return;
    const username = getUsernameFromRoot(root);
    if (!username) return;
    const usernameLcTmp = username.toLowerCase();
    const msgText = (root.textContent || '').toLowerCase();

    // Self-Check
    if (viewerNameLc && usernameLcTmp === viewerNameLc) {
      root.dataset.qsBtn = '1';
      return;
    }

    // Klassen-Check for UI/button/auto logic (original QS)
    if (shouldExcludeByClass(nameWrap)) {
      root.dataset.qsBtn = '1';
      return;
    }

    const triggeredByWord = TRIGGER_WORDS.find(word => word && msgText.includes(word));
    const triggeredByUser = AUTO_SILENCE_USERS.includes(usernameLcTmp);

    // manual silence button — NO NOTE PROMPT
    if (isPrivileged && getCfg('showSilenceButtons')) {
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'qs_sil_btn';
      btn.textContent = 'Silence';
      btn.addEventListener('click', async (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        const ok = await postSilence(username);
        toast(ok ? `🛑 Silenced @${username}` : `⚠️ Forbidden @${username}`);
        root.setAttribute('data-qs-silenced', ok ? '1' : '0');
        if (ok) {
          upsertSilenced({
            username,
            note: 'manual',
            mode: 'manual',
            reason: 'manual',
            room: currentRoom,
            ts: Date.now(),
          });
        }
      });
      nameWrap.appendChild(btn);
      root.dataset.qsBtn = '1';
    }

    // auto (QS text/user logic)
    if ((triggeredByUser || triggeredByWord) && root.getAttribute('data-qs-silenced') !== '1') {
      if (isPrivileged && getCfg('enableAutoSilence')) {
        postSilence(username).then(ok => {
          toast(ok
            ? `🤫 Auto-silenced @${username}${triggeredByWord ? ` for "${triggeredByWord}"` : ''}`
            : `⚠️ Auto-silence failed @${username}`);
          if (ok || getCfg('enableHighlighting')) playBeep(); // soft cue
          root.setAttribute('data-qs-silenced', ok ? '1' : '0');
          if (ok) {
            const note = triggeredByUser ? 'listed user' : (triggeredByWord ? `trigger: "${triggeredByWord}"` : 'auto');
            upsertSilenced({ username, note, mode: 'auto', reason: triggeredByWord || (triggeredByUser ? 'listed user' : ''), room: currentRoom, ts: Date.now() });
          }
        });
      } else if (!isPrivileged && getCfg('enableHighlighting')) {
        if (triggeredByUser) root.classList.add('qs_flagged_user');
        if (triggeredByWord) root.classList.add('qs_flagged_word');
        playBeep(); // soft cue
        root.setAttribute('data-qs-silenced', '0');
      }
    }

    // NEW: kick off image-hash analysis for this message
    analyzeImagesForMessage(root);
  }

  function scanExisting() {
    document.querySelectorAll(SEL.rootMessage).forEach(mountButtonForMessage);
  }
  function rescanExistingForHighlights() {
    if (!getCfg('enableHighlighting')) return;
    document.querySelectorAll(SEL.rootMessage).forEach(root => {
      root.classList.remove('qs_flagged_user','qs_flagged_word');
      root.removeAttribute('data-qs-silenced');
      mountButtonForMessage(root);
    });
  }

  const msgObserver = new MutationObserver((muts) => {
    for (const m of muts) for (const node of m.addedNodes) {
      if (!(node instanceof HTMLElement)) continue;
      if (node.matches?.(SEL.rootMessage)) {
        mountButtonForMessage(node);
      } else {
        node.querySelectorAll?.(SEL.rootMessage).forEach(mountButtonForMessage);
      }
    }
  });

  function stopAll() {
    if (!running) return;
    msgObserver.disconnect();
    document.querySelectorAll('.qs_sil_btn').forEach(btn => btn.remove());
    running = false;
    isPrivileged = false;
  }

  async function startForRoom(room) {
    stopAll();

    const broadcasterMode = isBroadcasterView();

    const viewerEl = await waitForSelector(SEL.viewerUsername, 5000);
    const viewer = viewerEl?.textContent?.trim() || '';
    viewerNameLc = (viewer || '').toLowerCase();

    if (!viewer && !broadcasterMode) return;

    if (broadcasterMode) {
      isPrivileged = true;
    } else {
      isPrivileged = await fetchIsPrivileged(viewer, room);
    }

    log('Room:', room, 'Viewer:', viewer, 'BroadcasterMode:', broadcasterMode, 'isPrivileged:', isPrivileged);
    running = true;

    if (hasSupabase()) {
      await syncFromSupabaseOnce();
      startPolling();
    } else {
      toast('ℹ️ Using local fallback lists (no Supabase config)');
    }

    scanExisting();
    msgObserver.observe(document.body, { childList: true, subtree: true });
  }

  function handleRoomEnter() {
    const room = getRoomFromURL();
    if (!room || room === currentRoom) return;
    currentRoom = room;
    startForRoom(room);
  }

  // SPA locationchange hook
  (() => {
    const _ps = history.pushState, _rs = history.replaceState;
    history.pushState = function (...a) { const r = _ps.apply(this, a); window.dispatchEvent(new Event('locationchange')); return r; };
    history.replaceState = function (...a) { const r = _rs.apply(this, a); window.dispatchEvent(new Event('locationchange')); return r; };
    window.addEventListener('popstate', () => window.dispatchEvent(new Event('locationchange')));
    window.addEventListener('locationchange', handleRoomEnter);
  })();

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', handleRoomEnter, { once: true });
  } else {
    handleRoomEnter();
  }

  // ===================== DB Overlay (unchanged) =====================
  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
  }
  function fmtTs(ts) {
    if (!ts) return '';
    try {
      const d = new Date(ts);
      const p = (n)=>String(n).padStart(2,'0');
      return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
    } catch { return String(ts); }
  }
  function ensureDbOverlay() {
    let el = document.getElementById('qs_db_overlay');
    if (el) return el;
    el = document.createElement('div');
    el.id = 'qs_db_overlay';
    el.innerHTML = `
      <header>
        <span>DB: Auto-silence users (active)</span>
        <div class="qs_actions">
          <button type="button" data-action="refresh">Refresh</button>
          <button type="button" data-action="export">Export JSON</button>
          <button type="button" data-action="close">Close</button>
        </div>
      </header>
      <div class="qs_body"><div class="qs_empty">Loading…</div></div>
    `;
    el.querySelector('[data-action="close"]').addEventListener('click', () => el.remove());
    el.querySelector('[data-action="export"]').addEventListener('click', async () => {
      const data = await fetchUsersDetailed().catch(()=>[]);
      const pretty = JSON.stringify(data, null, 2);
      const w = window.open('', '_blank', 'noopener,noreferrer,width=700,height=700');
      if (w) {
        w.document.write(`<pre style="white-space:pre-wrap;font:12px/1.4 monospace;padding:12px;margin:0;background:#0e0e10;color:#e9e9ea">${escapeHtml(pretty)}</pre>`);
        w.document.close();
      } else {
        prompt('Copy JSON:', pretty);
      }
    });
    el.querySelector('[data-action="refresh"]').addEventListener('click', () => renderDbOverlay(el, true));
    document.body.appendChild(el);
    return el;
  }
  async function renderDbOverlay(el = ensureDbOverlay(), force = false) {
    const body = el.querySelector('.qs_body');
    body.innerHTML = `<div class="qs_empty">Loading…</div>`;
    try {
      if (!hasSupabase()) { body.innerHTML = `<div class="qs_empty">Supabase config missing.</div>`; return; }
      const rows = await fetchUsersDetailed();
      if (!rows.length) { body.innerHTML = `<div class="qs_empty">No active DB entries.</div>`; return; }
      rows.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
      const html = [
        `<table>`,
        `<thead><tr><th style="width:30%">Username</th><th style="width:36%">Note/Reason</th><th style="width:14%">Source</th><th style="width:20%">Updated</th></tr></thead>`,
        `<tbody>`,
        ...rows.map(r => `
          <tr>
            <td><strong>@${escapeHtml(r.username)}</strong><div class="qs_small">${r.is_active ? 'active' : 'inactive'}</div></td>
            <td>${escapeHtml(r.note || r.reason || '')}</td>
            <td>${escapeHtml(r.source || '')}</td>
            <td><span title="created: ${escapeHtml(fmtTs(r.created_at))}">${escapeHtml(fmtTs(r.updated_at))}</span></td>
          </tr>
        `),
        `</tbody></table>`
      ].join('');
      body.innerHTML = html;
    } catch (e) {
      body.innerHTML = `<div class="qs_empty">Error loading DB list: ${escapeHtml(e.message||String(e))}</div>`;
    }
  }
  function openDbOverlay() {
    const el = ensureDbOverlay();
    renderDbOverlay(el, true);
  }

  // ===================== Floating Settings Window =====================
  function ensureSettingsUI() {
    if (document.getElementById('qs_cfg_btn')) return;

    const btn = document.createElement('button');
    btn.id = 'qs_cfg_btn';
    btn.textContent = 'QS ⚙️';
    btn.title = 'Quick Silence Settings';
    document.body.appendChild(btn);

    const panel = document.createElement('div');
    panel.id = 'qs_cfg_panel';
    panel.innerHTML = `
      <header>
        <span>Quick Silence Settings</span>
        <div>
          <button type="button" data-action="close">Close</button>
        </div>
      </header>
      <div class="qs_body">
        <div class="row"><strong class="muted">Core</strong></div>
        <div class="row">
          <label><input type="checkbox" data-k="enableAutoSilence"> Auto Silence</label>
          <label><input type="checkbox" data-k="enableHighlighting"> Highlight Matches</label>
          <label><input type="checkbox" data-k="showSilenceButtons"> Show Silence Buttons (mods)</label>
        </div>
        <div class="row">
          <label>Poll interval (sec) <input type="number" min="10" step="1" style="width:80px" data-k="pollSec"></label>
          <button type="button" data-action="sync">Sync now (Supabase)</button>
          <button type="button" data-action="db">Show DB list</button>
        </div>

        <hr style="border:none;border-top:1px solid #2a2a2e;margin:8px 0">

        <div class="row"><strong>Image Hash Guard</strong> <span class="muted">(dHash 64-bit)</span></div>
        <div class="row">
          <label><input type="checkbox" data-hk="hashEnabled"> Enable image-hash detection</label>
          <label>Threshold (0..64) <input type="number" min="0" max="64" step="1" style="width:80px" data-hk="threshold"></label>
          <label><input type="checkbox" data-hk="includeMods"> Detect in mod/broadcaster messages</label>
        </div>
        <div class="row">
          <button type="button" data-action="addRefs">Add reference images…</button>
          <button type="button" data-action="clearRefs">Clear references</button>
        </div>
        <div class="row">
          <table>
            <thead><tr><th style="width:46%">Name</th><th style="width:36%">Hash</th><th>Actions</th></tr></thead>
            <tbody id="qs_refs_tbody"><tr><td colspan="3" class="muted">No references yet.</td></tr></tbody>
          </table>
        </div>

        <hr style="border:none;border-top:1px solid #2a2a2e;margin:8px 0">

        <div class="row"><strong>Audio (soft)</strong></div>
        <div class="row">
          <label><input type="checkbox" data-hk="beepOnMatch"> Beep on match</label>
          <label>Volume <input type="range" min="0" max="1" step="0.01" style="width:150px" data-hk="beepVolume"></label>
          <label>Tone (Hz) <input type="number" min="200" max="4000" step="10" style="width:90px" data-hk="beepToneHz"></label>
          <label>Duration (s) <input type="number" min="0.05" max="1" step="0.01" style="width:80px" data-hk="beepDuration"></label>
          <label>Lowpass (Hz) <input type="number" min="200" max="8000" step="50" style="width:90px" data-hk="beepLowpassHz"></label>
          <label><input type="checkbox" data-hk="beepOncePerMessage"> Once per message</label>
          <button type="button" data-action="testBeep">Test</button>
        </div>

      </div>
    `;
    document.body.appendChild(panel);

    function refreshPanelValues() {
      const pollSec = Math.max(10, ((Number(getCfg('pollMs')) || DEFAULT_POLL_MS) / 1000) | 0);
      panel.querySelector('[data-k="enableAutoSilence"]').checked = !!getCfg('enableAutoSilence');
      panel.querySelector('[data-k="enableHighlighting"]').checked = !!getCfg('enableHighlighting');
      panel.querySelector('[data-k="showSilenceButtons"]').checked = !!getCfg('showSilenceButtons');
      panel.querySelector('[data-k="pollSec"]').value = String(pollSec);

      const hc = getHashCfg();
      panel.querySelector('[data-hk="hashEnabled"]').checked = !!hc.hashEnabled;
      panel.querySelector('[data-hk="threshold"]').value = String(hc.threshold);
      panel.querySelector('[data-hk="includeMods"]').checked = !!hc.includeMods;

      panel.querySelector('[data-hk="beepOnMatch"]').checked = !!hc.beepOnMatch;
      panel.querySelector('[data-hk="beepVolume"]').value = String(Math.max(0, Math.min(1, hc.beepVolume)));
      panel.querySelector('[data-hk="beepOncePerMessage"]').checked = !!hc.beepOncePerMessage;
      panel.querySelector('[data-hk="beepToneHz"]').value = String(hc.beepToneHz);
      panel.querySelector('[data-hk="beepDuration"]').value = String(hc.beepDuration);
      panel.querySelector('[data-hk="beepLowpassHz"]').value = String(hc.beepLowpassHz);

      renderRefsTable();
    }

    function renderRefsTable() {
      const tbody = panel.querySelector('#qs_refs_tbody');
      const refs = getHashRefs();
      if (!refs.length) { tbody.innerHTML = `<tr><td colspan="3" class="muted">No references yet.</td></tr>`; return; }
      tbody.innerHTML = refs.map(r => `
        <tr data-id="${r.id}">
          <td>${escapeHtml(r.name || '(unnamed)')}</td>
          <td><code>${escapeHtml(r.hashHex)}</code></td>
          <td><button type="button" data-action="delRef" data-id="${r.id}">Delete</button></td>
        </tr>
      `).join('');
    }

    btn.addEventListener('click', () => {
      if (panel.style.display === 'flex') { panel.style.display = 'none'; return; }
      refreshPanelValues();
      panel.style.display = 'flex';
      try { getAudioCtx()?.resume?.(); } catch {}
    });
    panel.querySelector('[data-action="close"]').addEventListener('click', () => { panel.style.display = 'none'; });

    // Core toggles
    panel.querySelector('[data-k="enableAutoSilence"]').addEventListener('change', (e) => setCfg('enableAutoSilence', !!e.target.checked));
    panel.querySelector('[data-k="enableHighlighting"]').addEventListener('change', (e) => { setCfg('enableHighlighting', !!e.target.checked); rescanExistingForHighlights(); });
    panel.querySelector('[data-k="showSilenceButtons"]').addEventListener('change', (e) => setCfg('showSilenceButtons', !!e.target.checked));
    panel.querySelector('[data-k="pollSec"]').addEventListener('change', (e) => {
      const sec = Math.max(10, Number(e.target.value) || (DEFAULT_POLL_MS/1000));
      setCfg('pollMs', sec * 1000);
      startPolling();
      toast(`⏱️ Polling every ${sec}s`);
    });
    panel.querySelector('[data-action="sync"]').addEventListener('click', async () => { await syncFromSupabaseOnce(); toast('Supabase synced'); });
    panel.querySelector('[data-action="db"]').addEventListener('click', () => openDbOverlay());

    // Hash toggles
    panel.querySelector('[data-hk="hashEnabled"]').addEventListener('change', (e) => setHashCfg({ hashEnabled: !!e.target.checked }));
    panel.querySelector('[data-hk="threshold"]').addEventListener('change', (e) => {
      const n = Math.max(0, Math.min(64, Number(e.target.value) || HASH_DEFAULTS.threshold));
      setHashCfg({ threshold: n });
      e.target.value = String(n);
    });
    panel.querySelector('[data-hk="includeMods"]').addEventListener('change', (e) => setHashCfg({ includeMods: !!e.target.checked }));

    // Audio controls
    panel.querySelector('[data-hk="beepOnMatch"]').addEventListener('change', (e) => setHashCfg({ beepOnMatch: !!e.target.checked }));
    panel.querySelector('[data-hk="beepVolume"]').addEventListener('input', (e) => setHashCfg({ beepVolume: Math.max(0, Math.min(1, Number(e.target.value))) }));
    panel.querySelector('[data-hk="beepOncePerMessage"]').addEventListener('change', (e) => setHashCfg({ beepOncePerMessage: !!e.target.checked }));
    panel.querySelector('[data-hk="beepToneHz"]').addEventListener('change', (e) => setHashCfg({ beepToneHz: Number(e.target.value) || HASH_DEFAULTS.beepToneHz }));
    panel.querySelector('[data-hk="beepDuration"]').addEventListener('change', (e) => setHashCfg({ beepDuration: Number(e.target.value) || HASH_DEFAULTS.beepDuration }));
    panel.querySelector('[data-hk="beepLowpassHz"]').addEventListener('change', (e) => setHashCfg({ beepLowpassHz: Number(e.target.value) || HASH_DEFAULTS.beepLowpassHz }));
    panel.querySelector('[data-action="testBeep"]').addEventListener('click', () => { try { getAudioCtx()?.resume?.(); } catch {}; playBeep(); });

    // Refs management
    panel.addEventListener('click', (e) => {
      const t = e.target;
      if (!(t instanceof HTMLElement)) return;
      const act = t.getAttribute('data-action');

      if (act === 'addRefs') {
        const input = document.createElement('input');
        input.type = 'file'; input.multiple = true; input.accept = 'image/*'; input.style.display = 'none';
        document.body.appendChild(input);
        input.addEventListener('change', async () => {
          const files = Array.from(input.files || []);
          input.remove();
          if (!files.length) return;
          const refs = getHashRefs();
          for (const f of files) {
            try {
              const buf = await f.arrayBuffer();
              const blob = new Blob([buf], { type: f.type || 'image/*' });
              const hashHex = await computeDHashFromBlob(blob, 8);
              refs.push({ id: `${Date.now()}_${Math.random().toString(36).slice(2,8)}`, name: f.name, mime: f.type, size: f.size, hashHex });
              log('[QS][HASH] added', f.name, hashHex);
            } catch (err) {
              console.warn('[QS][HASH] failed to hash', f.name, err);
            }
          }
          setHashRefs(refs);
          renderRefsTable();
        }, { once: true });
        input.click();
      }

      if (act === 'clearRefs') {
        if (!confirm('Remove all stored reference hashes?')) return;
        setHashRefs([]);
        renderRefsTable();
      }

      if (act === 'delRef') {
        const id = t.getAttribute('data-id');
        if (!id) return;
        const refs = getHashRefs().filter(r => r.id !== id);
        setHashRefs(refs);
        renderRefsTable();
      }
    });

    try { GM_registerMenuCommand?.('Open Quick Silence Settings', () => { refreshPanelValues(); panel.style.display = 'flex'; try { getAudioCtx()?.resume?.(); } catch {}; }); } catch {}
  }
  ensureSettingsUI();

})();