enhances the fishtank.rip chat experience and adds a live stox ticker, inline image/video/social network embeds and so much more.
// ==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}/ — 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
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);
})();