Not affiliated with Chaturbate. Deep rewind player (minutes of scrubbable buffer) with its own controls, player on/off next to Send Tip, save the buffer as a file, tip-sound mute and volume, dark theme, chat translate, hover previews, room notes, multi cam, site cleanup.
// ==UserScript== // @name Chaturbate Enhanced Plus // @namespace chaturbate.enhanced.plus // @version 0.9.6 // @description Not affiliated with Chaturbate. Deep rewind player (minutes of scrubbable buffer) with its own controls, player on/off next to Send Tip, save the buffer as a file, tip-sound mute and volume, dark theme, chat translate, hover previews, room notes, multi cam, site cleanup. // @match https://chaturbate.com/* // @match https://*.chaturbate.com/* // @exclude https://secure.chaturbate.com/* // @exclude https://*.chaturbate.com/auth/* // @exclude https://*.chaturbate.com/security/* // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js // @grant none // @run-at document-start // @author Chaturbate Enhanced Plus contributors // @icon https://chaturbate.com/favicon.ico // @license MIT // ==/UserScript== (function () { 'use strict'; /* ================================================================== * * settings * ================================================================== */ var KEY = 'cbx-settings'; var MULTI_KEY = 'cbx-multi-rooms'; var HIDE_KEY = 'cbx-hidden-selectors'; var BLOCK_KEY = 'cbx-blocked-rooms'; var NOTES_KEY = 'cbx-room-notes'; var POS_KEY = 'cbx-launcher-pos'; var WATCH_KEY = 'cbx-alert-list'; var SEEN_KEY = 'cbx-seen-online'; var DEFAULTS = { // sound blockSoundFx: true, tipSliderZero: true, tipVolume: 0, bgMute: true, // video inlinePreview: true, hoverPreview: true, previewInline: true, previewMuted: true, cardWatchBtn: true, showDuration: false, pipButton: true, deepPlayer: true, playerMode: 'deep', rewindBar: true, dvrMode: true, dvrBuffer: 300, dvrQuality: 720, bigBuffer: false, parkSite: true, mobileHeight: 0, uiGen: 4, clipSave: false, keyShortcuts: true, dblTapSeek: true, autoQuality: true, qualityCap: 0, // appearance forceDark: true, hideAds: true, hideSocials: true, hidePlayerLogo: true, tightMargins: true, hideBadges: true, biggerCards: false, cleanProfile: true, // rooms bioInfo: false, cardTools: true, openNewTab: true, autoChatRules: false, // chat translateChat: false, translateTo: 'en', chatHideNotices: true, chatHideSubject: true, chatHideTips: false, chatHideGreys: false, // multi cam multiCam: false, multiShowSubject: true, multiResizable: true, multiHideOffline: true, multiAutoRemove: false, multiMaxHeight: 720, // tabs + alerts inactivePause: false, inactiveQuality: true, exclusiveAudio: false, alertsOn: false, alertEvery: 60, trackSchedule: true, // layout gridSize: 0, hideGenderF: false, hideGenderM: false, hideGenderC: false, hideGenderT: false, time24: false, // panel uiLang: 'auto', randomGenders: 'f', randomCount: 6, showLauncher: true, edgeSwipe: true, debug: false }; var MINIMAL_SET = ['hideAds', 'hideSocials', 'hidePlayerLogo', 'tightMargins', 'hideBadges', 'cleanProfile', 'chatHideNotices', 'chatHideSubject']; var S = readSettings(); function readSettings() { var out = {}, k; for (k in DEFAULTS) out[k] = DEFAULTS[k]; try { var raw = JSON.parse(localStorage.getItem(KEY) || '{}'); for (k in DEFAULTS) if (typeof raw[k] === typeof DEFAULTS[k]) out[k] = raw[k]; var gen = raw.uiGen || 1; // 1.9.9: the built-in bar became the player; browser controls are opt-in now if (gen < 3) { out.bioInfo = false; } // 0.9.6: one switch — deep rewind player on, or the plain site player if (gen < 4) { if (typeof raw.rewindBar === 'boolean' || typeof raw.dvrMode === 'boolean') out.deepPlayer = raw.rewindBar !== false && raw.dvrMode !== false; out.cardWatchBtn = true; out.openNewTab = true; } else if (typeof raw.deepPlayer !== 'boolean' && typeof raw.playerMode === 'string') { out.deepPlayer = raw.playerMode !== 'off'; } out.uiGen = 4; } catch (e) {} out.playerMode = out.deepPlayer ? 'deep' : 'off'; out.rewindBar = out.deepPlayer; out.dvrMode = out.deepPlayer; out.tipVolume = Math.max(0, Math.min(100, Math.round(out.tipVolume) || 0)); return out; } function save() { try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) {} } function log() { if (!S.debug) return; try { console.log.apply(console, ['[cep]'].concat([].slice.call(arguments))); } catch (e) {} } var IS_MULTI = /[?&]cbx-multi=1/.test(location.search); /* ================================================================== * * helpers * ================================================================== */ function $(s, r) { try { return (r || document).querySelector(s); } catch (e) { return null; } } function $$(s, r) { try { return [].slice.call((r || document).querySelectorAll(s)); } catch (e) { return []; } } function el(tag, attrs, html) { var n = document.createElement(tag); if (attrs) for (var k in attrs) n.setAttribute(k, attrs[k]); if (html != null) n.innerHTML = html; return n; } function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); } function onReady(fn) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn(); } var NON_ROOM = /^(tags|messages|accounts|affiliates|apps|search|my|api|b|static|photo_videos|supporter|tipping|external_link|privacy|terms|roomlist|feedback|contest|security|auth|signup|login|logout|terms-of-service|dmca|support|help|about)$/; function isRoomPath(seg) { if (!seg || NON_ROOM.test(seg)) return false; // every listing page ends in -cams: female-cams, couple-cams, new-cams, followed-cams… if (/-cams$/.test(seg)) return false; return true; } function roomName() { var seg = location.pathname.split('/').filter(Boolean)[0] || ''; return isRoomPath(seg) ? seg : null; } function jsonGet(key, fb) { try { var v = JSON.parse(localStorage.getItem(key) || 'null'); return v == null ? fb : v; } catch (e) { return fb; } } function jsonSet(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (e) {} } function fmtTime(t) { t = Math.max(0, Math.floor(t)); var h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60; return (h ? h + ':' + ('0' + m).slice(-2) : m) + ':' + ('0' + s).slice(-2); } var MAIN_PLAYER_SEL = '#video-panel,#VideoPanel,#main-video,[data-testid="room-player"],[data-testid="video-panel"],.VideoPanel,.video-player-panel,#TheaterModePlayer'; function isLivePlaying(v) { return v && !v.paused && v.readyState > 2 && v.currentTime > 0; } /* ================================================================== * * sound * ================================================================== */ var blockedCount = 0; var webAudioBlocked = 0; function installWebAudioBlock() { // Chaturbate plays tip beeps through Web Audio. Short effects are // AudioBufferSourceNode / OscillatorNode; the cam stream arrives as a // MediaElementSource, so blocking only these two leaves audio alone. ['AudioBufferSourceNode', 'OscillatorNode'].forEach(function (name) { var Ctor = window[name]; if (!Ctor || !Ctor.prototype || !Ctor.prototype.start) return; var origStart = Ctor.prototype.start; Ctor.prototype.start = function () { if (S.blockSoundFx) { webAudioBlocked++; blockedCount++; log('blocked a Web Audio effect', name, 'total', webAudioBlocked); return; } return origStart.apply(this, arguments); }; }); // Belt and braces: if a buffer source is wired straight to the speakers // through a gain node, keep that path silent too. ['AudioContext', 'webkitAudioContext'].forEach(function (name) { var Ctx = window[name]; if (!Ctx || !Ctx.prototype || !Ctx.prototype.decodeAudioData) return; var orig = Ctx.prototype.decodeAudioData; Ctx.prototype.decodeAudioData = function () { if (S.blockSoundFx) log('a sound effect was decoded'); return orig.apply(this, arguments); }; }); } function installSoundBlock() { installWebAudioBlock(); var origPlay = HTMLMediaElement.prototype.play; HTMLMediaElement.prototype.play = function () { try { if (this instanceof HTMLVideoElement) { if (S.inlinePreview) tagInline(this); } else if (S.blockSoundFx) { blockedCount++; log('blocked sound effect', this.currentSrc || this.src); try { this.pause(); this.muted = true; this.volume = 0; } catch (e) {} return Promise.resolve(); } } catch (e) {} return origPlay.apply(this, arguments); }; var OrigAudio = window.Audio; if (OrigAudio) { function PatchedAudio(src) { var a = new OrigAudio(src); try { if (S.blockSoundFx) { a.muted = true; a.volume = 0; } } catch (e) {} return a; } PatchedAudio.prototype = OrigAudio.prototype; try { window.Audio = PatchedAudio; } catch (e) {} } } var SEL_SLIDER = '[data-testid="tip-volume-slider"]'; var SEL_LABEL = '[data-testid="tip-volume-value-label"]'; var tipStatus = 'not tried yet'; var tipDone = false; function readTipVolume() { var l = $(SEL_LABEL); if (!l) return null; var m = (l.textContent || '').match(/(\d+)\s*%/); return m ? parseInt(m[1], 10) : null; } function mouseDrag(slider, x, y) { var handle = slider.lastElementChild || slider; function fire(t, type) { t.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, buttons: 1 })); } function firePointer(t, type) { if (!window.PointerEvent) return; t.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, buttons: 1, pointerId: 1, pointerType: 'mouse', isPrimary: true })); } firePointer(handle, 'pointerdown'); fire(handle, 'mousedown'); firePointer(document, 'pointermove'); fire(document, 'mousemove'); fire(slider, 'mousemove'); firePointer(document, 'pointerup'); fire(document, 'mouseup'); } function touchDrag(slider, x, y) { var handle = slider.lastElementChild || slider, touch, list; try { if (typeof document.createTouch === 'function') { touch = document.createTouch(window, handle, 1, x, y, x, y); list = document.createTouchList(touch); } else { touch = new Touch({ identifier: 1, target: handle, clientX: x, clientY: y, pageX: x, pageY: y }); list = [touch]; } } catch (e) { return false; } function fire(type, target) { var ev; try { ev = new TouchEvent(type, { bubbles: true, cancelable: true, view: window, touches: type === 'touchend' ? [] : [touch], targetTouches: type === 'touchend' ? [] : [touch], changedTouches: [touch] }); } catch (e) { try { ev = document.createEvent('TouchEvent'); ev.initTouchEvent(type, true, true, window, 0, 0, 0, x, y, false, false, false, false, list, list, list, 1, 0); } catch (e2) { return false; } } target.dispatchEvent(ev); return true; } return fire('touchstart', handle) && fire('touchmove', document) && fire('touchend', document); } // The site's slider is its own component (not an <input>), so we can only // drive it with events. Each rung is tried and the label read back; the // first one that lands within a point of the target wins. tipRung says which. var tipRung = ''; function tipLanded(pct) { var v = readTipVolume(); return v !== null && Math.abs(v - pct) <= 1; } function trackClick(slider, x, y) { function fire(t, type, ctor, extra) { var init = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, button: 0, buttons: type.indexOf('up') > -1 ? 0 : 1 }; for (var k in extra) init[k] = extra[k]; try { t.dispatchEvent(new ctor(type, init)); } catch (e) {} } var pe = window.PointerEvent ? { pointerId: 1, pointerType: 'mouse', isPrimary: true } : null; if (pe) fire(slider, 'pointerdown', PointerEvent, pe); fire(slider, 'mousedown', MouseEvent); if (pe) fire(document, 'pointermove', PointerEvent, pe); fire(document, 'mousemove', MouseEvent); fire(slider, 'mousemove', MouseEvent); if (pe) fire(document, 'pointerup', PointerEvent, pe); fire(document, 'mouseup', MouseEvent); fire(slider, 'mouseup', MouseEvent); fire(slider, 'click', MouseEvent); } function setTipVolume(slider, pct) { var r = slider.getBoundingClientRect(); if (!r.width) return false; pct = Math.max(0, Math.min(100, pct)); // aim a whisker inside the ends so a click-to-position handler cannot clamp us out var x = r.left + Math.max(1, Math.min(r.width - 1, r.width * pct / 100)), y = r.top + r.height / 2; var rungs = [ ['track click', function () { trackClick(slider, x, y); }], ['handle drag', function () { mouseDrag(slider, x, y); }], ['track drag from handle', function () { var h = slider.lastElementChild || slider, hr = h.getBoundingClientRect(); mouseDrag(slider, hr.left + hr.width / 2, y); mouseDrag(slider, x, y); }], ['touch drag', function () { touchDrag(slider, x, y); }] ]; for (var i = 0; i < rungs.length; i++) { try { rungs[i][1](); } catch (e) {} if (tipLanded(pct)) { tipRung = rungs[i][0]; return true; } } tipRung = 'none'; return false; } function setTipVolumeZero(slider) { return setTipVolume(slider, 0); } var SETTINGS_GUESSES = [ '[data-testid="chat-settings-btn"]', '[data-testid="chat-settings-icon"]', '[data-testid="settings-btn"]', '[data-testid="settings-icon"]', '[data-testid="chat-settings"]', '[data-testid="video-settings-btn"]' ]; function findSettingsToggle() { for (var g = 0; g < SETTINGS_GUESSES.length; g++) { var hit = $(SETTINGS_GUESSES[g]); if (hit) return hit; } var nodes = $$('[data-testid],[aria-label],[title],[id]'); for (var i = 0; i < nodes.length; i++) { var n = nodes[i]; var s = ((n.getAttribute('data-testid') || '') + ' ' + (n.getAttribute('aria-label') || '') + ' ' + (n.getAttribute('title') || '') + ' ' + (n.id || '')).toLowerCase(); if (/chat.?settings|settings.?(icon|button|toggle|tab)|gear/.test(s)) { if (n.closest && n.closest('#cbx-panel')) continue; return n; } } return null; } function applyTipMute(attempt) { if (!S.tipSliderZero || tipDone) return; attempt = attempt || 0; if (attempt > 12) { tipStatus = 'slider not reachable — sound blocking is doing the work'; refreshPanel(); return; } var slider = $(SEL_SLIDER); // present-but-hidden (settings tab closed) is the same problem as absent if (slider && !slider.getBoundingClientRect().width) slider = null; if (!slider) { var toggle = attempt === 0 ? findSettingsToggle() : null; if (toggle) { try { toggle.click(); } catch (e) {} setTimeout(function () { applyTipMute(attempt + 1); }, 450); return; } setTimeout(function () { applyTipMute(attempt + 1); }, 700); return; } var before = readTipVolume(), want = S.tipVolume; if (before !== null && Math.abs(before - want) <= 1) { tipDone = true; tipStatus = 'slider already at ' + before + '%'; refreshPanel(); return; } if (setTipVolume(slider, want)) { tipDone = true; tipStatus = 'slider set to ' + readTipVolume() + '% (was ' + before + '%) via ' + tipRung; if (attempt > 0) { var t = findSettingsToggle(); if (t) { try { t.click(); } catch (e) {} } } refreshPanel(); return; } tipStatus = 'slider found but would not move (at ' + readTipVolume() + '%, want ' + want + '%)'; setTimeout(function () { applyTipMute(attempt + 1); }, 700); } function tipDiagnostics() { var slider = $(SEL_SLIDER), label = $(SEL_LABEL), toggle = findSettingsToggle(); return [ 'status: ' + tipStatus, 'slider in DOM: ' + (slider ? (slider.getBoundingClientRect().width ? 'yes, visible' : 'yes, hidden') : 'no'), 'label reads: ' + (label ? label.textContent.trim() : '—'), 'settings toggle guess: ' + (toggle ? (toggle.getAttribute('data-testid') || toggle.getAttribute('aria-label') || toggle.id || toggle.tagName) : 'none found'), 'target: ' + (S.tipSliderZero ? S.tipVolume + '%' : 'not applied') + (tipRung ? ' · last rung: ' + tipRung : ''), 'sound effects blocked: ' + blockedCount + ' (' + webAudioBlocked + ' via Web Audio)' ].join('\n'); } /* ================================================================== * * video basics * ================================================================== */ function tagInline(v) { try { v.playsInline = true; v.setAttribute('playsinline', ''); v.setAttribute('webkit-playsinline', 'true'); } catch (e) {} } function isMainPlayer(v) { if (!v || !v.closest) return false; if (v.closest(MAIN_PLAYER_SEL) || v.closest('#cbx-watch')) return true; var r = v.getBoundingClientRect(); return !!roomName() && r.width > 0.6 * window.innerWidth; } function installInlinePreview() { document.addEventListener('webkitbeginfullscreen', function (e) { if (!S.inlinePreview) return; var v = e.target; if (!(v instanceof HTMLVideoElement) || isMainPlayer(v)) return; try { v.webkitExitFullscreen(); } catch (err) {} }, true); } var priorMuted = new WeakMap(); function applyBgMute(hidden) { if (!S.bgMute && hidden) return; $$('video').forEach(function (v) { if (hidden) { if (!priorMuted.has(v)) priorMuted.set(v, v.muted); v.muted = true; } else if (priorMuted.has(v)) { v.muted = priorMuted.get(v); priorMuted.delete(v); } }); } function installBgMute() { document.addEventListener('visibilitychange', function () { applyBgMute(document.hidden); applyInactive(document.hidden); }); document.addEventListener('play', function (e) { if (S.bgMute && document.hidden && e.target instanceof HTMLVideoElement) { if (!priorMuted.has(e.target)) priorMuted.set(e.target, e.target.muted); e.target.muted = true; } }, true); } /* ================================================================== * * player * * Site structure (measured): video.vjs-tech > #chat-player > * .videoPlayerDiv (absolute, overflow hidden) > #TheaterModePlayer * (relative, overflow hidden) — all the same size. The site's video is * an absolute layer filling that box. Ours is a second absolute layer in * the same box: picture above, controls beneath. The box is found by * walking up from the video while the ancestor has the same size, so * no selector and no rectangle maths are needed. * * Engine: hls.js (loaded by the userscript manager via @require). * ================================================================== */ function ranges(tr) { var out = []; if (!tr) return out; for (var i = 0; i < tr.length; i++) out.push(tr.start(i).toFixed(1) + '-' + tr.end(i).toFixed(1)); return out; } function seekWindow(v) { if (!v) return null; if (v.buffered && v.buffered.length) return { start: v.buffered.start(0), end: v.buffered.end(v.buffered.length - 1) }; if (v.seekable && v.seekable.length) return { start: v.seekable.start(0), end: v.seekable.end(v.seekable.length - 1) }; return null; } var P = { block: null, shell: null, bar: null, video: null, site: null, box: null, eng: null, busy: false, suspended: false, forcedSite: false, attempt: 0, armed: false, heldNote: '', heldSec: 0, ring: null }; var userHasInteracted = false; ['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) { document.addEventListener(ev, function () { userHasInteracted = true; }, { capture: true, once: true }); }); function dvrBudget() { var touch = document.documentElement.classList.contains('cbx-touch'); return (touch ? (S.bigBuffer ? 150 : 60) : (S.bigBuffer ? 400 : 120)) * 1024 * 1024; } // the site keeps a hidden theater-mode copy with the same ids: take the one that has a size function siteVideo() { var list = $$('video').filter(function (v) { return !v.closest('#cbx-hover') && !v.closest('#cbx-watch') && !v.closest('#cbx-block') && !v.classList.contains('cbx-inline-prev'); }); var vis = list.filter(function (v) { return v.getBoundingClientRect().width > 0; }); return vis[0] || list[0] || null; } function activeVideo() { if (P.video && P.video.isConnected && P.video.readyState >= 1) return P.video; return P.site && P.site.isConnected ? P.site : siteVideo(); } // the player box: outermost ancestor still the same size as the video function playerBox(site) { var r = site.getBoundingClientRect(), n = site, box = null; for (var i = 0; i < 6 && n.parentElement && n.parentElement !== document.body; i++) { var p = n.parentElement, pr = p.getBoundingClientRect(); if (Math.abs(pr.width - r.width) > 3 || Math.abs(pr.height - r.height) > 3) break; box = p; n = p; } return box || site.parentElement; } /* ---- engines ---- */ function hlsEngine(video, url) { var E = { kind: 'hls', h: null }; E.attach = function (onFatal) { return attachStream(video, url, { onFatal: onFatal, backBufferLength: S.dvrBuffer, liveDurationInfinity: true, capLevelToPlayerSize: false, lowLatencyMode: false, maxBufferLength: 30, maxMaxBufferLength: 60, liveSyncDurationCount: 2, maxLiveSyncPlaybackRate: 1, liveMaxLatencyDurationCount: Infinity, maxBufferHole: 1.5, nudgeMaxRetry: 10, startLevel: P.attempt > 0 ? 0 : -1 }).then(function () { E.h = video._cbxHls; if (!E.h) throw new Error('hls.js did not attach'); if (S.clipSave) E.ring(); }); }; E.tracks = function () { var h = E.h; if (!h || !h.levels) return []; return h.levels.map(function (l, i) { return { id: i, height: l.height || 0, fps: levelFps(l), bw: l.bitrate || 0, label: levelLabel(l) }; }) .sort(function (a, b) { return (b.height - a.height) || (b.fps - a.fps) || (b.bw - a.bw); }); }; E.active = function () { var h = E.h; if (!h) return null; var i = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel; return E.tracks().filter(function (t) { return t.id === i; })[0] || null; }; E.isAuto = function () { return !!E.h && E.h.autoLevelEnabled; }; E.pin = function (t) { var h = E.h; if (!h || !t) return; h.autoLevelCapping = -1; h.loadLevel = t.id; h.nextLevel = t.id; }; E.auto = function () { var h = E.h; if (h) { h.loadLevel = -1; h.nextLevel = -1; } }; E.retry = function () { try { E.h.startLoad(); } catch (e) {} }; E.goLive = function () { var h = E.h, w = seekWindow(video); if (h && h.liveSyncPosition != null) video.currentTime = h.liveSyncPosition; else if (w) video.currentTime = w.end - 0.5; }; E.setBehind = function (sec) { if (E.h) E.h.config.backBufferLength = sec; }; E.destroy = function () { try { if (E.h) E.h.destroy(); } catch (e) {} E.h = null; }; E.ring = function () { var h = E.h, ring = P.ring = { init: null, frags: [], bytes: 0, ext: 'mp4', level: -1 }; h.on(Hls.Events.FRAG_LOADED, function (_, d) { var f = d && d.frag, buf = d && d.payload; if (!f || !buf || f.type !== 'main' || !P.ring) return; if (f.sn === 'initSegment') { ring.init = buf; ring.level = f.level; return; } if (/\.ts(\?|$)/i.test(f.relurl || '')) ring.ext = 'ts'; if (f.level !== ring.level) { ring.frags = []; ring.bytes = 0; ring.init = null; ring.level = f.level; } ring.frags.push(buf); ring.bytes += buf.byteLength; while (ring.frags.length && ring.bytes > dvrBudget() / 2) ring.bytes -= ring.frags.shift().byteLength; }); }; return E; } function bestTrackUnder(cap) { var list = P.eng ? P.eng.tracks() : []; var ok = list.filter(function (t) { return t.height <= (cap || Infinity); }); return ok[0] || list[list.length - 1] || null; } function pinTrack(t) { if (!P.eng || !t) return; P.eng.pin(t); fitBufferToMemory(t); log('pinned to', t.label, '(no flush)'); } function fitBufferToMemory(t) { try { var budget = dvrBudget(); var bps = t && t.bw; if (!bps) return; var use = Math.max(60, Math.min(S.dvrBuffer, Math.floor(budget / (bps / 8)))); P.eng.setBehind(use); P.heldSec = use; if (use < S.dvrBuffer) { // which rendition would actually fit the window that was asked for? var fits = (P.eng.tracks() || []).filter(function (o) { return o.bw && budget / (o.bw / 8) >= S.dvrBuffer; })[0]; P.heldNote = 'holds ~' + fmtTime(use) + ' at ' + (t.label || t.height + 'p') + ' (asked for ' + fmtTime(S.dvrBuffer) + ')' + (fits ? ' — ' + (fits.label || fits.height + 'p') + ' would hold the full ' + fmtTime(S.dvrBuffer) : ' — no rendition fits that window in memory'); } else P.heldNote = 'holds up to ' + fmtTime(use) + ' at ' + (t.label || t.height + 'p'); log('back buffer', use + 's at', Math.round(bps / 1000) + 'kbps'); } catch (e) {} } /* ---- transport ---- */ function seekTo(v, target, quiet) { var before = v.currentTime; if (Math.abs(target - before) < 0.3) { if (!quiet) toast(target <= before ? 'Already at the start of the buffer' : 'Already live'); return false; } try { v.currentTime = target; } catch (e) { toast('This player refused the seek'); return false; } setTimeout(function () { var landed = Math.abs(v.currentTime - target) < 2; log('seek', before.toFixed(1), '->', target.toFixed(1), '->', v.currentTime.toFixed(1), landed ? 'ok' : 'REFUSED'); if (!landed && v !== P.video) toast('The site player pulled back to live — deep rewind is off on this room'); }, 400); return true; } function seekBack(sec) { var v = activeVideo(), w = seekWindow(v); if (!v || !w) { toast('Nothing held to rewind into'); return; } var want = v.currentTime - sec, target = Math.max(w.start + 0.3, want); if (!seekTo(v, target)) return; if (want < w.start) toast('Start of the buffer (' + fmtTime(w.end - w.start) + ' held)'); updateBar(); } function seekForward(sec) { var v = activeVideo(), w = seekWindow(v); if (!v || !w) return; var target = Math.min(w.end - 0.5, v.currentTime + sec); if (target >= w.end - 1.5) { goLive(); return; } seekTo(v, target); updateBar(); } function seekToStart() { var v = activeVideo(), w = seekWindow(v); if (v && w) { seekTo(v, w.start + 0.5); updateBar(); } } function goLive() { var v = activeVideo(), w = seekWindow(v); if (!v || !w) return; v.playbackRate = 1; if (v === P.video && P.eng) P.eng.goLive(); else seekTo(v, w.end - 0.5); var p = v.play(); if (p && p.catch) p.catch(function () {}); updateBar(); } function togglePause() { var v = activeVideo(); if (!v) return; if (v.paused) { var p = v.play(); if (p && p.catch) p.catch(function () {}); } else v.pause(); updateBar(); } function toggleMute() { var v = activeVideo(); if (!v) return; userHasInteracted = true; v.muted = !v.muted; if (!v.muted && v.volume === 0) v.volume = 0.5; updateBar(); } function toggleCatchUp() { var v = activeVideo(); if (!v) return; v.playbackRate = v.playbackRate > 1 ? 1 : 2; toast(v.playbackRate > 1 ? 'Catching up at 2×' : 'Normal speed'); } function snapshotFrame() { var v = activeVideo(); if (!v || !v.videoWidth) { toast('No picture to capture'); return; } try { var c = document.createElement('canvas'); c.width = v.videoWidth; c.height = v.videoHeight; c.getContext('2d').drawImage(v, 0, 0); var a = el('a', { download: (roomName() || 'cam') + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.png' }); a.href = c.toDataURL('image/png'); document.body.appendChild(a); a.click(); a.remove(); toast('Frame saved'); } catch (e) { toast('Could not capture this frame'); } } function togglePiP() { var v = activeVideo(); if (!v) { toast('No video on this page'); return; } try { if (document.pictureInPictureElement) document.exitPictureInPicture(); else if (v.requestPictureInPicture) v.requestPictureInPicture(); else toast('No picture in picture here'); } catch (e) { toast('Picture in picture was refused'); } } function goFullscreen() { var target = P.block || (activeVideo() && activeVideo().closest(MAIN_PLAYER_SEL)) || activeVideo(); if (!target) return; try { if (document.fullscreenElement) document.exitFullscreen(); else if (target.requestFullscreen) target.requestFullscreen(); else if (P.video && P.video.webkitEnterFullscreen) P.video.webkitEnterFullscreen(); } catch (e) {} } function saveClip() { var ring = P.ring; if (!ring) { toast('Turn on "Keep buffer for saving" in the Video tab, then reload the room', 5000); return; } if (!ring.frags.length) { toast('Nothing held yet'); return; } var parts = []; if (ring.ext === 'mp4') { if (!ring.init) { toast('No init segment captured yet; wait a moment'); return; } parts.push(ring.init); } parts = parts.concat(ring.frags); var blob = new Blob(parts, { type: ring.ext === 'mp4' ? 'video/mp4' : 'video/mp2t' }); var a = el('a', { download: (roomName() || 'cam') + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ring.ext }); a.href = URL.createObjectURL(blob); document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(a.href); }, 30000); toast('Saved ' + Math.round(blob.size / 1048576) + ' MB'); } /* ---- block + bar ---- */ var I = { back: '<svg viewBox="0 0 24 24"><path d="M12 5V2L7 6l5 4V7a5.5 5.5 0 1 1-5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>', fwd: '<svg viewBox="0 0 24 24"><path d="M12 5V2l5 4-5 4V7a5.5 5.5 0 1 0 5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>', play: '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>', pause: '<svg viewBox="0 0 24 24"><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>', vol: '<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9z" fill="currentColor"/><path d="M16 8.5a5 5 0 0 1 0 7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>', muted: '<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9z" fill="currentColor"/><path d="M16 9l5 6M21 9l-5 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>', cam: '<svg viewBox="0 0 24 24"><path d="M4 8h3l2-2h6l2 2h3v11H4z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>', save: '<svg viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 19h14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>', pip: '<svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><rect x="11" y="11" width="8" height="6" rx="1" fill="currentColor"/></svg>', fs: '<svg viewBox="0 0 24 24"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>' }; function barHTML() { return '<div class="cbx-row cbx-row-scrub"><span id="cbx-scrub-at" class="cbx-time">live</span>' + '<div id="cbx-scrub" role="slider" title="Full = live. Drag left to rewind."><div class="cbx-held"></div><div class="cbx-thumb"></div></div></div>' + '<div class="cbx-row cbx-row-btns">' + '<button data-act="pause" aria-label="Play or pause">' + I.play + '</button>' + '<button data-act="mute" aria-label="Mute or unmute">' + I.vol + '</button>' + '<input type="range" id="cbx-vol" min="0" max="100" value="100" aria-label="Volume">' + '<button data-act="back" title="Back 10s (←)">' + I.back + '<b>10</b></button>' + '<button data-act="fwd" title="Forward 10s (→)">' + I.fwd + '<b>10</b></button>' + '<button data-act="live" class="cbx-live"><i></i>Live</button>' + '<span class="cbx-qwrap"><select id="cbx-qsel" class="cbx-q" aria-label="Video quality"><option value="">…</option></select></span>' + '<button data-act="snap" title="Save a frame (S)">' + I.cam + '</button>' + (S.clipSave ? '<button data-act="clip" title="Save the buffer (D)">' + I.save + '</button>' : '') + '<span id="cbx-behind"></span>' + '<button data-act="pip" title="Picture in picture">' + I.pip + '</button>' + '<button data-act="fs" title="Fullscreen">' + I.fs + '</button></div>'; } function ensureBlock() { if (P.block && P.block.isConnected) return P.block; if (!roomName() || !S.rewindBar || P.forcedSite) return null; var site = siteVideo(); if (!site || site.getBoundingClientRect().width < 60) return null; P.site = site; P.box = playerBox(site); var block = el('div', { id: 'cbx-block' }), shell = el('div', { id: 'cbx-shell' }), bar = el('div', { id: 'cbx-bar' }, barHTML()); block.appendChild(shell); block.appendChild(bar); var touch = document.documentElement.classList.contains('cbx-touch'); if (touch) { var grip = el('div', { id: 'cbx-hgrip', title: 'Drag to change the video height (double-tap to reset)' }, '<i></i>'); block.appendChild(grip); wireGrip(grip); } if (getComputedStyle(P.box).position === 'static') P.box.style.position = 'relative'; P.box.appendChild(block); P.block = block; P.shell = shell; P.bar = bar; if (touch) { document.documentElement.classList.add('cep-touch-block'); fitTouchHeight(); window.addEventListener('resize', fitTouchHeight); wirePan(shell); } document.documentElement.classList.add('cep-block'); wireBar(bar, shell); var r = block.getBoundingClientRect(); log('block mounted in', (P.box.id ? '#' + P.box.id : P.box.className), Math.round(r.width) + 'x' + Math.round(r.height)); return block; } // phones: the site's box is short; grow it so the picture keeps its height // and the controls sit underneath instead of eating into it var TOUCH_MIN_PIC = 56; // thin strip function touchDefaultPic() { return Math.round(P.box.clientWidth * 9 / 16); } function touchMaxPic() { return Math.max(TOUCH_MIN_PIC, window.innerHeight - 80); } function fitTouchHeight() { var box = P.box, bar = P.bar; if (!box || !bar || !bar.isConnected) return; var grip = $('#cbx-hgrip', P.block); var def = touchDefaultPic(); var pic = S.mobileHeight || def; pic = Math.max(TOUCH_MIN_PIC, Math.min(touchMaxPic(), pic)); var h = pic + bar.offsetHeight + (grip ? grip.offsetHeight : 0); if (Math.abs(box.clientHeight - h) > 1) { box.style.height = h + 'px'; growTouchAncestors(box); // the site places the panels below the player from its own measurements try { window.dispatchEvent(new Event('resize')); } catch (e) {} } // shorter than or at default: whole frame stays visible (letterbox). // taller than default: fill the box and crop (zoom), pan with left/right drags. var zoomed = pic > def + 1; P.block.classList.toggle('cbx-zoomed', zoomed); if (!zoomed && P.video && P.video.style.objectPosition) { P.panX = null; P.video.style.objectPosition = ''; } } // the site's outer wrappers keep their own height on phones, so our taller // box just overflowed on top of the chat. Grow any ancestor that no longer // encloses the box so the content below (chat, tabs) is pushed down instead. function growTouchAncestors(box) { P.grown = P.grown || []; P.grown.forEach(function (n) { n.style.minHeight = ''; }); var bottom = box.getBoundingClientRect().bottom, n = box; for (var i = 0; i < 8 && n.parentElement && n.parentElement !== document.body; i++) { n = n.parentElement; var pr = n.getBoundingClientRect(); if (pr.bottom < bottom - 1) { if (P.grown.indexOf(n) < 0) { n._cbxStyle = { height: n.style.height, maxHeight: n.style.maxHeight, minHeight: n.style.minHeight }; P.grown.push(n); } var cs = getComputedStyle(n); if (cs.maxHeight !== 'none') n.style.maxHeight = 'none'; if (cs.overflowY === 'hidden' || cs.overflow === 'hidden') n.style.overflow = 'visible'; n.style.height = 'auto'; n.style.minHeight = Math.ceil(bottom - pr.top) + 'px'; } } } function restoreTouchAncestors() { (P.grown || []).forEach(function (n) { var s = n._cbxStyle || {}; n.style.height = s.height || ''; n.style.maxHeight = s.maxHeight || ''; n.style.minHeight = s.minHeight || ''; n.style.overflow = ''; delete n._cbxStyle; }); P.grown = []; } // zoomed picture: drag left/right to pan (vertical drags still scroll the page) function wirePan(shell) { var x0 = 0, p0 = 50, active = false; shell.addEventListener('pointerdown', function (e) { if (!P.block.classList.contains('cbx-zoomed')) return; x0 = e.clientX; p0 = P.panX == null ? 50 : P.panX; active = true; }); shell.addEventListener('pointermove', function (e) { if (!active || !P.video) return; var dx = e.clientX - x0; if (Math.abs(dx) < 6) return; P.panX = Math.max(0, Math.min(100, p0 - dx / Math.max(1, shell.clientWidth) * 100)); P.video.style.objectPosition = P.panX + '% 50%'; }); ['pointerup', 'pointercancel', 'pointerleave'].forEach(function (ev) { shell.addEventListener(ev, function () { active = false; }); }); } function removeBlock() { if (P.block) { // our panel may have been re-homed into the block for fullscreen; get it out first [scrimEl, panelEl, launcherEl, dockEl, $('#cbx-toast')].forEach(function (n) { if (n && P.block.contains(n)) document.body.appendChild(n); }); try { P.block.remove(); } catch (e) {} } if (P.box) P.box.style.height = ''; restoreTouchAncestors(); window.removeEventListener('resize', fitTouchHeight); document.documentElement.classList.remove('cep-touch-block'); P.block = P.shell = P.bar = null; document.documentElement.classList.remove('cep-block'); } function wireGrip(grip) { var startY = 0, startH = 0; grip.addEventListener('pointerdown', function (e) { startY = e.clientY; startH = P.shell.getBoundingClientRect().height; try { grip.setPointerCapture(e.pointerId); } catch (err) {} grip.classList.add('cbx-dragging'); e.preventDefault(); }); grip.addEventListener('pointermove', function (e) { if (!grip.classList.contains('cbx-dragging')) return; var pic = Math.round(Math.max(TOUCH_MIN_PIC, Math.min(touchMaxPic(), startH + (e.clientY - startY)))); // snap to the default (full 16:9 frame) so it's easy to land back on it if (Math.abs(pic - touchDefaultPic()) < 10) pic = 0; S.mobileHeight = pic; fitTouchHeight(); }); ['pointerup', 'pointercancel'].forEach(function (ev) { grip.addEventListener(ev, function () { if (!grip.classList.contains('cbx-dragging')) return; grip.classList.remove('cbx-dragging'); save(); }); }); grip.addEventListener('dblclick', function () { S.mobileHeight = 0; P.panX = null; if (P.video) P.video.style.objectPosition = ''; save(); fitTouchHeight(); }); } function wireBar(bar, shell) { ['pointerdown', 'mousedown', 'touchstart'].forEach(function (ev) { bar.addEventListener(ev, function (e) { e.stopPropagation(); }, true); }); bar.addEventListener('click', function (e) { var b = e.target.closest('button'); if (!b) return; e.preventDefault(); e.stopPropagation(); ({ pause: togglePause, mute: toggleMute, back: function () { seekBack(10); }, fwd: function () { seekForward(10); }, live: goLive, snap: snapshotFrame, clip: saveClip, pip: togglePiP, fs: goFullscreen })[b.getAttribute('data-act')](); }, true); var scrub = $('#cbx-scrub', bar); var seekAt = function (clientX) { var v = activeVideo(), w = seekWindow(v); if (!v || !w) return; var r = scrub.getBoundingClientRect(), frac = Math.min(1, Math.max(0, (clientX - r.left) / Math.max(1, r.width))); var win = scrubWindow(v, w), t = w.end - win * (1 - frac); if (t < w.start) t = w.start + 0.3; seekTo(v, Math.min(t, w.end - 0.5), true); scrub._pos = 1 - (w.end - t) / win; updateBar(true); }; scrub.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); scrub._held = true; try { scrub.setPointerCapture(e.pointerId); } catch (err) {} seekAt(e.clientX); }); scrub.addEventListener('pointermove', function (e) { if (scrub._held) seekAt(e.clientX); }); ['pointerup', 'pointercancel'].forEach(function (ev) { scrub.addEventListener(ev, function () { scrub._held = false; }); }); var vol = $('#cbx-vol', bar); vol.addEventListener('input', function () { var v = activeVideo(); if (!v) return; userHasInteracted = true; v.volume = vol.value / 100; v.muted = vol.value === '0'; }); var qsel = $('#cbx-qsel', bar); qsel.addEventListener('change', function () { qsel._open = false; pickQuality(qsel.value); }); qsel.addEventListener('pointerdown', function () { qsel._open = true; fillQualitySelect(qsel, true); }, true); qsel.addEventListener('focus', function () { qsel._open = true; }); qsel.addEventListener('blur', function () { qsel._open = false; }); fillQualitySelect(qsel, false); var lastTap = 0, lastX = 0, single = null; shell.addEventListener('click', function (e) { var now = Date.now(), r = shell.getBoundingClientRect(), x = (e.clientX - r.left) / Math.max(1, r.width); if (S.dblTapSeek && now - lastTap < 320 && Math.abs(e.clientX - lastX) < 60) { clearTimeout(single); single = null; lastTap = 0; if (x < 0.34) seekBack(10); else if (x > 0.66) seekForward(10); else goFullscreen(); return; } lastTap = now; lastX = e.clientX; clearTimeout(single); single = setTimeout(function () { single = null; togglePause(); }, S.dblTapSeek ? 330 : 0); }); } function scrubWindow(v, w) { return Math.max(w.end - w.start, v === P.video ? (P.heldSec || S.dvrBuffer) : 0, 1); } function updateBar(fromScrub) { var bar = P.bar; if (!bar || !bar.isConnected) return; var v = activeVideo(), w = seekWindow(v); var out = $('#cbx-behind', bar), scrub = $('#cbx-scrub', bar), at = $('#cbx-scrub-at', bar); var ours = v === P.video; bar.classList.toggle('cbx-ours', ours); if (!v || !w) { out.textContent = v ? 'starting…' : ''; return; } var span = Math.max(0.1, w.end - w.start), behind = Math.max(0, w.end - v.currentTime), win = scrubWindow(v, w); var pos = (scrub._held || fromScrub) && scrub._pos != null ? scrub._pos : 1 - (w.end - v.currentTime) / win; pos = Math.min(1, Math.max(0, pos)); $('.cbx-held', scrub).style.width = Math.min(100, (span / win) * 100).toFixed(1) + '%'; $('.cbx-thumb', scrub).style.left = (pos * 100).toFixed(2) + '%'; at.textContent = behind < 2 ? 'live' : '−' + fmtTime(behind); var t = ours && P.eng ? P.eng.active() : null, mb = t && t.bw ? ' · ' + Math.round(span * t.bw / 8 / 1048576) + ' MB' : ''; var atMax = ours && span >= (P.heldSec || S.dvrBuffer) - 8, warming = ours && !P.armed; bar.classList.toggle('cbx-warming', warming); out.textContent = warming ? 'Rewind ready in ' + Math.max(0, Math.ceil(10 - span)) + 's…' : (v.playbackRate > 1 ? '2× · ' : '') + (behind < 2 ? fmtTime(span) + (atMax ? ' (max)' : ' / ' + fmtTime(win)) + ' held' + mb : '−' + fmtTime(behind) + ' of ' + fmtTime(span) + (atMax ? ' (max)' : '')); out.title = atMax && P.heldNote ? P.heldNote : ''; bar.classList.toggle('cbx-behind-live', behind >= 2); if (v.playbackRate > 1 && behind < 1.5) v.playbackRate = 1; $('button[data-act="pause"]', bar).innerHTML = v.paused ? I.play : I.pause; $('button[data-act="mute"]', bar).innerHTML = (v.muted || v.volume === 0) ? I.muted : I.vol; var vol = $('#cbx-vol', bar); if (document.activeElement !== vol) vol.value = v.muted ? 0 : Math.round(v.volume * 100); var qsel = $('#cbx-qsel', bar); if (!qsel._open) fillQualitySelect(qsel, false); } /* ---- deep rewind ---- */ function installDvr() { var user = roomName(); if (!S.dvrMode || P.suspended || P.forcedSite || !user || P.busy) return; if (P.video && P.video.isConnected) return; if (!ensureBlock()) return; var site = P.site; P.busy = true; P.armed = false; hlsFor(user).then(function (url) { if (!url || roomName() !== user || (P.video && P.video.isConnected)) return; var v = el('video', { 'class': 'cbx-dvr', playsinline: '', 'webkit-playsinline': 'true', autoplay: '', muted: '' }); v.muted = true; P.shell.appendChild(v); P.video = v; site.muted = true; site.style.opacity = '0'; site.style.pointerEvents = 'none'; document.documentElement.classList.add('cep-dvr-on'); var E = P.eng = hlsEngine(v, url); var onFatal = function () { log('dvr fatal, rebuilding'); dropDvr(); setTimeout(function () { if (roomName() === user) installDvr(); }, 2500); }; return E.attach(onFatal).then(function () { v.addEventListener('playing', function once() { v.removeEventListener('playing', once); try { E.goLive(); } catch (e) {} }); var p = v.play(); if (p && p.catch) p.catch(function (e) { if (!e || e.name !== 'AbortError') log('play refused', e && e.name); }); startupWatch(v, site, user); }); }).catch(function (e) { log('dvr', e && (e.message || e)); toast('Deep rewind could not load: ' + (e && e.message || e), 5000); dropDvr(); }).then(function () { P.busy = false; }); } function startupWatch(v, site, user) { var tries = 0, lastT = -1, lastEnd = 0, stuck = 0; var check = setInterval(function () { if (v !== P.video || !v.isConnected) { clearInterval(check); return; } tries++; if (isLivePlaying(v)) { clearInterval(check); P.attempt = 0; log('dvr running at', v.currentTime.toFixed(1), 'after', tries * 2, 's, decoding', v.videoHeight + 'p (auto until 10s held)'); toast('Deep rewind on — press M or the speaker to unmute'); setTimeout(dropSiteQuality, 500); armDvr(v); setTimeout(function () { if (v !== P.video || !v.getVideoPlaybackQuality) return; if (v.getVideoPlaybackQuality().totalVideoFrames === 0 && !document.hidden) { log('no frames painted after 6s; restarting low'); dropDvr(); P.attempt = 1; setTimeout(function () { if (roomName() === user) installDvr(); }, 800); } }, 6000); return; } var end = v.buffered.length ? v.buffered.end(v.buffered.length - 1) : 0; var moving = v.currentTime > lastT + 0.2 || end > lastEnd + 0.2; lastT = v.currentTime; lastEnd = end; stuck = moving ? 0 : stuck + 1; log('dvr starting…', 'ready=' + v.readyState, 'buffered=[' + ranges(v.buffered).join(' ') + ']', moving ? 'progressing' : 'stalled ' + stuck); if (v.paused) { var rp = v.play(); if (rp && rp.catch) rp.catch(function () {}); } var edgeSick = (v._cbxTimeouts || 0) >= 2 && !v.buffered.length, siteDead = !isLivePlaying(site); if (edgeSick || stuck >= 5 || tries >= 20 || (siteDead && tries >= 5) || (v._cbxErrors || 0) >= 6) { clearInterval(check); dropDvr(); if (!siteDead && P.attempt++ < 2) { log(edgeSick ? 'edge not answering, retrying with a fresh session' : 'did not start, retrying (attempt ' + P.attempt + ')'); setTimeout(function () { if (roomName() === user) installDvr(); }, 1500); return; } P.suspended = true; P.attempt = 0; setTimeout(function () { qualityBusy = false; applyQuality(); }, 1200); toast(siteDead ? 'The stream server is not responding for this room' : 'Deep rewind could not start on this room — site player only', 5000); } }, 2000); } function parkSite(on) { var site = P.site && P.site.isConnected ? P.site : null; if (!site) return; try { if (on) { if (!site.paused) { site.pause(); log('site player parked'); } } else if (site.paused && P.video) { var pp = site.play(); if (pp && pp.catch) pp.catch(function () {}); } } catch (e) {} } function armDvr(v) { var steady = 0, lastT = v.currentTime; var arm = setInterval(function () { if (v !== P.video || !v.isConnected) { clearInterval(arm); return; } var w = seekWindow(v), span = w ? w.end - w.start : 0; steady = (v.currentTime > lastT + 0.5 && !v.paused) ? steady + 1 : 0; lastT = v.currentTime; if (span >= 10 && steady >= 3) { clearInterval(arm); P.armed = true; pinTrack(bestTrackUnder(S.dvrQuality || S.qualityCap)); updateBar(); log('dvr armed: ' + fmtTime(span) + ' held, quality pinned, rewind on'); if (S.parkSite) setTimeout(function () { if (v === P.video) parkSite(true); }, 1500); watchDvr(v); } }, 1000); } function watchDvr(v) { var lastEnd = 0, stuck = 0, unlocked = false, ticks = 0; var wd = setInterval(function () { if (v !== P.video || !v.isConnected) { clearInterval(wd); return; } if (++ticks % 15 === 1) { var r = v.getBoundingClientRect(), q = v.getVideoPlaybackQuality ? v.getVideoPlaybackQuality() : null, w = seekWindow(v) || { start: 0, end: 0 }; log('on screen', Math.round(r.width) + 'x' + Math.round(r.height), 'decoded', v.videoWidth + 'x' + v.videoHeight, 'ready', v.readyState, 'frames', q ? q.totalVideoFrames + ' (dropped ' + q.droppedVideoFrames + ')' : '?', 'held', fmtTime(w.end - w.start)); } if (S.parkSite && P.site && !P.site.paused && ticks % 5 === 0) parkSite(true); if (v.paused || document.hidden) return; var end = v.buffered.length ? v.buffered.end(v.buffered.length - 1) : 0; if (end <= lastEnd + 0.2 && end - v.currentTime < 3) stuck++; else stuck = 0; lastEnd = end; if (stuck === 3 && P.eng) { log('buffer end stalled, nudging'); P.eng.retry(); } if (stuck >= 6 && P.eng && !unlocked) { unlocked = true; log('pinned rendition starving, back to auto'); P.eng.auto(); toast('This rendition stalled — quality set to auto'); } if (stuck === 0) unlocked = false; }, 2000); } function dropDvr() { var v = P.video, E = P.eng; P.video = null; P.eng = null; P.armed = false; P.ring = null; if (E) E.destroy(); if (v) { try { v.remove(); } catch (e) {} } document.documentElement.classList.remove('cep-dvr-on'); var site = P.site && P.site.isConnected ? P.site : siteVideo(); if (site) { site.style.opacity = ''; site.style.pointerEvents = ''; if (userHasInteracted) site.muted = false; else toast('Click the video to get sound back'); try { var sp = site.play(); if (sp && sp.catch) sp.catch(function () {}); } catch (e) {} } updateBar(); } function removeDvr() { dropDvr(); removeBlock(); } function backToSitePlayer() { P.suspended = true; P.forcedSite = true; removeDvr(); toast('Site player restored for this page'); updatePlayerToggle(); } function onPlayerNavigate() { P.suspended = false; P.forcedSite = false; P.attempt = 0; removeDvr(); } /* ---- player mode (off / site / deep) ---- */ function setPlayerMode(mode) { var on = mode === 'deep' || mode === true; S.deepPlayer = on; S.playerMode = on ? 'deep' : 'off'; S.rewindBar = on; S.dvrMode = on; save(); if (!on) removeDvr(); else { P.suspended = false; P.forcedSite = false; P.attempt = 0; playerTick(); } refreshPanel(); updatePlayerToggle(); } /* ---- floating player pill (beside the gear): Deep ⇄ Site ---- */ function playerIsDeep() { return S.deepPlayer && !P.forcedSite; } function updatePlayerToggle() { if (!dockEl) return; var deep = playerIsDeep(); dockEl.innerHTML = '<i class="cbx-dot cbx-dot-' + (deep ? 'deep' : 'off') + '"></i>' + (deep ? 'Deep' : 'Site'); dockEl.title = deep ? 'Deep rewind player is on — click for the plain site player (P)' : 'Plain site player — click for deep rewind (P)'; dockEl.setAttribute('aria-pressed', deep ? 'true' : 'false'); } function togglePlayer() { var on = !playerIsDeep(); P.forcedSite = false; P.suspended = false; P.attempt = 0; setPlayerMode(on ? 'deep' : 'off'); toast(on ? 'Deep rewind player on' : 'Site player'); } /* ---- site quality menu helpers ---- */ function siteMenuOpen() { return $$(QUALITY_OPT).some(function (n) { var r = n.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); } function closeSiteMenu(btn, done) { var attempts = 0; (function step() { if (!siteMenuOpen() || attempts >= 3) { document.documentElement.classList.remove('cep-quiet-menu'); if (done) done(); return; } attempts++; try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true })); } catch (e) {} if (attempts === 1) clickHard(btn); else ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(function (type) { var Ctor = /pointer/.test(type) && window.PointerEvent ? PointerEvent : MouseEvent; try { document.body.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, view: window, clientX: 2, clientY: 2, button: 0, pointerId: 1, pointerType: 'mouse', isPrimary: true })); } catch (e) {} }); setTimeout(step, 350); })(); } function dropSiteQuality() { if (!P.video) return; var btn = $(QUALITY_BTN); if (!btn) { log('no quality button, site player stays on its own setting'); return; } document.documentElement.classList.add('cep-quiet-menu'); clickHard(btn); setTimeout(function () { var opts = $$(QUALITY_OPT).map(parseQualityLabel).filter(Boolean); if (opts.length) { var lowest = opts.sort(function (a, b) { return a.height - b.height; })[0]; clickHard(lowest.node); log('site player dropped to', lowest.label); } setTimeout(function () { closeSiteMenu(btn); }, 250); }, 600); } function playerTick() { if (!roomName()) { if (P.block) removeDvr(); placeDock(); return; } if (P.block && (!P.box || !P.box.isConnected)) { log('player box replaced; rebuilding'); removeDvr(); } ensureBlock(); installDvr(); if (!document.hidden) updateBar(); placeDock(); } /* ================================================================== * * auto quality * * Two paths. With deep rewind on, the stream runs through our own * hls.js instance, so we just pick the level. Otherwise we drive the * site's own quality menu and re-check periodically, because the * player drops back to Auto after buffering stalls and PiP. * ================================================================== */ var QUALITY_BTN = '[data-testid="video-quality-btn"]'; var QUALITY_OPT = '[data-testid="quality-option"]'; var qualityTimer = null, qualityBusy = false, qualityNote = 'not applied yet'; function parseQualityLabel(node) { var label = (node.textContent || '').trim().toLowerCase().replace(/\s+/g, ''); var h = 0, fps = 0, m; if ((m = /^(\d{3,4})p(\d{2,3})?/.exec(label))) { h = +m[1]; fps = +(m[2] || 0); } else if ((m = /^(\d{3,4})x(\d{3,4})(?:@?(\d{2,3}))?/.exec(label))) { h = Math.min(+m[1], +m[2]); fps = +(m[3] || 0); } else if ((m = /^([248])k(\d{2,3})?/.exec(label))) { h = { 2: 1440, 4: 2160, 8: 4320 }[+m[1]]; fps = +(m[2] || 0); } else return null; return { node: node, label: label, height: h, fps: fps, score: h * 1000 + fps }; } function clickHard(node) { var r = node.getBoundingClientRect(); var x = r.left + r.width / 2, y = r.top + r.height / 2; ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(function (type) { var Ctor = /pointer/.test(type) && window.PointerEvent ? PointerEvent : MouseEvent; try { node.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, pointerId: 1, pointerType: 'mouse', isPrimary: true })); } catch (e) {} }); } function bestLevelFrom(list, getHeight) { var cap = S.qualityCap || Infinity; var eligible = list.filter(function (o) { return getHeight(o) <= cap; }); var pool = eligible.length ? eligible : list; return pool.sort(function (a, b) { return getHeight(b) - getHeight(a); })[0] || null; } function applyQualityViaHls() { if (P.video && P.eng) { qualityNote = P.armed ? 'deep rewind: pinned' : 'deep rewind: auto until armed'; return true; } var v = $('#cbx-watch video'); if (!v || !v._cbxHls || !v._cbxHls.levels || !v._cbxHls.levels.length) return false; if (v.classList.contains('cbx-dvr')) { // already pinned at load time; re-picking here would flush the buffer qualityNote = 'locked by deep rewind'; return true; } var h = v._cbxHls; var levels = h.levels.map(function (l, i) { return { i: i, height: l.height || 0 }; }); var pick = bestLevelFrom(levels, function (l) { return l.height; }); if (!pick) return false; setHlsLevel(h, pick.i); qualityNote = 'our player, locked to ' + (pick.height || '?') + 'p'; return true; } function applyQualityViaMenu() { if (qualityBusy) return; var btn = $(QUALITY_BTN); if (!btn) { qualityNote = 'quality button not on the page'; return; } qualityBusy = true; document.documentElement.classList.add('cep-quiet-menu'); clickHard(btn); setTimeout(function () { var opts = $$(QUALITY_OPT).map(parseQualityLabel).filter(Boolean); if (!opts.length) { qualityNote = 'no numeric qualities offered'; closeSiteMenu(btn, function () { qualityBusy = false; }); return; } var pick = bestLevelFrom(opts, function (o) { return o.height; }); // Clicking the label that is already selected does nothing, and the // player quietly decodes a lower rendition after PiP or a stall. // Bouncing through Auto first makes it reload the rendition. var v = siteVideo(); var isSel = function (n) { return n.style.color || n.getAttribute('aria-selected') === 'true' || n.getAttribute('aria-checked') === 'true'; }; var decodedLow = v && v.videoHeight && v.videoHeight < pick.height * 0.9; var auto = $$(QUALITY_OPT).filter(function (n) { return /^auto$/i.test((n.textContent || '').trim()); })[0]; var bounce = decodedLow && isSel(pick.node) && auto && Date.now() - lastQualityBounce > 60000; var finish = function () { setTimeout(function () { closeSiteMenu(btn, function () { qualityBusy = false; refreshPanel(); }); }, 400); }; try { if (bounce) { lastQualityBounce = Date.now(); clickHard(auto); qualityNote = 'reset to Auto, then ' + pick.label; setTimeout(function () { try { clickHard(btn); } catch (e) {} setTimeout(function () { var again = $$(QUALITY_OPT).map(parseQualityLabel).filter(Boolean) .filter(function (o) { return o.label === pick.label; })[0]; if (again) clickHard(again.node); finish(); }, 600); }, 400); return; } clickHard(pick.node); qualityNote = 'set to ' + pick.label; } catch (e) { log('quality menu', e); } finish(); }, 700); } var lastQualityBounce = 0; // switching with loadLevel keeps everything already buffered; currentLevel // flushes it, which is exactly what wiped the rewind window in 1.9.3 function setHlsLevel(h, i) { h.autoLevelCapping = -1; h.loadLevel = i; h.nextLevel = i; } function levelLabel(l) { var fps = l.frameRate || (l.attrs && parseFloat(l.attrs['FRAME-RATE'])) || 0; var kb = l.bitrate ? Math.round(l.bitrate / 1000) : 0; return (l.height || '?') + 'p' + (fps && Math.round(fps) !== 30 ? Math.round(fps) : '') + (kb ? ' · ' + (kb >= 1000 ? (kb / 1000).toFixed(1) + ' Mb' : kb + ' kb') : ''); } function levelFps(l) { return l.frameRate || (l.attrs && parseFloat(l.attrs['FRAME-RATE'])) || 0; } function hlsLevelOptions(h) { return h.levels.map(function (l, i) { return { value: 'h' + i, label: levelLabel(l), height: l.height || 0, fps: levelFps(l), bitrate: l.bitrate || 0, i: i }; }).sort(function (a, b) { return (b.height - a.height) || (b.fps - a.fps) || (b.bitrate - a.bitrate); }); } var siteQualityCache = { at: 0, opts: [] }; function readSiteQualities(done) { var btn = $(QUALITY_BTN); if (!btn) { done([]); return; } if (Date.now() - siteQualityCache.at < 60000 && siteQualityCache.opts.length) { done(siteQualityCache.opts); return; } document.documentElement.classList.add('cep-quiet-menu'); clickHard(btn); setTimeout(function () { var opts = $$(QUALITY_OPT).map(function (n) { var q = parseQualityLabel(n); return q ? { value: 's' + q.label, label: q.label, height: q.height, fps: q.fps } : null; }).filter(Boolean).sort(function (a, b) { return (b.height - a.height) || (b.fps - a.fps); }); siteQualityCache = { at: Date.now(), opts: opts }; closeSiteMenu(btn, function () { done(opts); }); }, 500); } function fillQualitySelect(sel, force) { var v = activeVideo(); if (!v) return; if (v === P.video && P.eng) { var cur = P.eng.active(); var html = '<option value="auto">Auto</option>' + P.eng.tracks().map(function (t) { return '<option value="t' + t.id + '">' + t.label + '</option>'; }).join(''); if (sel._cbxHtml !== html) { sel.innerHTML = html; sel._cbxHtml = html; } var want = P.eng.isAuto() ? 'auto' : (cur ? 't' + cur.id : 'auto'); if (sel.value !== want) sel.value = want; return; } var h = v._cbxHls; var nowLabel = v.videoHeight ? v.videoHeight + 'p' : '…'; // the placeholder is never blank: at worst it shows what is decoding now if (!sel._cbxHtml && sel.options.length === 1 && sel.options[0].value === '') sel.options[0].textContent = nowLabel; // site player: read its menu once, early, so the list is there before the first tap if (!h && !force && !siteQualityCache.opts.length && v.videoHeight && !sel._cbxReading && Date.now() - siteQualityCache.at > 15000) { force = true; } function render(opts, current) { var html = opts.map(function (o) { return '<option value="' + o.value + '">' + o.label + '</option>'; }).join(''); html = (h ? '<option value="auto">Auto</option>' : '') + html; if (sel._cbxHtml !== html) { sel.innerHTML = html; sel._cbxHtml = html; } if (current != null && sel.value !== current) sel.value = current; } if (h && h.levels && h.levels.length) { var lvl = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel; render(hlsLevelOptions(h), h.autoLevelEnabled && !v.classList.contains('cbx-dvr') ? 'auto' : 'h' + lvl); return; } if (!force && siteQualityCache.opts.length) { render(siteQualityCache.opts, v.videoHeight ? 's' + v.videoHeight + 'p' : null); return; } if (force && !sel._cbxReading) { sel._cbxReading = true; readSiteQualities(function (opts) { sel._cbxReading = false; if (opts.length) render(opts, v.videoHeight ? 's' + v.videoHeight + 'p' : null); else { sel.innerHTML = '<option value="">' + nowLabel + '</option>'; sel._cbxHtml = null; siteQualityCache.at = Date.now(); } }); } } function pickQuality(value) { var v = activeVideo(); var h = v && v._cbxHls; if (!value) return; if (v === P.video && P.eng) { if (value === 'auto') { P.eng.auto(); toast('Quality: auto'); return; } var id = parseInt(value.slice(1), 10); var t = P.eng.tracks().filter(function (x) { return x.id === id; })[0]; if (t) { pinTrack(t); toast('Quality: ' + t.label + ' — buffer kept'); } return; } if (h && value === 'auto') { h.autoLevelCapping = -1; h.loadLevel = -1; h.nextLevel = -1; toast('Quality: auto'); return; } if (h && value.charAt(0) === 'h') { var i = parseInt(value.slice(1), 10); setHlsLevel(h, i); toast('Quality: ' + levelLabel(h.levels[i]) + (v.classList.contains('cbx-dvr') ? ' — buffer kept' : '')); log('quality set to level', i, 'via loadLevel'); return; } if (value.charAt(0) === 's') { var label = value.slice(1); var btn = $(QUALITY_BTN); if (!btn) { toast('No quality menu on this player'); return; } document.documentElement.classList.add('cep-quiet-menu'); clickHard(btn); setTimeout(function () { var hit = $$(QUALITY_OPT).filter(function (n) { var q = parseQualityLabel(n); return q && q.label === label; })[0]; if (hit) { clickHard(hit); toast('Quality: ' + label); } setTimeout(function () { closeSiteMenu(btn); }, 250); }, 500); } } function cycleQuality() { var v = activeVideo(); if (v === P.video && P.eng) { var tl = P.eng.tracks(), tc = P.eng.active(); var ti = tl.findIndex(function (t) { return tc && t.id === tc.id; }); var tn = tl[(ti + 1 + tl.length) % tl.length]; if (tn) { pinTrack(tn); toast('Quality: ' + tn.label); } return; } var h = v && v._cbxHls; if (h && h.levels && h.levels.length > 1) { var order = hlsLevelOptions(h); var cur = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel; var at = order.findIndex(function (o) { return o.i === cur; }); var next = order[(at + 1 + order.length) % order.length]; pickQuality(next.value); return; } var btn = $(QUALITY_BTN); if (btn) { clickHard(btn); toast('Pick a quality from the menu'); return; } toast('No quality options available here'); } function applyQuality() { if (!S.autoQuality || !roomName()) return; if (applyQualityViaHls()) return; // the site player is about to be hidden and dropped to 240p anyway; // driving its menu now only makes it flash open on load if (S.dvrMode && !P.suspended) { qualityNote = 'deep rewind pending'; return; } applyQualityViaMenu(); } function startQualityWatchdog() { clearInterval(qualityTimer); if (!S.autoQuality) return; if (!startQualityWatchdog._pip) { startQualityWatchdog._pip = true; ['enterpictureinpicture', 'leavepictureinpicture'].forEach(function (ev) { document.addEventListener(ev, function () { if (S.autoQuality) setTimeout(function () { qualityBusy = false; applyQuality(); }, ev === 'leavepictureinpicture' ? 800 : 1200); }, true); }); } qualityTimer = setInterval(function () { if (document.hidden || !roomName()) return; var v = activeVideo(); if (!v || !v.videoHeight) return; var want = S.qualityCap || 0; // only nudge when the decoded picture is clearly below what we asked for if (want && v.videoHeight < want * 0.9) applyQuality(); else if (!want && v.videoHeight < 700) applyQuality(); }, 30000); } /* ================================================================== * * keyboard shortcuts + touch gestures * ================================================================== */ function inTextField() { var a = document.activeElement; return !!a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)); } var KEY_HELP = '← → 10s · J L 30s · K / space pause · M mute · Home start · End / 0 live · > 2× · Q quality · P deep/site player · S frame · D save buffer · ? help'; function installKeys() { document.addEventListener('keydown', function (e) { if (!S.keyShortcuts || !roomName() || inTextField() || e.ctrlKey || e.metaKey || e.altKey) return; var k = e.key; var handled = true; switch (k) { case 'ArrowLeft': seekBack(10); break; case 'ArrowRight': seekForward(10); break; case 'j': case 'J': seekBack(30); break; case 'l': case 'L': seekForward(30); break; case 'k': case 'K': case ' ': togglePause(); break; case 'm': case 'M': toggleMute(); break; case 'Home': seekToStart(); break; case 'End': case '0': goLive(); break; case '>': case '.': toggleCatchUp(); break; case 'q': case 'Q': cycleQuality(); break; case 'p': case 'P': togglePlayer(); break; case 's': case 'S': snapshotFrame(); break; case 'd': case 'D': if (S.clipSave) saveClip(); else handled = false; break; case '?': toast(KEY_HELP, 7000); break; default: handled = false; } if (handled) { e.preventDefault(); e.stopPropagation(); } }, true); } /* ================================================================== * * watch without joining chat * ================================================================== */ function openWatchOnly(user) { user = user || roomName(); if (!user) { toast('You are not in a room'); return; } closeWatchOnly(); var box = el('div', { id: 'cbx-watch' }, '<video playsinline webkit-playsinline autoplay controls></video>' + '<header><b>' + esc(user) + '</b><span class="cbx-note">Stream only — chat is not connected</span>' + '<button id="cbx-watch-close" aria-label="Close">×</button></header>'); document.body.appendChild(box); document.documentElement.style.overflow = 'hidden'; $$('video').forEach(function (v) { if (!v.closest('#cbx-watch')) v.muted = true; }); var v = $('video', box); $('#cbx-watch-close', box).addEventListener('click', closeWatchOnly); hlsFor(user).then(function (url) { if (!url) { toast(user + ' is offline'); return; } // long back buffer here, because this player is ours return attachStream(v, url, { backBufferLength: 600, liveDurationInfinity: true }).then(function () { var p = v.play(); if (p && p.catch) p.catch(function () {}); }); }).catch(function () { toast('Could not load the stream'); }); } function closeWatchOnly() { var box = $('#cbx-watch'); if (!box) return; var v = $('video', box); try { if (v && v._cbxHls) v._cbxHls.destroy(); } catch (e) {} box.remove(); document.documentElement.style.overflow = ''; } /* ================================================================== * * bio info * ================================================================== */ var bioFor = null; function renderBioInfo() { var user = roomName(); if (!S.bioInfo || !user) { var o = $('#cbx-bio'); if (o) o.remove(); return; } if (bioFor === user) return; // one attempt per room, whatever the outcome bioFor = user; fetch('/api/chatvideocontext/' + encodeURIComponent(user) + '/', { credentials: 'include' }) .then(function (r) { return r.json(); }) .then(function (d) { if (roomName() !== user) return; var bits = []; if (d.country) bits.push(['Country', d.country + (d.cc ? ' (' + String(d.cc).toUpperCase() + ')' : '')]); if (d.region) bits.push(['Region', d.region]); if (d.room_status) { bits.push(['Status', d.room_status]); if (String(d.room_status).toLowerCase() === 'public') recordSeen(user); } if (d.seconds_online != null) bits.push(['Online for', fmtTime(d.seconds_online)]); else if (d.online_for) bits.push(['Online for', d.online_for]); if (d.last_online_f) bits.push(['Last seen', d.last_online_f]); if (d.satisfaction_score != null) bits.push(['Satisfaction', Math.round(d.satisfaction_score) + '%']); if (d.performer_has_fanclub != null) bits.push(['Fan club', d.performer_has_fanclub ? 'yes' : 'no']); if (d.has_schedule != null) bits.push(['Schedule', d.has_schedule ? 'published' : 'none']); if (!bits.length) return; var old = $('#cbx-bio'); if (old) old.remove(); var strip = el('div', { id: 'cbx-bio' }, bits.map(function (b) { return '<div><dt>' + esc(b[0]) + '</dt><dd>' + esc(b[1]) + '</dd></div>'; }).join('')); var anchor = $('[data-testid="room-bio-tab-contents"]') || $('.BioContents') || ($('video') && ($('video').closest(MAIN_PLAYER_SEL) || $('video').parentElement)); if (anchor) anchor.insertBefore ? anchor.insertBefore(strip, anchor.firstChild) : anchor.appendChild(strip); }) .catch(function (e) { log('bio', e); }); } /* ================================================================== * * chat translate * ================================================================== */ var LANGS = [ ['en', 'English'], ['es', 'Spanish'], ['pt', 'Portuguese'], ['fr', 'French'], ['de', 'German'], ['it', 'Italian'], ['nl', 'Dutch'], ['pl', 'Polish'], ['ru', 'Russian'], ['tr', 'Turkish'], ['ar', 'Arabic'], ['hi', 'Hindi'], ['id', 'Indonesian'], ['ja', 'Japanese'], ['ko', 'Korean'], ['zh-CN', 'Chinese'], ['ro', 'Romanian'], ['uk', 'Ukrainian'] ]; var trCache = {}, trQueue = [], trBusy = false, trFails = 0; function translate(text, target) { target = target || S.translateTo; var ck = target + '\u0000' + text; if (trCache[ck]) return Promise.resolve(trCache[ck]); var url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=' + encodeURIComponent(target) + '&dt=t&q=' + encodeURIComponent(text); return fetch(url).then(function (r) { return r.json(); }).then(function (d) { var out = (d[0] || []).map(function (p) { return p[0]; }).join(''); var src = d[2]; var res = { text: out, src: src }; trCache[ck] = res; trFails = 0; return res; }); } function pumpQueue() { if (trBusy || !trQueue.length) return; trBusy = true; var job = trQueue.shift(); translate(job.text).then(function (res) { if (res.src && res.src.split('-')[0] === S.translateTo.split('-')[0]) return; if (!res.text || res.text.trim().toLowerCase() === job.text.trim().toLowerCase()) return; if (!job.node.isConnected) return; var tag = el('span', { 'class': 'cbx-tr' }, esc(res.text)); job.node.appendChild(tag); }).catch(function (e) { trFails++; log('translate failed', e); if (trFails >= 5) { S.translateChat = false; save(); refreshPanel(); toast('Translation service unreachable — turned off'); } }).then(function () { trBusy = false; setTimeout(pumpQueue, 220); }); } function translateNewMessages() { if (!S.translateChat) return; $$('div[data-testid="chat-message"]').forEach(function (m) { if (m._cbxTr) return; m._cbxTr = true; var body = m.querySelector('.message,[data-testid="chat-message-text"]') || m; var text = (body.textContent || '').trim(); if (!text || text.length > 400) return; if (trQueue.length > 40) return; trQueue.push({ node: m, text: text }); }); pumpQueue(); } /* ================================================================== * * appearance * ================================================================== */ function applyDark() { if (!S.forceDark) return; var apply = function () { if (document.body) document.body.classList.add('darkmode'); if (document.documentElement) document.documentElement.classList.add('darkmode'); }; apply(); try { var parts = location.hostname.split('.'); var base = parts.length <= 2 ? location.hostname : parts.slice(-2).join('.'); var exp = 'expires=Sun, 1 Jan 9999 00:00:00 UTC; path=/'; document.cookie = 'theme_name=darkmode; ' + exp; document.cookie = 'theme_name=darkmode; ' + exp + '; domain=.' + base; } catch (e) {} onReady(apply); } var RULES = { hideAds: '.ad,.vote-banner,.promoteRoomLink,#ad_unit,.ad-unit,.ad-container,.ad-wrapper,' + '[id^="ad_"],[id^="google_ads_"],[id^="div-gpt-ad"],[class*="ad-slot"],[class*="ads-container"],' + '[class*="banner-ad"],iframe[src*="doubleclick"],iframe[src*="googlesyndication"],' + 'iframe[src*="adnxs"],iframe[src*="adsafeprotected"]', hideSocials: '#social-media-icons,.social_medias,.BioContents tr.smContainer:has(td[data-testid="bio-tab-social-media-value"])', hidePlayerLogo: '#VideoPanel .cbLogo,.cbLogo', hideBadges: '.RoomCardThumbnail__labelContainer,.thumbnail_label', chatHideNotices: 'div[data-testid="chat-message"]:has(.roomNotice:not(.isTip):not(.titleChange):not(.bright-background))', chatHideSubject: 'div[data-testid="chat-message"]:has(.roomNotice.titleChange)', chatHideTips: 'div[data-testid="chat-message"]:has(.isTip)', chatHideGreys: 'div[data-testid="chat-message"]:has(.defaultUser)' }; // cards carry a gender span; the nav tabs and their links are hidden too, // so turning a gender off removes its button from the site as well var GENDER_RULES = { hideGenderF: '.RoomCard:has(span.genderf),.roomCard:has(span.genderf),' + '[data-testid="gender-nav-f"],a[href="/female-cams/"],li:has(>a[href="/female-cams/"])', hideGenderM: '.RoomCard:has(span.genderm),.roomCard:has(span.genderm),' + '[data-testid="gender-nav-m"],a[href="/male-cams/"],li:has(>a[href="/male-cams/"])', hideGenderC: '.RoomCard:has(span.genderc),.roomCard:has(span.genderc),' + '[data-testid="gender-nav-c"],a[href="/couple-cams/"],li:has(>a[href="/couple-cams/"])', hideGenderT: '.RoomCard:has(span.genders),.roomCard:has(span.genders),' + '[data-testid="gender-nav-t"],[data-testid="gender-nav-s"],a[href="/trans-cams/"],li:has(>a[href="/trans-cams/"])' }; var EXTRA_RULES = { tightMargins: '.main-content-wrapper:has(.top-section.roomPage){padding-left:0!important;padding-right:0!important}' + '.BaseRoomContents{margin-left:0!important;margin-top:0!important}' + '#theatermode-root{margin-right:0!important}', biggerCards: '.RoomCardGrid,.MoreRooms .list{grid-template-columns:repeat(auto-fill,minmax(240px,1fr))!important}', cleanProfile: 'tr:not(.smContainer):not(.psContainer) .contentText *{position:static!important;background:none!important;' + 'text-shadow:none!important;letter-spacing:normal!important;animation:none!important;transform:none!important}' + 'tr:not(.smContainer):not(.psContainer) .contentText img{max-width:100%!important;height:auto!important}' + 'tr:not(.smContainer):not(.psContainer) .contentText *[style*="position: absolute"]{position:static!important}' + 'div[data-testid="bio-tab-about-me-value"] *,div[data-testid="bio-tab-wish-list-value"] *{overflow:hidden!important;font-size:inherit!important}' }; var CARD_CSS = '.RoomCard,.roomCard{position:relative}' + '.cbx-tools{position:absolute;top:4px;right:4px;z-index:5;display:flex;gap:3px;opacity:0;transition:opacity .12s ease}' + '.RoomCard:hover .cbx-tools,.roomCard:hover .cbx-tools,.cbx-touch .cbx-tools{opacity:1}' + '.cbx-tools button{width:22px;height:22px;padding:0;border:0;border-radius:5px;background:rgba(15,18,21,.78);color:#fff;font:12px/1 system-ui,sans-serif;cursor:pointer}' + '.cbx-tools button:hover{background:#f67300}' + '.cbx-note-strip{position:absolute;left:0;right:0;bottom:0;z-index:4;background:rgba(15,18,21,.82);color:#ffd9b0;font:11px/1.3 system-ui,sans-serif;padding:3px 6px;pointer-events:none}' + '#cbx-duration{position:absolute;left:8px;top:8px;z-index:6;background:rgba(0,0,0,.6);color:#fff;border-radius:5px;padding:3px 7px;font:12px/1 system-ui,sans-serif;pointer-events:none}' + /* ---- player block: in flow, replaces the site's video slot ---- */ '#cbx-block{position:absolute;inset:0;z-index:2147483000;visibility:visible!important;display:flex;flex-direction:column;background:#000;color:#e8ebed;font:13px/1 system-ui,sans-serif}' + '#cbx-shell{position:relative;flex:1 1 auto;min-height:0;background:#000;overflow:hidden;visibility:visible!important}' + '#cbx-block *{visibility:visible}' + '#cbx-shell video.cbx-dvr{position:absolute;inset:0;width:100%;height:100%;background:#000;object-fit:contain;display:block}' + '#cbx-block.cbx-zoomed #cbx-shell video.cbx-dvr{object-fit:cover}#cbx-block.cbx-zoomed #cbx-shell{touch-action:pan-y}' + '#cbx-block:fullscreen{position:fixed;inset:0}' + '#cbx-bar{flex:none;display:flex;flex-direction:column;gap:0;padding:2px 6px 3px;background:#14171a;border-top:1px solid #2a3138}' + '.cbx-row{display:flex!important;align-items:center;gap:3px;flex-wrap:nowrap;min-width:0;width:100%;box-sizing:border-box}' + '.cbx-row-scrub{gap:6px;min-height:14px}' + '.cbx-time{font-size:11px;color:#cfd6db;flex:0 0 auto!important;width:auto!important;min-width:0!important;display:inline!important;white-space:nowrap}' + '#cbx-bar button{flex:0 0 auto;min-width:30px;min-height:30px;border:0;border-radius:6px;background:transparent;color:#e8ebed;' + 'font:12px/1 system-ui,sans-serif;cursor:pointer;-webkit-tap-highlight-color:transparent;display:flex;align-items:center;justify-content:center;gap:3px;padding:0 6px}' + '#cbx-bar button:hover{background:rgba(255,255,255,.12);color:#fff}' + '#cbx-bar button svg{width:15px;height:15px;flex:none}#cbx-bar button b{font-weight:600;font-size:12px}' + '#cbx-bar .cbx-live{background:rgba(246,115,0,.25);padding:0 8px}#cbx-bar .cbx-live i{width:6px;height:6px;border-radius:50%;background:currentColor;display:inline-block}' + '#cbx-bar.cbx-behind-live .cbx-live{background:#f67300;color:#fff}' + '#cbx-scrub{flex:1 1 0%!important;width:auto!important;min-width:40px;position:relative;height:4px;border-radius:2px;margin:6px 6px 6px 0;background:rgba(255,255,255,.18);cursor:pointer;touch-action:none;user-select:none;display:block!important}' + '#cbx-scrub .cbx-held{position:absolute;right:0;top:0;bottom:0;border-radius:3px;background:#f67300;width:0}' + '#cbx-scrub .cbx-thumb{position:absolute;top:50%;left:100%;width:13px;height:13px;margin:-6.5px 0 0 -6.5px;border-radius:50%;background:#fff;border:2px solid #f67300;box-shadow:0 1px 4px rgba(0,0,0,.5);box-sizing:border-box}' + '#cbx-vol{width:56px;flex:none;accent-color:#f67300;height:14px;cursor:pointer;margin:0}' + '.cbx-qwrap{position:relative;display:inline-block;flex:0 0 auto;width:auto!important}' + '#cbx-bar select.cbx-q{appearance:none;-webkit-appearance:none;border:0;border-radius:8px;color:#cfd6db;width:auto!important;max-width:110px;' + 'background:rgba(255,255,255,.08) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 10 6%27%3E%3Cpath d=%27M1 1l4 4 4-4%27 fill=%27none%27 stroke=%27%238b969e%27 stroke-width=%271.5%27/%3E%3C/svg%3E") no-repeat right 6px center/9px 6px;' + 'font:11px/1 system-ui,sans-serif;min-height:30px;min-width:64px;padding:0 18px 0 8px;cursor:pointer}' + '#cbx-bar select.cbx-q option{background:#14171a;color:#e8ebed}' + '#cbx-bar.cbx-ours select.cbx-q{color:#e8ebed;box-shadow:inset 0 0 0 1px rgba(246,115,0,.55)}' + '#cbx-behind{margin-left:auto;color:#cfd6db;font-size:11px;white-space:nowrap;padding:0 4px}' + '#cbx-bar.cbx-warming button[data-act="back"],#cbx-bar.cbx-warming button[data-act="fwd"],#cbx-bar.cbx-warming #cbx-scrub{opacity:.3;pointer-events:none}' + '#cbx-bar.cbx-warming #cbx-behind{color:#f6a25e}' + '#cbx-hgrip{flex:none;height:18px;display:flex;align-items:center;justify-content:center;background:#14171a;border-top:1px solid #2a3138;cursor:ns-resize;touch-action:none;user-select:none}' + '#cbx-hgrip i{width:44px;height:5px;border-radius:3px;background:#5a646c;display:block}#cbx-hgrip.cbx-dragging i{background:#f67300}' + /* site controls out of the way while our block is up */ 'html.cep-block .theater-video-controls{opacity:0!important;pointer-events:none!important}' + 'html.cep-block :is(div,ul,section):has(> [data-testid="quality-option"]),html.cep-block :is(div,ul,section):has(> * > [data-testid="quality-option"]){visibility:hidden!important}' + 'html.cep-quiet-menu [data-testid="quality-option"],html.cep-quiet-menu :is(div,ul,section):has(> [data-testid="quality-option"]),' + 'html.cep-quiet-menu :is(div,ul,section):has(> * > [data-testid="quality-option"]){visibility:hidden!important}' + 'html.cep-dvr-on :is(' + MAIN_PLAYER_SEL + ') :is(.vjs-big-play-button,.vjs-loading-spinner){display:none!important}' + /* small screens: two rows, bigger targets */ '@media (max-width:699.98px){#cbx-bar{padding:1px 4px 3px}#cbx-vol,#cbx-behind,#cbx-bar button[data-act="pip"]{display:none}' + '.cbx-row-btns{flex-wrap:nowrap;justify-content:center;gap:0}.cbx-row-scrub{gap:6px}' + '#cbx-bar button{min-width:0;padding:0 2px;flex:0 0 auto;width:38px}#cbx-bar button b{display:none}' + '#cbx-bar .cbx-live{width:auto;padding:0 8px;margin:0 3px}' + '#cbx-bar select.cbx-q{min-width:0;width:62px;padding:0 12px 0 4px;font-size:11px}.cbx-qwrap{flex:0 0 auto;margin:0 3px}}' + 'html.cbx-touch #cbx-bar button,html.cbx-touch #cbx-bar select.cbx-q{min-height:34px}' + 'html.cbx-touch #cbx-scrub{height:6px;margin:10px 8px}html.cbx-touch #cbx-scrub .cbx-thumb{width:18px;height:18px;margin:-9px 0 0 -9px}' + '#cbx-bio{display:flex;flex-wrap:wrap;gap:6px 14px;margin:8px 0;padding:8px 10px;border:1px solid #2a3138;border-radius:8px;' + 'background:rgba(20,23,26,.6);font:12px/1.4 system-ui,sans-serif;color:#e8ebed}' + '#cbx-bio dt{color:#8b969e;font-size:11px;margin:0}#cbx-bio dd{margin:0}' + '.cbx-tr{display:block;color:#ffb066;font-size:.92em;padding-left:4px}' + '#cbx-hover{position:fixed;right:14px;bottom:14px;width:min(440px,44vw);aspect-ratio:16/9;z-index:2147481000;' + 'background:#000;border:1px solid #2a3138;border-radius:10px;overflow:hidden;display:none;box-shadow:0 8px 32px rgba(0,0,0,.5)}' + '#cbx-hover.cbx-on{display:block}' + '.cbx-inline-prev{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:3;' + 'background:#000;border-radius:inherit;opacity:0;transition:opacity .18s ease}' + '.cbx-inline-prev.cbx-ready{opacity:1}' + '#cbx-hover video{width:100%;height:100%;object-fit:cover;display:block}' + '#cbx-hover figcaption{position:absolute;left:0;right:0;bottom:0;background:rgba(15,18,21,.85);color:#e8ebed;font:12px/1.4 system-ui,sans-serif;padding:4px 8px}' + '#cbx-watch{position:fixed;inset:0;z-index:2147481500;background:#0f1215;display:flex;flex-direction:column}' + '#cbx-watch header{display:flex;align-items:center;gap:10px;padding:10px 12px;color:#e8ebed;font:14px/1.3 system-ui,sans-serif;border-bottom:1px solid #2a3138}' + '#cbx-watch header .cbx-note{flex:1;color:#8b969e;font-size:12px;margin:0}' + '#cbx-watch header button{width:30px;height:30px;border:0;border-radius:15px;background:#1c2126;color:#e8ebed;font-size:18px;line-height:1;cursor:pointer}' + '#cbx-watch video{flex:1;width:100%;min-height:0;background:#000;object-fit:contain}'; function applySiteCSS() { var style = $('#cbx-site-css'); if (!style) { style = el('style', { id: 'cbx-site-css' }); (document.head || document.documentElement).appendChild(style); } var hides = [], css = CARD_CSS; Object.keys(RULES).forEach(function (k) { if (S[k]) hides.push(RULES[k]); }); Object.keys(GENDER_RULES).forEach(function (k) { if (S[k]) hides.push(GENDER_RULES[k]); }); if (S.gridSize) css += '.RoomCardGrid,.MoreRooms .list{grid-template-columns:repeat(auto-fill,minmax(' + S.gridSize + 'px,1fr))!important}'; Object.keys(EXTRA_RULES).forEach(function (k) { if (S[k]) css += EXTRA_RULES[k]; }); var custom = jsonGet(HIDE_KEY, []); if (custom.length) hides = hides.concat(custom); if (hides.length) css = hides.join(',') + '{display:none!important}' + css; style.textContent = css; } /* ================================================================== * * element picker * ================================================================== */ function selectorFor(node) { var parts = [], depth = 0; while (node && node.nodeType === 1 && depth < 4) { if (node.id && !/^\d/.test(node.id)) { parts.unshift('#' + CSS.escape(node.id)); break; } var tid = node.getAttribute('data-testid'); if (tid) { parts.unshift('[data-testid="' + tid + '"]'); break; } var cls = (node.className && typeof node.className === 'string') ? node.className.trim().split(/\s+/).filter(function (c) { return c && !/^cbx-/.test(c); }).slice(0, 2) : []; parts.unshift(node.tagName.toLowerCase() + (cls.length ? '.' + cls.map(function (c) { return CSS.escape(c); }).join('.') : '')); node = node.parentElement; depth++; } return parts.join(' '); } var pickerOn = false; function togglePicker(on) { pickerOn = on; document.documentElement.classList.toggle('cbx-picking', on); if (on) { document.addEventListener('mouseover', pickHover, true); document.addEventListener('click', pickClick, true); } else { document.removeEventListener('mouseover', pickHover, true); document.removeEventListener('click', pickClick, true); $$('.cbx-pick-hl').forEach(function (n) { n.classList.remove('cbx-pick-hl'); }); } } function pickHover(e) { $$('.cbx-pick-hl').forEach(function (n) { n.classList.remove('cbx-pick-hl'); }); if (e.target.closest && e.target.closest('#cbx-panel,#cbx-launcher')) return; e.target.classList.add('cbx-pick-hl'); } function pickClick(e) { if (e.target.closest && e.target.closest('#cbx-panel,#cbx-launcher')) return; e.preventDefault(); e.stopPropagation(); var sel = selectorFor(e.target); if (!sel) return; var list = jsonGet(HIDE_KEY, []); if (list.indexOf(sel) === -1) list.push(sel); jsonSet(HIDE_KEY, list); applySiteCSS(); togglePicker(false); refreshPanel(); toast('Hidden: ' + sel); } /* ================================================================== * * room cards * ================================================================== */ function cardUser(card) { var a = card.querySelector('a[href^="/"]'); if (!a) return null; var seg = a.getAttribute('href').split('/').filter(Boolean)[0]; return isRoomPath(seg) ? seg : null; } function decorateCards() { var block = jsonGet(BLOCK_KEY, []), note = jsonGet(NOTES_KEY, {}); $$('.RoomCard,.roomCard').forEach(function (card) { var user = cardUser(card); if (!user) return; if (block.indexOf(user) !== -1) { card.style.display = 'none'; return; } card.style.display = ''; if (S.openNewTab) $$('a[href^="/"]', card).forEach(function (a) { a.target = '_blank'; a.rel = 'noopener'; }); var strip = card.querySelector('.cbx-note-strip'); if (note[user]) { if (!strip) { strip = el('div', { 'class': 'cbx-note-strip' }); card.appendChild(strip); } strip.textContent = note[user]; } else if (strip) strip.remove(); if (!S.cardTools) { var t = card.querySelector('.cbx-tools'); if (t) t.remove(); return; } if (card.querySelector('.cbx-tools')) return; var tools = el('div', { 'class': 'cbx-tools' }, '<button data-act="hide" title="Hide this cam">×</button>' + '<button data-act="note" title="Note">✎</button>' + (S.cardWatchBtn ? '<button data-act="watch" title="Watch without chat">▶</button>' : '') + '<button data-act="alert" title="Alert when live">★</button>' + (S.multiCam ? '<button data-act="multi" title="Add to multi cam">+</button>' : '')); card.appendChild(tools); tools.addEventListener('click', function (e) { var b = e.target.closest('button'); if (!b) return; e.preventDefault(); e.stopPropagation(); var act = b.getAttribute('data-act'); if (act === 'hide') { var l = jsonGet(BLOCK_KEY, []); if (l.indexOf(user) === -1) l.push(user); jsonSet(BLOCK_KEY, l); card.style.display = 'none'; refreshPanel(); toast(user + ' hidden'); } else if (act === 'note') { var n = jsonGet(NOTES_KEY, {}); var val = prompt('Note for ' + user, n[user] || ''); if (val === null) return; if (val.trim()) n[user] = val.trim(); else delete n[user]; jsonSet(NOTES_KEY, n); decorateCards(); } else if (act === 'watch') { openWatchOnly(user); } else if (act === 'alert') { toggleAlertFor(user); } else if (act === 'multi') { var m = jsonGet(MULTI_KEY, []); if (m.indexOf(user) === -1) m.push(user); jsonSet(MULTI_KEY, m); toast(user + ' added to multi cam'); } }); }); } /* ================================================================== * * hover preview * ================================================================== */ var hoverBox = null, hoverTimer = null, hoverUser = null, inlineCard = null; /* --- preview played inside the thumbnail itself --- */ function thumbOf(card) { var img = card.querySelector('img'); return (img && img.parentElement) || card; } function inlinePreviewOn(card, user) { if (inlineCard === card) return; inlinePreviewOff(); inlineCard = card; var thumb = thumbOf(card); if (getComputedStyle(thumb).position === 'static') thumb.style.position = 'relative'; var v = el('video', { 'class': 'cbx-inline-prev', muted: '', playsinline: '', 'webkit-playsinline': 'true', autoplay: '' }); v.muted = S.previewMuted; thumb.appendChild(v); hlsFor(user).then(function (url) { if (inlineCard !== card || !url) return; return attachStream(v, url).then(function () { var p = v.play(); if (p && p.catch) p.catch(function () {}); v.classList.add('cbx-ready'); }); }).catch(function () {}); } function inlinePreviewOff() { if (!inlineCard) return; $$('.cbx-inline-prev', inlineCard).forEach(function (v) { try { if (v._cbxHls) { v._cbxHls.destroy(); v._cbxHls = null; } v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} v.remove(); }); inlineCard = null; } /* --- preview in a corner box --- */ function ensureHoverBox() { if (hoverBox) return hoverBox; hoverBox = el('figure', { id: 'cbx-hover' }, '<video muted playsinline webkit-playsinline autoplay></video><figcaption></figcaption>'); document.body.appendChild(hoverBox); return hoverBox; } function showPreview(user) { if (hoverUser === user) return; hoverUser = user; var box = ensureHoverBox(), v = $('video', box); $('figcaption', box).textContent = user; box.classList.add('cbx-on'); v.muted = S.previewMuted; hlsFor(user).then(function (url) { if (hoverUser !== user || !url) return; return attachStream(v, url).then(function () { var p = v.play(); if (p && p.catch) p.catch(function () {}); }); }).catch(function () {}); } function hidePreview() { hoverUser = null; if (!hoverBox) return; hoverBox.classList.remove('cbx-on'); var v = $('video', hoverBox); try { if (v._cbxHls) { v._cbxHls.destroy(); v._cbxHls = null; } v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} } function startPreview(card, user) { if (S.previewInline) inlinePreviewOn(card, user); else showPreview(user); } function stopPreview() { inlinePreviewOff(); hidePreview(); } function installHoverPreview() { document.addEventListener('mouseover', function (e) { if (!S.hoverPreview) return; var card = e.target.closest && e.target.closest('.RoomCard,.roomCard'); clearTimeout(hoverTimer); if (!card) { hoverTimer = setTimeout(stopPreview, 250); return; } if (card === inlineCard) return; var user = cardUser(card); if (!user) return; hoverTimer = setTimeout(function () { startPreview(card, user); }, 350); }, true); document.addEventListener('mouseout', function (e) { if (!S.hoverPreview || !inlineCard) return; var to = e.relatedTarget; if (to && inlineCard.contains(to)) return; clearTimeout(hoverTimer); hoverTimer = setTimeout(stopPreview, 200); }, true); // phones: press and hold a card, release or tap away to stop var lpTimer = null; document.addEventListener('touchstart', function (e) { if (!S.hoverPreview) return; var card = e.target.closest && e.target.closest('.RoomCard,.roomCard'); if (!card) { stopPreview(); return; } var user = cardUser(card); if (!user) return; lpTimer = setTimeout(function () { startPreview(card, user); }, 450); }, { passive: true }); ['touchend', 'touchmove', 'touchcancel'].forEach(function (ev) { document.addEventListener(ev, function () { clearTimeout(lpTimer); }, { passive: true }); }); document.addEventListener('click', function (e) { if (inlineCard && !e.target.closest('.cbx-inline-prev')) stopPreview(); if (hoverBox && hoverBox.classList.contains('cbx-on') && !e.target.closest('#cbx-hover')) hidePreview(); }); // a playing preview should not block the link underneath document.addEventListener('click', function (e) { var v = e.target.closest('.cbx-inline-prev'); if (!v) return; var card = v.closest('.RoomCard,.roomCard'); var a = card && card.querySelector('a[href^="/"]'); if (a) { e.preventDefault(); a.click(); } }, true); } /* ================================================================== * * chat rules * ================================================================== */ function autoAcceptRules() { if (!S.autoChatRules) return; var btns = $$('.rulesModal button,[data-testid="room-rules-accept"],button'); for (var i = 0; i < btns.length; i++) { var txt = (btns[i].textContent || '').trim().toLowerCase(); if (/^(i agree|agree|accept)$/.test(txt) && btns[i].offsetParent) { try { btns[i].click(); } catch (e) {} return; } } } /* ================================================================== * * streams * ================================================================== */ function hlsFor(user) { return fetch('/api/chatvideocontext/' + encodeURIComponent(user) + '/', { credentials: 'include', cache: 'no-store' }) .then(function (r) { return r.json(); }) .then(function (d) { return d && d.hls_source ? d.hls_source : null; }); } function requiredHls() { if (typeof window.Hls !== 'undefined' && window.Hls) return window.Hls; try { if (typeof Hls !== 'undefined' && Hls) return Hls; } catch (e) {} return null; } function loadHlsJs() { var have = requiredHls(); return have ? Promise.resolve(have) : Promise.reject(new Error('hls.js was not loaded by the userscript manager')); } // Chaturbate's playlists advertise CAN-BLOCK-RELOAD, so hls.js appends // _HLS_msn/_HLS_part to every playlist reload and the edge holds the // request until that part exists. On a slow edge that outlives the // timeout (the 1.9.7 log: every error was levelLoadTimeOut or // audioTrackLoadTimeOut on such a URL), and while a playlist request // hangs nothing new gets scheduled. A rewind player does not need the // live edge, so ask for the plain playlist and let the edge answer now. var plainPlaylistLoader = null; function plainLoaderFor(Hls) { if (plainPlaylistLoader) return plainPlaylistLoader; var Base = Hls.DefaultConfig.loader; function PlainLoader(config) { Base.call(this, config); } PlainLoader.prototype = Object.create(Base.prototype); PlainLoader.prototype.constructor = PlainLoader; PlainLoader.prototype.load = function (context, config, callbacks) { try { if (context && typeof context.url === 'string' && /_HLS_(msn|part|skip)=/.test(context.url)) { var u = new URL(context.url); ['_HLS_msn', '_HLS_part', '_HLS_skip'].forEach(function (k) { u.searchParams.delete(k); }); context.url = u.toString(); } } catch (e) {} return Base.prototype.load.call(this, context, config, callbacks); }; plainPlaylistLoader = PlainLoader; return PlainLoader; } function canUseMse() { return !!(window.MediaSource || window.ManagedMediaSource); } function attachStream(video, url, opts) { // Chrome answers "maybe" to the HLS mime type but native playback there // reports an empty seekable range, so rewinding is impossible. Use // hls.js whenever Media Source exists and keep native for real Safari. if (canUseMse()) { return loadHlsJs().then(function (Hls) { if (!Hls || !Hls.isSupported()) throw new Error('hls.js unsupported'); var cfg = { capLevelToPlayerSize: true, maxHeight: S.multiMaxHeight, preferManagedMediaSource: true }; if (video.classList.contains('cbx-dvr')) cfg.pLoader = plainLoaderFor(Hls); var onFatal = opts && opts.onFatal; if (opts) for (var k in opts) if (k !== 'onFatal') cfg[k] = opts[k]; var h = new Hls(cfg); var netRetries = 0, mediaRetries = 0; h.on(Hls.Events.ERROR, function (_, d) { if (!d) return; var where = (d.frag && d.frag.relurl) || (d.url) || (d.context && d.context.url) || ''; var code = d.response && d.response.code; var host = ''; try { host = new URL(where, location.href).host.split('.')[0]; } catch (e) {} log((d.fatal ? 'hls FATAL' : 'hls'), d.details, code ? 'http ' + code : '', host, where.slice(-60)); if (/LoadTimeOut$/.test(d.details || '') && video.classList.contains('cbx-dvr')) video._cbxTimeouts = (video._cbxTimeouts || 0) + 1; if (!d.fatal) return; if (d.type === Hls.ErrorTypes.NETWORK_ERROR && netRetries++ < 5) { setTimeout(function () { try { h.startLoad(); } catch (e) {} }, 800 * netRetries); } else if (d.type === Hls.ErrorTypes.MEDIA_ERROR && mediaRetries++ < 3) { try { h.recoverMediaError(); } catch (e) {} } else if (onFatal) { onFatal(d); } else { try { h.destroy(); } catch (e) {} } }); h.on(Hls.Events.FRAG_BUFFERED, function () { netRetries = 0; mediaRetries = 0; }); h.loadSource(url); h.attachMedia(video); video._cbxHls = h; log('attached via hls.js'); }).catch(function (e) { log('hls.js unavailable (' + (e && e.message) + ') — native playback has no rewind'); video.src = url; if (video.classList.contains('cbx-dvr')) { toast('Rewind needs hls.js, which the page blocked — see the Video tab'); } }); } if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; log('attached natively (no back buffer control)'); if (video.classList.contains('cbx-dvr')) { toast(/iP(hone|ad|od)/.test(navigator.userAgent) ? 'Deep rewind needs iOS 17.1 or newer; using the native player' : 'This browser has no Media Source support; using the native player'); } return Promise.resolve(); } return Promise.reject(new Error('no HLS support')); } /* ================================================================== * * multi cam page * ================================================================== */ function buildMulti() { document.head.appendChild(el('style', null, MULTI_CSS)); var root = el('div', { id: 'cbx-multi' }, '<header id="cbx-multi-bar">' + '<input type="text" id="cbx-multi-input" placeholder="Add a room by name" autocapitalize="off" autocorrect="off" spellcheck="false">' + '<button id="cbx-multi-add" class="cbx-b cbx-b-accent">Add</button>' + '<button id="cbx-multi-reload" class="cbx-b">Reload</button>' + '<button id="cbx-multi-mute" class="cbx-b">Mute all</button>' + '<button id="cbx-multi-random" class="cbx-b">Random</button>' + '</header><div id="cbx-multi-grid"></div>' + '<p id="cbx-multi-empty">No rooms yet. Type a name above, or use the + button on any room card.</p>'); document.body.appendChild(root); document.documentElement.style.overflow = 'hidden'; document.title = 'Multi cam'; var grid = $('#cbx-multi-grid', root), empty = $('#cbx-multi-empty', root); function sync() { empty.style.display = grid.children.length ? 'none' : 'block'; } function addCam(user) { user = String(user || '').trim().toLowerCase().replace(/[^a-z0-9_]/g, ''); if (!user || $('.cbx-cam[data-user="' + user + '"]', grid)) return; var cell = el('div', { 'class': 'cbx-cam', 'data-user': user }, '<video muted playsinline webkit-playsinline autoplay></video>' + '<span class="cbx-name">' + user + '</span>' + '<button class="cbx-x" aria-label="Remove ' + user + '">×</button>' + '<span class="cbx-state">Loading</span>' + (S.multiShowSubject ? '<span class="cbx-subject"></span>' : '')); if (S.multiResizable) cell.classList.add('cbx-resizable'); grid.appendChild(cell); sync(); var video = $('video', cell), state = $('.cbx-state', cell); $('.cbx-x', cell).addEventListener('click', function () { try { if (video._cbxHls) video._cbxHls.destroy(); } catch (e) {} cell.remove(); jsonSet(MULTI_KEY, jsonGet(MULTI_KEY, []).filter(function (u) { return u !== user; })); sync(); }); video.addEventListener('click', function () { var wasMuted = video.muted; $$('.cbx-cam video', grid).forEach(function (v) { v.muted = true; }); $$('.cbx-cam', grid).forEach(function (c) { c.classList.remove('cbx-live-audio'); }); video.muted = !wasMuted; cell.classList.toggle('cbx-live-audio', !video.muted); }); if (S.multiShowSubject) { fetch('/api/chatvideocontext/' + encodeURIComponent(user) + '/', { credentials: 'include' }) .then(function (r) { return r.json(); }) .then(function (d) { var sub = $('.cbx-subject', cell); if (sub && d && d.room_title) sub.textContent = d.room_title; }).catch(function () {}); } hlsFor(user).then(function (url) { if (!url) { state.textContent = 'Offline'; if (S.multiAutoRemove) { cell.remove(); jsonSet(MULTI_KEY, jsonGet(MULTI_KEY, []).filter(function (u) { return u !== user; })); sync(); } else if (S.multiHideOffline) cell.style.display = 'none'; return; } return attachStream(video, url, { backBufferLength: 120 }).then(function () { state.style.display = 'none'; var p = video.play(); if (p && p.catch) p.catch(function () {}); }); }).catch(function (e) { state.textContent = 'Could not load'; log('multi', user, e); }); var list = jsonGet(MULTI_KEY, []); if (list.indexOf(user) === -1) { list.push(user); jsonSet(MULTI_KEY, list); } } $('#cbx-multi-add', root).addEventListener('click', function () { var i = $('#cbx-multi-input', root); i.value.split(',').forEach(addCam); i.value = ''; }); $('#cbx-multi-input', root).addEventListener('keydown', function (e) { if (e.key === 'Enter') $('#cbx-multi-add', root).click(); }); $('#cbx-multi-reload', root).addEventListener('click', function () { var users = $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); }); grid.innerHTML = ''; users.forEach(addCam); }); $('#cbx-multi-mute', root).addEventListener('click', function () { $$('.cbx-cam video', grid).forEach(function (v) { v.muted = true; }); $$('.cbx-cam', grid).forEach(function (c) { c.classList.remove('cbx-live-audio'); }); }); $('#cbx-multi-random', root).addEventListener('click', function () { fillRandom(addCam); }); jsonGet(MULTI_KEY, []).forEach(addCam); var m = location.search.match(/[?&]cbx-rooms=([^&]+)/); if (m) decodeURIComponent(m[1]).split(',').forEach(addCam); sync(); } /* ================================================================== * * inactive tabs, exclusive audio * ================================================================== */ var chan = null; try { chan = new BroadcastChannel('cbx'); } catch (e) {} function broadcast(msg) { try { if (chan) chan.postMessage(msg); } catch (e) {} } function muteEverythingHere() { $$('video').forEach(function (v) { v.muted = true; }); } if (chan) { chan.onmessage = function (e) { if (!e.data) return; if (e.data.type === 'mute-others' && e.data.from !== TAB_ID) muteEverythingHere(); if (e.data.type === 'alert' && e.data.from !== TAB_ID) toast(e.data.text); }; } var TAB_ID = String(Math.random()).slice(2); function capQuality(low) { $$('video').forEach(function (v) { var h = v._cbxHls; if (!h || !h.levels || !h.levels.length) return; // loadLevel only changes what gets fetched next; currentLevel would // flush the rewind buffer every time the tab goes to the background if (low) { if (v._cbxPrevLevel == null) v._cbxPrevLevel = h.loadLevel; h.loadLevel = 0; } else if (v._cbxPrevLevel != null) { h.loadLevel = v._cbxPrevLevel; v._cbxPrevLevel = null; } }); } var pausedByUs = []; function applyInactive(hidden) { if (hidden) { if (S.inactiveQuality) capQuality(true); if (S.inactivePause) { pausedByUs = $$('video').filter(function (v) { return !v.paused; }); pausedByUs.forEach(function (v) { try { v.pause(); } catch (e) {} }); } } else { if (S.inactiveQuality) capQuality(false); pausedByUs.forEach(function (v) { try { v.play(); } catch (e) {} }); pausedByUs = []; } } function installExclusiveAudio() { document.addEventListener('volumechange', function (e) { if (!S.exclusiveAudio) return; var v = e.target; if (v instanceof HTMLVideoElement && !v.muted && v.volume > 0) { broadcast({ type: 'mute-others', from: TAB_ID }); } }, true); } /* ================================================================== * * video transforms * ================================================================== */ var xform = { rot: 0, flip: 1, zoom: 1 }; function applyTransform() { var v = activeVideo(); if (!v) return; v.style.transform = 'rotate(' + xform.rot + 'deg) scaleX(' + xform.flip + ') scale(' + xform.zoom + ')'; v.style.transformOrigin = 'center center'; } function resetTransform() { xform = { rot: 0, flip: 1, zoom: 1 }; var v = activeVideo(); if (v) v.style.transform = ''; } /* ================================================================== * * copy stream URL * ================================================================== */ function copyStreamUrl() { var user = roomName(); if (!user) { toast('You are not in a room'); return; } hlsFor(user).then(function (url) { if (!url) { toast('No stream URL — the room may be offline'); return; } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(function () { toast('Stream URL copied'); }, function () { prompt('Stream URL', url); }); } else prompt('Stream URL', url); }).catch(function () { toast('Could not fetch the stream URL'); }); } /* ================================================================== * * who in chat is broadcasting * ================================================================== */ var scanning = false; function scanChat() { if (scanning) return; scanning = true; var names = {}; $$('div[data-testid="chat-message"]').forEach(function (m) { var u = m.querySelector('[data-testid="chat-message-username"],.username,a[href^="/"]'); if (!u) return; var name = (u.textContent || '').trim().replace(/[:\s]+$/, '').toLowerCase(); if (/^[a-z0-9_]{3,}$/.test(name)) names[name] = u; }); var list = Object.keys(names).slice(0, 25); if (!list.length) { scanning = false; toast('No chat names found'); return; } toast('Checking ' + list.length + ' chatters…'); var found = 0, i = 0; function next() { if (i >= list.length) { scanning = false; toast(found ? found + ' of them are broadcasting' : 'None of them are live'); return; } var name = list[i++]; hlsFor(name).then(function (url) { if (url && names[name] && names[name].isConnected) { found++; if (!names[name].querySelector('.cbx-livedot')) { names[name].appendChild(el('span', { 'class': 'cbx-livedot', title: 'Live now' }, '● live')); } } }).catch(function () {}).then(function () { setTimeout(next, 140); }); } next(); } /* ================================================================== * * status alerts for a watch list * ================================================================== */ var alertTimer = null, alertState = {}; function alertList() { return jsonGet(WATCH_KEY, []); } function toggleAlertFor(user) { var l = alertList(), i = l.indexOf(user); if (i === -1) l.push(user); else l.splice(i, 1); jsonSet(WATCH_KEY, l); refreshPanel(); toast(i === -1 ? 'Alerting for ' + user : 'No longer alerting for ' + user); if (l.length && S.alertsOn) startAlerts(); } function notify(text) { toast(text); broadcast({ type: 'alert', from: TAB_ID, text: text }); try { if (window.Notification && Notification.permission === 'granted') new Notification('Chaturbate Enhanced Plus', { body: text }); } catch (e) {} } function checkAlerts() { var list = alertList(); if (!S.alertsOn || !list.length) return; var i = 0; function next() { if (i >= list.length) return; var user = list[i++]; hlsFor(user).then(function (url) { var online = !!url; if (online && alertState[user] === false) notify(user + ' is online'); alertState[user] = online; }).catch(function () {}).then(function () { setTimeout(next, 500); }); } next(); } function startAlerts() { clearInterval(alertTimer); if (!S.alertsOn) return; try { if (window.Notification && Notification.permission === 'default') Notification.requestPermission(); } catch (e) {} checkAlerts(); alertTimer = setInterval(checkAlerts, Math.max(30, S.alertEvery) * 1000); } /* ================================================================== * * schedule: when has this room been online before * ================================================================== */ function recordSeen(user) { if (!S.trackSchedule || !user) return; var d = new Date(), key = d.getDay() + '-' + d.getHours(); var all = jsonGet(SEEN_KEY, {}); if (!all[user]) all[user] = {}; if (all[user][key] === 1) return; all[user][key] = 1; var keys = Object.keys(all); if (keys.length > 400) delete all[keys[0]]; jsonSet(SEEN_KEY, all); } var DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; function scheduleHTML(user) { var all = jsonGet(SEEN_KEY, {}), grid = (user && all[user]) || null; if (!grid) return '<p class="cbx-note">Nothing recorded yet. Visit the room while it is live and the grid fills in.</p>'; var out = '<div class="cbx-sched">'; for (var d = 0; d < 7; d++) { out += '<div class="cbx-sched-row"><b>' + DAYS[d] + '</b>'; for (var h = 0; h < 24; h++) { out += '<i class="' + (grid[d + '-' + h] ? 'cbx-on' : '') + '" title="' + DAYS[d] + ' ' + h + ':00"></i>'; } out += '</div>'; } return out + '</div><p class="cbx-note">Hours this room was live when you looked.</p>'; } /* ================================================================== * * panel language * * Rather than shipping 45 hand-written string tables, the panel is * translated on demand and cached, so any language the endpoint knows * is available and nothing goes stale when a label changes. * ================================================================== */ function uiLangResolved() { if (S.uiLang && S.uiLang !== 'auto') return S.uiLang; var n = (navigator.language || 'en').toLowerCase(); if (n.indexOf('zh') === 0) return 'zh-CN'; return n.split('-')[0]; } var i18nBusy = false; function localizePanel() { var lang = uiLangResolved(); if (!panelEl || !lang || lang.indexOf('en') === 0 || i18nBusy) return; var cacheKey = 'cbx-i18n-' + lang; var cache = jsonGet(cacheKey, {}); var walker = document.createTreeWalker(panelEl, NodeFilter.SHOW_TEXT, null); var todo = [], nodes = [], node; while ((node = walker.nextNode())) { var raw = node.nodeValue; var t = raw.trim(); if (!t || t.length < 2 || /^[\sו●★✎▶+−–—]+$/.test(t)) continue; if (node._cbxSrc === t) continue; nodes.push({ node: node, src: t, pad: raw }); if (!cache[t] && todo.indexOf(t) === -1) todo.push(t); } function paint() { nodes.forEach(function (n) { var out = cache[n.src]; if (!out) return; n.node.nodeValue = n.pad.replace(n.src, out); n.node._cbxSrc = out; }); } if (!todo.length) { paint(); return; } i18nBusy = true; var i = 0; function next() { if (i >= todo.length) { jsonSet(cacheKey, cache); i18nBusy = false; paint(); return; } var src = todo[i++]; translate(src, lang).then(function (r) { if (r && r.text) cache[src] = r.text; }).catch(function () {}).then(function () { setTimeout(next, 160); }); } paint(); next(); } /* ================================================================== * * random rooms by gender * * The room-list endpoint is tried first; if its shape is not what we * expect, the public gender page is parsed for room links instead, so * this keeps working when the API moves. * ================================================================== */ var GENDER_PAGES = { f: 'female-cams', m: 'male-cams', c: 'couple-cams', t: 'trans-cams' }; function harvestUsernames(obj, out) { if (!obj || typeof obj !== 'object' || out.length > 300) return out; if (Array.isArray(obj)) { obj.forEach(function (o) { harvestUsernames(o, out); }); return out; } var u = obj.username || obj.room || obj.slug; if (typeof u === 'string' && /^[a-z0-9_]{3,}$/i.test(u) && out.indexOf(u.toLowerCase()) === -1) { out.push(u.toLowerCase()); } Object.keys(obj).forEach(function (k) { harvestUsernames(obj[k], out); }); return out; } function roomsForGender(g) { var page = GENDER_PAGES[g] || 'female-cams'; return fetch('/api/ts/roomlist/room-list/?genders=' + g + '&limit=90', { credentials: 'include' }) .then(function (r) { if (!r.ok) throw new Error('bad status'); return r.json(); }) .then(function (d) { var names = harvestUsernames(d, []); if (!names.length) throw new Error('no usernames'); return names; }) .catch(function () { return fetch('/' + page + '/', { credentials: 'include' }) .then(function (r) { return r.text(); }) .then(function (html) { var out = [], m, re = /href="\/([a-z0-9_]{3,})\/"/g; while ((m = re.exec(html))) { var u = m[1]; if (isRoomPath(u) && out.indexOf(u) === -1) out.push(u); } return out; }); }); } function fillRandom(target) { var genders = (S.randomGenders || 'f').split(''); var want = S.randomCount || 6; toast('Looking for rooms…'); Promise.all(genders.map(function (g) { return roomsForGender(g).catch(function () { return []; }); })).then(function (lists) { var pool = []; lists.forEach(function (l) { pool = pool.concat(l); }); var have = jsonGet(MULTI_KEY, []); pool = pool.filter(function (u) { return have.indexOf(u) === -1; }); if (!pool.length) { toast('Found nothing to add'); return; } for (var i = pool.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = pool[i]; pool[i] = pool[j]; pool[j] = t; } var picked = pool.slice(0, want); if (target) picked.forEach(target); else { jsonSet(MULTI_KEY, have.concat(picked)); toast('Added ' + picked.length + ' rooms — open the multi cam tab'); } }).catch(function (e) { log('random', e); toast('Could not fetch the room list'); }); } /* ================================================================== * * panel * ================================================================== */ var UI_CSS = [ ':root{--cbx-bg:#14171a;--cbx-bg-2:#1c2126;--cbx-line:#2a3138;--cbx-fg:#e8ebed;--cbx-dim:#8b969e;--cbx-accent:#f67300}', '#cbx-launcher{position:fixed;z-index:2147483400;width:40px;height:40px;border-radius:20px;border:1px solid var(--cbx-line);', 'background:var(--cbx-bg);color:var(--cbx-fg);display:flex;align-items:center;justify-content:center;cursor:grab;', 'box-shadow:0 2px 10px rgba(0,0,0,.35);opacity:.72;transition:opacity .15s ease;touch-action:none;-webkit-tap-highlight-color:transparent}', '#cbx-launcher:hover,#cbx-launcher:focus-visible{opacity:1}', '#cbx-dock{position:fixed;z-index:2147483400;display:none;align-items:center;gap:7px;height:40px;padding:0 14px 0 12px;border-radius:20px;', 'border:1px solid var(--cbx-line);background:var(--cbx-bg);color:var(--cbx-fg);font:500 13px/1 system-ui,sans-serif;cursor:pointer;opacity:.72;', 'box-shadow:0 2px 10px rgba(0,0,0,.35);white-space:nowrap;-webkit-tap-highlight-color:transparent;transition:opacity .15s ease}', '#cbx-dock:hover,#cbx-dock:focus-visible{opacity:1}', '.cbx-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none;background:#8b969e}', '.cbx-dot-deep{background:#3ad07a}.cbx-dot-off{background:#8b969e}', '#cbx-launcher svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round}', '#cbx-scrim{position:fixed;inset:0;z-index:2147483500;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .18s ease}', '#cbx-scrim.cbx-on{opacity:1;pointer-events:auto}', '#cbx-panel{position:fixed;z-index:2147483600;background:var(--cbx-bg);color:var(--cbx-fg);border:1px solid var(--cbx-line);', 'font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;', 'box-shadow:0 10px 40px rgba(0,0,0,.5);transition:transform .22s cubic-bezier(.2,.8,.3,1)}', '#cbx-panel h1{font-size:15px;font-weight:600;margin:0}', '#cbx-panel h2{font-size:12px;font-weight:600;color:var(--cbx-dim);margin:0 0 2px}', '@media (min-width:700px){#cbx-panel{top:0;right:0;bottom:0;width:340px;border-width:0 0 0 1px;transform:translateX(102%)}', '#cbx-panel.cbx-on{transform:translateX(0)}}', '@media (max-width:699.98px){#cbx-panel{left:0;right:0;bottom:0;max-height:80vh;border-radius:16px 16px 0 0;border-width:1px 0 0;transform:translateY(102%)}', '#cbx-panel.cbx-on{transform:translateY(0)}#cbx-grip{display:block}}', '#cbx-grip{display:none;width:36px;height:4px;border-radius:2px;background:var(--cbx-line);margin:8px auto 0;flex:none}', '#cbx-head{display:flex!important;position:static!important;width:auto!important;height:auto!important;align-items:center;justify-content:space-between;padding:12px 16px 8px;flex:none;box-sizing:border-box;margin:0}', '#cbx-head h1{flex:1 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '#cbx-tabs{display:flex!important;visibility:visible!important;gap:2px;overflow-x:auto;scrollbar-width:none;padding:0 10px 8px;flex:none;min-height:36px;border-bottom:1px solid var(--cbx-line)}', '#cbx-tabs::-webkit-scrollbar{display:none}', '.cbx-tab{flex:none;display:inline-block!important;appearance:none;border:0;background:transparent;color:var(--cbx-dim);font:inherit;font-size:13px;', 'padding:8px 12px;border-radius:8px;cursor:pointer;white-space:nowrap;-webkit-tap-highlight-color:transparent}', '.cbx-tab:hover{color:var(--cbx-fg)}', '.cbx-tab.cbx-on{background:var(--cbx-bg-2);color:var(--cbx-fg);box-shadow:inset 0 -2px 0 var(--cbx-accent)}', '#cbx-filter-wrap{padding:8px 16px 0;flex:none}', '#cbx-filter{width:100%;box-sizing:border-box;font:inherit;font-size:13px;padding:7px 10px;border-radius:8px;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-fg)}', '#cbx-panel.cbx-filtering #cbx-tabs,#cbx-panel.cbx-filtering #cbx-secpick-wrap{display:none!important}', '#cbx-panel.cbx-filtering .cbx-pane{display:none!important}#cbx-panel.cbx-filtering .cbx-pane.cbx-match{display:block!important}', '#cbx-panel.cbx-filtering .cbx-pane::before{content:attr(data-pane);display:block;font-size:11px;text-transform:capitalize;color:var(--cbx-dim);margin:0 0 4px}', '#cbx-panel .cbx-hide{display:none!important}', '#cbx-body .cbx-pane{display:none;padding:12px 0;border:0}', '#cbx-body .cbx-pane.cbx-on{display:block}', '#cbx-panel h3{font-size:12px;font-weight:600;color:var(--cbx-dim);margin:14px 0 2px}', '#cbx-panel h3:first-child{margin-top:2px}', '.cbx-sched{display:flex;flex-direction:column;gap:2px;margin-top:6px}', '.cbx-sched-row{display:flex;align-items:center;gap:2px}', '.cbx-sched-row b{width:30px;font:11px/1 system-ui,sans-serif;font-weight:400;color:var(--cbx-dim)}', '.cbx-sched-row i{flex:1;height:11px;border-radius:2px;background:var(--cbx-bg-2)}', '.cbx-sched-row i.cbx-on{background:var(--cbx-accent)}', '.cbx-livedot{color:#3ad07a;font-size:10px;margin-left:5px}', '.cbx-chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}', '.cbx-chip{appearance:none;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-dim);', 'font:inherit;font-size:12px;padding:7px 11px;border-radius:16px;cursor:pointer}', '.cbx-chip.cbx-on{background:var(--cbx-accent);border-color:var(--cbx-accent);color:#fff}', '#cbx-body{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:4px 16px 24px;flex:1}', '#cbx-secpick-wrap{display:none;align-items:center;gap:8px;padding:6px 0 10px;border-bottom:1px solid var(--cbx-line);margin-bottom:6px}', 'html.cbx-touch #cbx-secpick-wrap{display:flex}#cbx-secpick-wrap label{font-size:12px;color:var(--cbx-dim)}', '#cbx-secpick{flex:1;font:14px system-ui,sans-serif;padding:8px 10px;border-radius:8px;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-fg)}', '#cbx-body .cbx-pane{padding:12px 0;border-bottom:1px solid var(--cbx-line)}', '#cbx-body .cbx-pane:last-of-type{border-bottom:0}', '.cbx-row{display:flex;align-items:center;gap:12px;padding:7px 0;cursor:pointer}', '.cbx-row span{flex:1;min-width:0}', '.cbx-sw{flex:none;width:38px;height:22px;border-radius:11px;background:var(--cbx-bg-2);border:1px solid var(--cbx-line);position:relative;transition:background .15s ease}', '.cbx-sw::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--cbx-dim);transition:transform .15s ease,background .15s ease}', '.cbx-row input{position:absolute;opacity:0;width:0;height:0}', '.cbx-row input:checked + .cbx-sw{background:var(--cbx-accent);border-color:var(--cbx-accent)}', '.cbx-row input:checked + .cbx-sw::after{transform:translateX(16px);background:#fff}', '.cbx-row input:focus-visible + .cbx-sw{outline:2px solid var(--cbx-accent);outline-offset:2px}', '.cbx-b{appearance:none;font:inherit;font-size:13px;padding:8px 12px;border-radius:8px;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-fg);cursor:pointer}', '.cbx-b:hover{border-color:var(--cbx-dim)}', '.cbx-b-accent{background:var(--cbx-accent);border-color:var(--cbx-accent);color:#fff}', '.cbx-b-wide{display:block;width:100%;margin-top:8px;text-align:center}', '.cbx-b-quiet{background:transparent}', '.cbx-b-pair{display:flex;gap:8px;margin-top:8px}.cbx-b-pair .cbx-b{flex:1;margin-top:0}', '#cbx-close{flex:none;width:30px;height:30px;min-width:30px;padding:0;border-radius:15px;line-height:1;font-size:18px;margin-left:8px}', '.cbx-note{color:var(--cbx-dim);font-size:12px;margin:6px 0 0}', '.cbx-mono{font:11px/1.5 ui-monospace,Menlo,Consolas,monospace;color:var(--cbx-dim);white-space:pre-wrap;margin-top:8px}', '.cbx-list-item{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--cbx-dim);padding:3px 0}', '.cbx-list-item b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;font-weight:400}', '.cbx-range{display:flex;align-items:center;gap:10px;padding:6px 0}.cbx-range label{font-size:13px;flex:none}', '.cbx-range input{flex:1;min-width:0;accent-color:var(--cbx-accent)}.cbx-range b{flex:none;width:38px;text-align:right;font-size:12px;color:var(--cbx-dim)}', '.cbx-sel{width:100%;margin-top:6px;padding:7px 9px;border-radius:8px;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-fg);font:inherit;font-size:13px}', 'html.cbx-picking *{cursor:crosshair!important}', '.cbx-pick-hl{outline:2px solid var(--cbx-accent)!important;outline-offset:-2px!important;background:rgba(246,115,0,.12)!important}', '#cbx-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%) translateY(8px);z-index:2147483647;', 'background:var(--cbx-bg);color:var(--cbx-fg);border:1px solid var(--cbx-line);border-radius:8px;padding:9px 14px;', 'font:13px system-ui,-apple-system,sans-serif;opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;max-width:80vw}', '#cbx-toast.cbx-on{opacity:1;transform:translateX(-50%) translateY(0)}', '@media (prefers-reduced-motion:reduce){#cbx-panel,#cbx-scrim,#cbx-toast,.cbx-sw,.cbx-sw::after{transition:none}}' ].join(''); var MULTI_CSS = [ '#cbx-multi{position:fixed;inset:0;z-index:2147483000;background:#0f1215;color:#e8ebed;overflow:auto;padding:10px;', 'font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}', '#cbx-multi-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}', '#cbx-multi input[type=text]{flex:1;min-width:150px;padding:9px 11px;border-radius:8px;border:1px solid #2a3138;background:#1c2126;color:#e8ebed;font-size:16px}', '#cbx-multi input[type=text]::placeholder{color:#8b969e}', '#cbx-multi .cbx-b{appearance:none;font:inherit;font-size:13px;padding:9px 12px;border-radius:8px;border:1px solid #2a3138;background:#1c2126;color:#e8ebed;cursor:pointer}', '#cbx-multi .cbx-b-accent{background:#f67300;border-color:#f67300;color:#fff}', '#cbx-multi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:8px}', '#cbx-multi-empty{color:#8b969e;font-size:13px;max-width:44ch}', '.cbx-cam{position:relative;background:#000;border:1px solid #2a3138;border-radius:10px;overflow:hidden;aspect-ratio:16/9}', '.cbx-cam.cbx-live-audio{border-color:#f67300}', '.cbx-cam video{width:100%;height:100%;object-fit:contain;background:#000;display:block;cursor:pointer}', '.cbx-cam .cbx-name{position:absolute;left:8px;top:7px;padding:2px 7px;border-radius:5px;background:rgba(0,0,0,.65);font-size:12px}', '.cbx-cam .cbx-x{position:absolute;right:6px;top:5px;width:24px;height:24px;border-radius:12px;border:0;background:rgba(0,0,0,.65);color:#fff;font-size:16px;line-height:1;cursor:pointer}', '.cbx-cam .cbx-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#8b969e;font-size:12px}', '.cbx-cam .cbx-subject{position:absolute;left:0;right:0;bottom:0;padding:3px 7px;background:rgba(0,0,0,.62);', 'font:11px/1.35 system-ui,sans-serif;color:#cfd6db;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.cbx-cam.cbx-resizable{resize:both;overflow:auto;aspect-ratio:auto;min-width:180px;min-height:110px;height:190px}', '.cbx-cam.cbx-resizable video{height:100%}' ].join(''); var GEAR_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v2M12 19v2M4.2 7.5l1.7 1M18.1 15.5l1.7 1M4.2 16.5l1.7-1M18.1 8.5l1.7-1"/></svg>'; var TABS = [ { id: 'sound', label: 'Sound', groups: [ { title: null, items: [ ['blockSoundFx', 'Mute tip and alert sounds', 'Blocks the effects outright. Cam audio is untouched.'], ['tipSliderZero', 'Set the site\'s Tip Volume', 'Drives the slider in the site\'s chat settings to the level below.'], ['bgMute', 'Mute when tab is hidden', null], ['exclusiveAudio', 'Only one tab plays sound', 'Unmuting here mutes the other CB tabs.'] ] } ] }, { id: 'video', label: 'Video', groups: [ { title: 'Rewind', items: [ ['deepPlayer', 'Deep rewind player', 'Our player over the site\'s, holding minutes of video you can scrub through. Off leaves the plain site player.'], ['bigBuffer', 'Large rewind buffer (uses more RAM)', 'About 400 MB instead of 120 (150 vs 60 on phones). Longer windows at high quality; heavier on memory over long sessions.'], ['parkSite', 'Pause the hidden site player', 'While deep rewind runs, keeps the site\'s own player paused so it stops fetching video underneath ours.'], ['clipSave', 'Keep buffer for saving', 'Adds a save button that writes the held window to an .mp4 instantly. Uses extra memory.'], ['keyShortcuts', 'Keyboard shortcuts', 'Press ? on a room page for the list.'], ['dblTapSeek', 'Double-tap the video to skip', 'Left third −10s, right third +10s, middle fullscreen. Single tap pauses.'] ] }, { title: 'Previews', items: [ ['inlinePreview', 'Keep previews inline', 'Stops previews jumping to fullscreen on iOS.'], ['hoverPreview', 'Preview on hover', 'Press and hold on a phone.'], ['previewInline', 'Play the preview in the thumbnail', 'Off shows it in a corner box instead.'], ['previewMuted', 'Previews start muted', null] ] }, { title: 'Player', items: [ ['autoQuality', 'Always use the best quality', 'Re-applies it when the player drops back to Auto.'], ['showDuration', 'Show stream time', null], ['pipButton', 'Picture in picture controls', null] ] }, { title: 'Background tabs', items: [ ['inactiveQuality', 'Drop quality when hidden', null], ['inactivePause', 'Pause the stream when hidden', null] ] } ] }, { id: 'look', label: 'Look', groups: [ { title: null, items: [ ['forceDark', 'Dark theme', null], ['hideAds', 'Hide ads and banners', null], ['hideSocials', 'Hide social links', null], ['hidePlayerLogo', 'Hide logo on the player', null], ['hideBadges', 'Hide thumbnail badges', null], ['tightMargins', 'Tighter page margins', null], ['cleanProfile', 'Flatten profile styling', 'Strips absolute positioning, backgrounds and animation from bios.'] ] }, { title: 'Who to show', items: [ ['hideGenderF', 'Hide women', null], ['hideGenderM', 'Hide men', null], ['hideGenderC', 'Hide couples', null], ['hideGenderT', 'Hide trans', null] ] } ] }, { id: 'rooms', label: 'Rooms', groups: [ { title: null, items: [ ['bioInfo', 'Show extra room info', 'Country, region, time online, satisfaction.'], ['cardTools', 'Buttons on room cards', 'Hide, note, alert, add to multi cam.'], ['cardWatchBtn', 'Add a watch-without-chat button', 'Puts a play button on every thumbnail.'], ['openNewTab', 'Open rooms in a new tab', null], ['autoChatRules', 'Accept room rules automatically', null], ['trackSchedule', 'Remember when rooms are live', null], ['time24', '24 hour times', null] ] } ] }, { id: 'chat', label: 'Chat', groups: [ { title: null, items: [ ['translateChat', 'Translate messages', null], ['chatHideNotices', 'Hide room notices', null], ['chatHideSubject', 'Hide subject changes', null], ['chatHideTips', 'Hide tip messages', null], ['chatHideGreys', 'Hide grey users', null] ] } ] }, { id: 'multi', label: 'Multi', groups: [ { title: null, items: [ ['multiCam', 'Enable multi cam', 'Opens in its own tab.'], ['multiShowSubject', 'Show room subjects', null], ['multiResizable', 'Resizable tiles', null], ['multiHideOffline', 'Hide cams that are offline', null], ['multiAutoRemove', 'Drop offline cams from the list', null] ] } ] }, { id: 'alerts', label: 'Alerts', groups: [ { title: null, items: [ ['alertsOn', 'Tell me when a room goes live', 'Checks your alert list in the background.'] ] } ] }, { id: 'panel', label: 'Panel', groups: [ { title: null, items: [ ['showLauncher', 'Show the floating button', null], ['edgeSwipe', 'Open by swiping from the right edge', null], ['debug', 'Log to console', null] ] } ] } ]; function rowHTML(key, label, note) { return '<label class="cbx-row"><span>' + label + (note ? '<br><small class="cbx-note">' + note + '</small>' : '') + '</span><input type="checkbox" data-cbx="' + key + '"' + (S[key] ? ' checked' : '') + '><i class="cbx-sw"></i></label>'; } function selectHTML(id, label, options, current) { // values may be numbers or strings; comparison below is string-based return '<select class="cbx-sel" id="' + id + '">' + options.map(function (o) { return '<option value="' + o[0] + '"' + (String(current) === String(o[0]) ? ' selected' : '') + '>' + label + o[1] + '</option>'; }).join('') + '</select>'; } function tabExtras(id) { if (id === 'sound') return '<div class="cbx-range"><label for="cbx-tipvol">Tip Volume</label><input type="range" id="cbx-tipvol" min="0" max="100" step="5" value="' + S.tipVolume + '"><b id="cbx-tipvol-val">' + S.tipVolume + '%</b></div>' + '<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-tip-check">Check tip volume</button><div class="cbx-mono" id="cbx-tip-status"></div><div class="cbx-mono" id="cbx-tabs-debug"></div>'; if (id === 'video') return selectHTML('cbx-quality-cap', 'Quality: ', [[0, 'best available'], [1080, 'up to 1080p'], [720, 'up to 720p'], [480, 'up to 480p']], S.qualityCap) + '<div class="cbx-mono" id="cbx-quality-note"></div>' + selectHTML('cbx-dvr-buffer', 'Rewind window: ', [[120, '2 minutes'], [300, '5 minutes'], [600, '10 minutes'], [1200, '20 minutes']], S.dvrBuffer) + selectHTML('cbx-dvr-quality', 'Deep rewind quality: ', [[0, 'best available'], [1080, '1080p'], [720, '720p (recommended)'], [480, '480p'], [360, '360p']], S.dvrQuality) + '<div class="cbx-mono" id="cbx-dvr-note"></div>' + '<div class="cbx-b-pair"><button class="cbx-b" id="cbx-pip">Picture in picture</button><button class="cbx-b" id="cbx-fs">Fullscreen</button></div>' + '<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-siteplayer">Back to site player (this page)</button>' + '<h3>Picture</h3><div class="cbx-b-pair"><button class="cbx-b" id="cbx-rot">Rotate</button>' + '<button class="cbx-b" id="cbx-flip">Flip</button><button class="cbx-b" id="cbx-zin">Zoom +</button>' + '<button class="cbx-b" id="cbx-zout">Zoom −</button></div>' + '<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-xreset">Reset picture</button>'; if (id === 'look') return selectHTML('cbx-grid', 'Room card size: ', [[0, 'site default'], [150, 'small'], [200, 'medium'], [260, 'large'], [340, 'extra large']], S.gridSize) + '<button class="cbx-b cbx-b-wide" id="cbx-minimal">Apply clean look</button>' + '<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-pick">Hide an element…</button><div id="cbx-hidden-list"></div>'; if (id === 'rooms') return '<div class="cbx-b-pair"><button class="cbx-b" id="cbx-watch-btn">Watch without chat</button>' + '<button class="cbx-b" id="cbx-copy-url">Copy stream URL</button></div>' + '<h3>When this room is usually live</h3><div id="cbx-sched"></div>' + '<div id="cbx-blocked-list"></div>'; if (id === 'chat') return selectHTML('cbx-tr-lang', 'Translate into ', LANGS, S.translateTo) + '<button class="cbx-b cbx-b-wide" id="cbx-scan">Who in chat is broadcasting</button>'; if (id === 'multi') return selectHTML('cbx-multi-quality', 'Max ', [[1080, '1080p'], [720, '720p'], [480, '480p'], [360, '360p']], S.multiMaxHeight) + '<div id="cbx-multi-actions"><button class="cbx-b cbx-b-wide" id="cbx-open-multi">Open multi cam tab</button>' + '<button class="cbx-b cbx-b-wide" id="cbx-add-multi">Add this room</button>' + '<h3>Fill with random rooms</h3><div class="cbx-chips" id="cbx-genders">' + [['f', 'Women'], ['m', 'Men'], ['c', 'Couples'], ['t', 'Trans']].map(function (g) { return '<button class="cbx-chip' + (S.randomGenders.indexOf(g[0]) !== -1 ? ' cbx-on' : '') + '" data-gender="' + g[0] + '">' + g[1] + '</button>'; }).join('') + '</div>' + selectHTML('cbx-random-count', 'Add ', [[3, '3 rooms'], [6, '6 rooms'], [9, '9 rooms'], [12, '12 rooms']], S.randomCount) + '<button class="cbx-b cbx-b-wide" id="cbx-random">Add random rooms</button></div>'; if (id === 'alerts') return selectHTML('cbx-alert-every', 'Check every ', [[30, '30 seconds'], [60, 'minute'], [180, '3 minutes'], [600, '10 minutes']], S.alertEvery) + '<button class="cbx-b cbx-b-wide" id="cbx-alert-add">Alert me for this room</button><div id="cbx-alert-list"></div>'; if (id === 'panel') return selectHTML('cbx-uilang', 'Panel language: ', [['auto', 'match my browser'], ['en', 'English']].concat(LANGS.filter(function (l) { return l[0] !== 'en'; })), S.uiLang) + '<div class="cbx-b-pair"><button class="cbx-b" id="cbx-export">Export</button>' + '<button class="cbx-b" id="cbx-import">Import</button></div>' + '<input type="file" id="cbx-import-file" accept="application/json" hidden>' + '<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-reset">Reset everything</button>'; return ''; } var panelEl = null, scrimEl = null, launcherEl = null, dockEl = null; function buildUI() { if ($('#cbx-panel')) return; document.head.appendChild(el('style', { id: 'cbx-ui-css' }, UI_CSS)); scrimEl = el('div', { id: 'cbx-scrim' }); panelEl = el('div', { id: 'cbx-panel', role: 'dialog', 'aria-label': 'Chaturbate Enhanced Plus' }); var nav = TABS.map(function (t, i) { return '<button role="tab" class="cbx-tab' + (i === 0 ? ' cbx-on' : '') + '" data-tab="' + t.id + '">' + t.label + '</button>'; }).join(''); var panes = TABS.map(function (t, i) { var inner = t.groups.map(function (g) { return (g.title ? '<h3>' + g.title + '</h3>' : '') + g.items.map(function (it) { return typeof it === 'string' ? it : rowHTML(it[0], it[1], it[2]); }).join(''); }).join(''); return '<div class="cbx-pane' + (i === 0 ? ' cbx-on' : '') + '" role="tabpanel" data-pane="' + t.id + '">' + inner + tabExtras(t.id) + '</div>'; }).join(''); panelEl.innerHTML = '<div id="cbx-grip"></div>' + '<div id="cbx-head"><h1>Enhanced Plus <small style="font-weight:400;font-size:11px;opacity:.6">0.9.6</small></h1><button class="cbx-b" id="cbx-close" aria-label="Close">×</button></div>' + '<div id="cbx-tabs" role="tablist">' + nav + '</div>' + '<div id="cbx-filter-wrap"><input type="search" id="cbx-filter" placeholder="Filter settings…" autocomplete="off" spellcheck="false"></div>' + '<div id="cbx-body"><div id="cbx-secpick-wrap"><label for="cbx-secpick">Section</label><select id="cbx-secpick">' + TABS.map(function (t) { return '<option value="' + t.id + '">' + t.label + '</option>'; }).join('') + '</select></div>' + panes + '</div>'; panelEl.addEventListener('change', function (e) { if (e.target.id !== 'cbx-secpick') return; var id = e.target.value; $$('.cbx-tab', panelEl).forEach(function (b) { b.classList.toggle('cbx-on', b.getAttribute('data-tab') === id); }); $$('#cbx-body .cbx-pane', panelEl).forEach(function (s) { s.classList.toggle('cbx-on', s.getAttribute('data-pane') === id); }); }); panelEl.addEventListener('click', function (e) { var t = e.target.closest('.cbx-tab'); if (!t) return; var id = t.getAttribute('data-tab'); $$('.cbx-tab', panelEl).forEach(function (b) { b.classList.toggle('cbx-on', b === t); }); $$('[data-pane]', panelEl).forEach(function (p) { p.classList.toggle('cbx-on', p.getAttribute('data-pane') === id); }); $('#cbx-body', panelEl).scrollTop = 0; try { localStorage.setItem('cbx-panel-tab', id); } catch (err) {} if (id === 'rooms') renderSchedule(); try { t.scrollIntoView({ block: 'nearest', inline: 'center' }); } catch (err) {} }); document.body.appendChild(scrimEl); document.body.appendChild(panelEl); launcherEl = el('div', { id: 'cbx-launcher', role: 'button', tabindex: '0', 'aria-label': 'Chaturbate Enhanced Plus settings' }, GEAR_SVG); document.body.appendChild(launcherEl); dockEl = el('button', { id: 'cbx-dock', type: 'button', title: 'Enhanced player on/off (P)' }, ''); dockEl.addEventListener('click', function (e) { e.preventDefault(); togglePlayer(); }); document.body.appendChild(dockEl); placeLauncher(); makeDraggable(launcherEl); window.addEventListener('resize', placeDock); scrimEl.addEventListener('click', function () { openPanel(false); }); $('#cbx-close', panelEl).addEventListener('click', function () { openPanel(false); }); try { var lastTab = localStorage.getItem('cbx-panel-tab'), lastBtn = lastTab && $('.cbx-tab[data-tab="' + lastTab + '"]', panelEl); if (lastBtn) lastBtn.click(); } catch (e) {} panelEl.addEventListener('change', function (e) { if (e.target.id === 'cbx-multi-quality') { S.multiMaxHeight = parseInt(e.target.value, 10); save(); return; } if (e.target.id === 'cbx-quality-cap') { S.qualityCap = parseInt(e.target.value, 10); save(); applyQuality(); return; } if (e.target.id === 'cbx-tipvol') { S.tipVolume = parseInt(e.target.value, 10) || 0; save(); tipDone = false; applyTipMute(0); return; } if (e.target.id === 'cbx-random-count') { S.randomCount = parseInt(e.target.value, 10); save(); return; } if (e.target.id === 'cbx-uilang') { S.uiLang = e.target.value; save(); localizePanel(); return; } if (e.target.id === 'cbx-tr-lang') { S.translateTo = e.target.value; trCache = {}; save(); return; } if (e.target.id === 'cbx-grid') { S.gridSize = parseInt(e.target.value, 10); save(); applySiteCSS(); return; } if (e.target.id === 'cbx-alert-every') { S.alertEvery = parseInt(e.target.value, 10); save(); startAlerts(); return; } if (e.target.id === 'cbx-dvr-buffer') { S.dvrBuffer = parseInt(e.target.value, 10); save(); if (P.eng) { var ct = P.eng.active(); if (ct) fitBufferToMemory(ct); } return; } if (e.target.id === 'cbx-dvr-quality') { S.dvrQuality = parseInt(e.target.value, 10); save(); var dq = $('video.cbx-dvr'); if (dq && P.eng && P.armed) pinTrack(bestTrackUnder(S.dvrQuality || S.qualityCap)); return; } var k = e.target.getAttribute && e.target.getAttribute('data-cbx'); if (!k) return; S[k] = !!e.target.checked; save(); onSettingChanged(k); }); panelEl.addEventListener('input', function (e) { if (e.target.id === 'cbx-tipvol') $('#cbx-tipvol-val', panelEl).textContent = e.target.value + '%'; if (e.target.id === 'cbx-filter') applyFilter(e.target.value); }); $('#cbx-filter', panelEl).addEventListener('keydown', function (e) { if (e.key === 'Escape' && e.target.value) { e.target.value = ''; applyFilter(''); e.stopPropagation(); } }); $('#cbx-tip-check', panelEl).addEventListener('click', function () { tipDone = false; tipRung = ''; applyTipMute(0); var show = function () { var st = $('#cbx-tip-status', panelEl); if (st) st.textContent = tipDiagnostics(); }; show(); [800, 2000, 4000, 7000].forEach(function (ms) { setTimeout(show, ms); }); }); $('#cbx-pip', panelEl).addEventListener('click', togglePiP); $('#cbx-siteplayer', panelEl).addEventListener('click', backToSitePlayer); $('#cbx-fs', panelEl).addEventListener('click', goFullscreen); $('#cbx-watch-btn', panelEl).addEventListener('click', function () { openPanel(false); openWatchOnly(); }); $('#cbx-copy-url', panelEl).addEventListener('click', copyStreamUrl); $('#cbx-random', panelEl).addEventListener('click', function () { fillRandom(null); }); $('#cbx-genders', panelEl).addEventListener('click', function (e) { var c = e.target.closest('.cbx-chip'); if (!c) return; var g = c.getAttribute('data-gender'); var cur = S.randomGenders.split('').filter(Boolean); var i = cur.indexOf(g); if (i === -1) cur.push(g); else cur.splice(i, 1); if (!cur.length) cur = [g]; S.randomGenders = cur.join(''); save(); $$('.cbx-chip', panelEl).forEach(function (b) { b.classList.toggle('cbx-on', S.randomGenders.indexOf(b.getAttribute('data-gender')) !== -1); }); }); $('#cbx-scan', panelEl).addEventListener('click', function () { openPanel(false); scanChat(); }); $('#cbx-rot', panelEl).addEventListener('click', function () { xform.rot = (xform.rot + 90) % 360; applyTransform(); }); $('#cbx-flip', panelEl).addEventListener('click', function () { xform.flip *= -1; applyTransform(); }); $('#cbx-zin', panelEl).addEventListener('click', function () { xform.zoom = Math.min(3, xform.zoom + 0.1); applyTransform(); }); $('#cbx-zout', panelEl).addEventListener('click', function () { xform.zoom = Math.max(0.5, xform.zoom - 0.1); applyTransform(); }); $('#cbx-xreset', panelEl).addEventListener('click', resetTransform); $('#cbx-alert-add', panelEl).addEventListener('click', function () { var r = roomName(); if (!r) { toast('You are not in a room'); return; } toggleAlertFor(r); }); $('#cbx-minimal', panelEl).addEventListener('click', function () { MINIMAL_SET.forEach(function (k) { S[k] = true; }); save(); applySiteCSS(); refreshPanel(); toast('Clean look applied'); }); $('#cbx-pick', panelEl).addEventListener('click', function () { openPanel(false); togglePicker(true); toast('Tap the thing you want gone'); }); $('#cbx-open-multi', panelEl).addEventListener('click', function () { window.open('https://chaturbate.com/?cbx-multi=1', '_blank'); }); $('#cbx-add-multi', panelEl).addEventListener('click', function () { var r = roomName(); if (!r) { toast('You are not in a room'); return; } var list = jsonGet(MULTI_KEY, []); if (list.indexOf(r) === -1) list.push(r); jsonSet(MULTI_KEY, list); toast(r + ' added — ' + list.length + ' saved'); }); $('#cbx-export', panelEl).addEventListener('click', exportAll); $('#cbx-import', panelEl).addEventListener('click', function () { $('#cbx-import-file', panelEl).click(); }); $('#cbx-import-file', panelEl).addEventListener('change', importAll); $('#cbx-reset', panelEl).addEventListener('click', function () { if (!confirm('Reset all Enhanced Plus settings, notes and hidden cams?')) return; [KEY, MULTI_KEY, HIDE_KEY, BLOCK_KEY, NOTES_KEY, POS_KEY, WATCH_KEY, SEEN_KEY].forEach(function (k) { try { localStorage.removeItem(k); } catch (e) {} }); location.reload(); }); document.addEventListener('keydown', function (e) { if (e.target.matches && e.target.matches('input,textarea,[contenteditable]')) return; if (e.key === 'Escape') { if (pickerOn) { togglePicker(false); toast('Cancelled'); } else if ($('#cbx-watch')) closeWatchOnly(); else openPanel(false); } if (e.altKey && /^c$/i.test(e.key)) { e.preventDefault(); openPanel(!panelEl.classList.contains('cbx-on')); } if (e.altKey && /^p$/i.test(e.key)) { e.preventDefault(); togglePiP(); } if (e.key === 'ArrowLeft' && !e.altKey && !e.ctrlKey && !e.metaKey && roomName()) { e.preventDefault(); seekBack(e.shiftKey ? 60 : 15); } if (e.key === 'ArrowRight' && !e.altKey && !e.ctrlKey && !e.metaKey && roomName()) { e.preventDefault(); goLive(); } }); installGestures(); refreshPanel(); setTimeout(localizePanel, 400); } function onSettingChanged(k) { if (k === 'deepPlayer') { setPlayerMode(S.deepPlayer ? 'deep' : 'off'); return; } if (k === 'bigBuffer' && P.eng) { var bt = P.eng.active(); if (bt) fitBufferToMemory(bt); } if (k === 'parkSite') parkSite(!!S.parkSite && !!P.armed); if (k === 'clipSave') { removeBlock(); if (S.clipSave) toast('Reload the room once so the buffer is captured from the start'); } if (k === 'tipSliderZero' && S.tipSliderZero) { tipDone = false; applyTipMute(0); } if (k === 'bgMute' && !S.bgMute) applyBgMute(false); if (k === 'showLauncher') placeLauncher(); if (k === 'forceDark') { if (S.forceDark) applyDark(); else { document.body.classList.remove('darkmode'); document.documentElement.classList.remove('darkmode'); } } if (k === 'showDuration') installDuration(); if (k === 'rewindBar') { if (!S.rewindBar) removeDvr(); else playerTick(); } if (k === 'dvrMode') { if (S.dvrMode) installDvr(); else dropDvr(); } if (k === 'hoverPreview' && !S.hoverPreview) stopPreview(); if (k === 'previewInline') stopPreview(); if (k === 'cardWatchBtn') $$('.cbx-tools').forEach(function (t) { t.remove(); }); if (k === 'bioInfo') { bioFor = null; renderBioInfo(); } if (k === 'alertsOn') startAlerts(); if (k === 'autoQuality') { startQualityWatchdog(); if (S.autoQuality) applyQuality(); } applySiteCSS(); decorateCards(); refreshPanel(); } var durTimer = null; function installDuration() { clearInterval(durTimer); var old = $('#cbx-duration'); if (!S.showDuration || !roomName()) { if (old) old.remove(); return; } durTimer = setInterval(function () { var v = activeVideo(); if (!v) return; var host = P.shell || v.closest('#cbx-watch') || v.closest(MAIN_PLAYER_SEL) || v.parentElement; if (!host) return; if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; var badge = $('#cbx-duration'); if (!badge) { badge = el('div', { id: 'cbx-duration' }); host.appendChild(badge); } badge.textContent = fmtTime(v.currentTime); }, 1000); } function listHTML(items, kind) { return items.map(function (v, i) { return '<div class="cbx-list-item"><b>' + esc(v) + '</b><button class="cbx-b cbx-b-quiet" data-un="' + kind + ':' + i + '">Undo</button></div>'; }).join(''); } // filtering flattens every tab into one list of matching rows function applyFilter(q) { q = (q || '').trim().toLowerCase(); panelEl.classList.toggle('cbx-filtering', !!q); var panes = $$('.cbx-pane', panelEl), total = 0; panes.forEach(function (pane) { var any = false; Array.prototype.forEach.call(pane.children, function (c) { if (!q) { c.classList.remove('cbx-hide'); return; } var row = c.classList.contains('cbx-row') || c.classList.contains('cbx-sel-wrap') || c.classList.contains('cbx-range'); var hit = row && (c.textContent || '').toLowerCase().indexOf(q) > -1; c.classList.toggle('cbx-hide', !hit); if (hit) any = true; }); pane.classList.toggle('cbx-match', any); if (any) total++; }); var none = $('#cbx-filter-none', panelEl); if (q && !total) { if (!none) $('#cbx-body', panelEl).appendChild(el('div', { id: 'cbx-filter-none', 'class': 'cbx-note' }, 'Nothing matches.')); } else if (none) none.remove(); } function refreshPanel() { if (!panelEl) return; $$('input[data-cbx]', panelEl).forEach(function (i) { i.checked = !!S[i.getAttribute('data-cbx')]; }); var tv = $('#cbx-tipvol', panelEl); if (tv && document.activeElement !== tv) { tv.value = S.tipVolume; $('#cbx-tipvol-val', panelEl).textContent = S.tipVolume + '%'; } var acts = $('#cbx-multi-actions', panelEl); if (acts) acts.style.display = S.multiCam ? 'block' : 'none'; var qn = $('#cbx-quality-note', panelEl); if (qn) { var dvrV = $('video.cbx-dvr'); var engine = requiredHls() ? 'hls.js ready' : 'hls.js not loaded'; var win = dvrV && dvrV.seekable && dvrV.seekable.length ? Math.round(dvrV.seekable.end(dvrV.seekable.length - 1) - dvrV.seekable.start(0)) + 's rewind window' : (dvrV ? 'no rewind window' : 'deep rewind off'); qn.textContent = 'Quality: ' + qualityNote + '\n' + engine + ' · ' + win; } var dn = $('#cbx-dvr-note', panelEl); if (dn) { var ring = P.ring; dn.textContent = (P.video ? P.heldNote : 'deep rewind not running') + (ring ? ' · ' + Math.round(ring.bytes / 1048576) + ' MB kept for saving' : ''); } var st = $('#cbx-tip-status', panelEl); if (st && !st.textContent) st.textContent = 'Tip volume: ' + tipStatus; var hidden = jsonGet(HIDE_KEY, []), box = $('#cbx-hidden-list', panelEl); if (box) box.innerHTML = hidden.length ? '<p class="cbx-note">Hidden by you</p>' + listHTML(hidden, 'sel') : ''; var al = alertList(), abox = $('#cbx-alert-list', panelEl); if (abox) abox.innerHTML = al.length ? '<p class="cbx-note">Alerting for</p>' + listHTML(al, 'alert') : ''; var block = jsonGet(BLOCK_KEY, []), bbox = $('#cbx-blocked-list', panelEl); if (bbox) bbox.innerHTML = block.length ? '<p class="cbx-note">Hidden cams</p>' + listHTML(block, 'room') : ''; $$('[data-un]', panelEl).forEach(function (b) { b.addEventListener('click', function () { var p = b.getAttribute('data-un').split(':'); var key = p[0] === 'sel' ? HIDE_KEY : (p[0] === 'alert' ? WATCH_KEY : BLOCK_KEY); var l = jsonGet(key, []); l.splice(parseInt(p[1], 10), 1); jsonSet(key, l); applySiteCSS(); decorateCards(); refreshPanel(); }); }); } function renderSchedule() { var box = $('#cbx-sched', panelEl); if (box) box.innerHTML = scheduleHTML(roomName()); } function exportAll() { var data = { settings: S, multi: jsonGet(MULTI_KEY, []), hidden: jsonGet(HIDE_KEY, []), blocked: jsonGet(BLOCK_KEY, []), notes: jsonGet(NOTES_KEY, {}), alerts: jsonGet(WATCH_KEY, []), seen: jsonGet(SEEN_KEY, {}) }; var url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); var a = el('a'); a.href = url; a.download = 'chaturbate-enhanced-plus.json'; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 2000); toast('Settings exported'); } function importAll(e) { var f = e.target.files && e.target.files[0]; if (!f) return; var fr = new FileReader(); fr.onload = function () { try { var d = JSON.parse(fr.result); if (d.settings) { for (var k in DEFAULTS) if (typeof d.settings[k] === typeof DEFAULTS[k]) S[k] = d.settings[k]; save(); } if (d.multi) jsonSet(MULTI_KEY, d.multi); if (d.hidden) jsonSet(HIDE_KEY, d.hidden); if (d.blocked) jsonSet(BLOCK_KEY, d.blocked); if (d.notes) jsonSet(NOTES_KEY, d.notes); if (d.alerts) jsonSet(WATCH_KEY, d.alerts); if (d.seen) jsonSet(SEEN_KEY, d.seen); applySiteCSS(); decorateCards(); refreshPanel(); toast('Settings imported'); } catch (err) { toast('That file could not be read'); } }; fr.readAsText(f); e.target.value = ''; } function openPanel(on) { if (!panelEl) return; panelEl.classList.toggle('cbx-on', on); scrimEl.classList.toggle('cbx-on', on); if (on) { refreshPanel(); localizePanel(); setTimeout(checkTabs, 350); } } // the site's mobile stylesheet can collapse our tab strip; if it did, // rebuild the tabs with every style inline and say what was measured function checkTabs() { var strip = $('#cbx-tabs', panelEl); if (!strip) return; var r = strip.getBoundingClientRect(), first = $('.cbx-tab', strip), fr = first ? first.getBoundingClientRect() : { width: 0, height: 0 }; var ok = r.height >= 10 && fr.width >= 10 && fr.height >= 10 && getComputedStyle(strip).display !== 'none'; log('tabs', Math.round(r.width) + 'x' + Math.round(r.height), 'first tab', Math.round(fr.width) + 'x' + Math.round(fr.height), ok ? 'ok' : 'COLLAPSED'); var dbg = $('#cbx-tabs-debug', panelEl), cs = getComputedStyle(strip), fcs = first ? getComputedStyle(first) : null; if (dbg) dbg.textContent = 'tabs strip: ' + Math.round(r.width) + 'x' + Math.round(r.height) + ' at ' + Math.round(r.left) + ',' + Math.round(r.top) + ' disp=' + cs.display + ' vis=' + cs.visibility + ' op=' + cs.opacity + ' ovf=' + cs.overflow + ' | first tab: ' + Math.round(fr.width) + 'x' + Math.round(fr.height) + (fcs ? ' color=' + fcs.color + ' bg=' + fcs.backgroundColor + ' font=' + fcs.fontSize : '') + ' | panel ' + Math.round(panelEl.getBoundingClientRect().top) + ' head ' + Math.round($('#cbx-head', panelEl).getBoundingClientRect().bottom) + ' body ' + Math.round($('#cbx-body', panelEl).getBoundingClientRect().top); if (ok || strip._cbxFixed) return; strip._cbxFixed = true; var css = 'display:block!important;visibility:visible!important;opacity:1!important;height:auto!important;min-height:0!important;'; strip.setAttribute('style', css + 'padding:6px 10px 8px;overflow:visible;'); $$('.cbx-tab', strip).forEach(function (b) { b.setAttribute('style', 'display:inline-block!important;visibility:visible!important;opacity:1!important;height:auto!important;width:auto!important;' + 'margin:2px 4px 2px 0;padding:8px 12px;border:1px solid #2a3138;border-radius:8px;background:#1b2128;color:#e8ebed;font:13px/1 system-ui,sans-serif;'); }); toast('Tabs were hidden by the site (' + Math.round(r.width) + 'x' + Math.round(r.height) + '); rebuilt', 4000); } function placeLauncher() { if (!launcherEl) return; launcherEl.style.display = S.showLauncher ? 'flex' : 'none'; var p = jsonGet(POS_KEY, null); if (p && typeof p.x === 'number') { launcherEl.style.left = Math.min(Math.max(p.x, 4), innerWidth - 44) + 'px'; launcherEl.style.top = Math.min(Math.max(p.y, 4), innerHeight - 44) + 'px'; launcherEl.style.right = 'auto'; launcherEl.style.bottom = 'auto'; } else { launcherEl.style.right = '12px'; launcherEl.style.bottom = '12px'; } placeDock(); } // the on/off pill sits beside the gear and follows it around function placeDock() { if (!dockEl || !launcherEl) return; var show = !!roomName() && S.showLauncher; dockEl.style.display = show ? 'inline-flex' : 'none'; if (!show) return; updatePlayerToggle(); var r = launcherEl.getBoundingClientRect(), w = dockEl.offsetWidth || 74, h = dockEl.offsetHeight || 30; var left = r.left - w - 8; if (left < 4) left = r.right + 8; dockEl.style.left = Math.round(left) + 'px'; dockEl.style.top = Math.round(r.top + (r.height - h) / 2) + 'px'; } function makeDraggable(node) { var dragging = false, moved = false, offX = 0, offY = 0; function down(e) { var p = e.touches ? e.touches[0] : e; dragging = true; moved = false; var r = node.getBoundingClientRect(); offX = p.clientX - r.left; offY = p.clientY - r.top; } function move(e) { if (!dragging) return; var p = e.touches ? e.touches[0] : e; moved = true; node.style.left = (p.clientX - offX) + 'px'; node.style.top = (p.clientY - offY) + 'px'; node.style.right = 'auto'; node.style.bottom = 'auto'; if (node === launcherEl) placeDock(); e.preventDefault(); } function up() { if (!dragging) return; dragging = false; if (moved) { var r = node.getBoundingClientRect(); jsonSet(POS_KEY, { x: r.left, y: r.top }); } else openPanel(!panelEl.classList.contains('cbx-on')); } node.addEventListener('mousedown', down); document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); node.addEventListener('touchstart', down, { passive: true }); document.addEventListener('touchmove', move, { passive: false }); document.addEventListener('touchend', up); node.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(true); } }); } var resizeTimer = null; window.addEventListener('resize', function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(function () { placeLauncher(); // re-clamp into the new viewport }, 150); }); function installGestures() { var startX = 0, startY = 0, tracking = false; document.addEventListener('touchstart', function (e) { if (!S.edgeSwipe || panelEl.classList.contains('cbx-on')) return; var t = e.touches[0]; tracking = t.clientX > innerWidth - 24; startX = t.clientX; startY = t.clientY; }, { passive: true }); document.addEventListener('touchmove', function (e) { if (!tracking) return; var t = e.touches[0]; if (startX - t.clientX > 45 && Math.abs(t.clientY - startY) < 40) { tracking = false; openPanel(true); } }, { passive: true }); var sy = 0, sheetDrag = false; panelEl.addEventListener('touchstart', function (e) { if (innerWidth >= 700) return; var t = e.touches[0]; sheetDrag = t.clientY < panelEl.getBoundingClientRect().top + 48; sy = t.clientY; }, { passive: true }); panelEl.addEventListener('touchmove', function (e) { if (!sheetDrag) return; if (e.touches[0].clientY - sy > 60) { sheetDrag = false; openPanel(false); } }, { passive: true }); } var toastTimer = null; // while something is fullscreen only its subtree is painted, so our fixed // UI has to live inside it (a bare <video> cannot hold children — skip then) function uiHost() { var fs = document.fullscreenElement || document.webkitFullscreenElement; return fs && fs.tagName !== 'VIDEO' && fs.isConnected ? fs : document.body; } function rehomeUI() { var host = uiHost(); [scrimEl, panelEl, launcherEl, dockEl, $('#cbx-toast')].forEach(function (n) { if (n && n.parentNode !== host) host.appendChild(n); }); placeDock(); } ['fullscreenchange', 'webkitfullscreenchange'].forEach(function (ev) { document.addEventListener(ev, function () { setTimeout(rehomeUI, 50); }); }); function toast(msg, ms) { var t = $('#cbx-toast'); if (!t) { t = el('div', { id: 'cbx-toast' }); uiHost().appendChild(t); } t.textContent = msg; t.classList.add('cbx-on'); clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.classList.remove('cbx-on'); }, ms || 2600); } /* ================================================================== * * loop + navigation * ================================================================== */ var tick = null; function scheduleWork() { clearTimeout(tick); tick = setTimeout(function () { decorateCards(); autoAcceptRules(); translateNewMessages(); if (S.inlinePreview) $$('video:not([playsinline])').forEach(tagInline); playerTick(); renderBioInfo(); applyTransform(); }, 180); } function onNavigate() { tipDone = false; tipStatus = 'not tried yet'; bioFor = null; onPlayerNavigate(); applySiteCSS(); applyDark(); installDuration(); scheduleWork(); qualityNote = 'not applied yet'; setTimeout(function () { applyTipMute(0); }, 800); setTimeout(applyQuality, 2500); } function hookHistory() { ['pushState', 'replaceState'].forEach(function (m) { var orig = history[m]; history[m] = function () { var r = orig.apply(this, arguments); try { onNavigate(); } catch (e) {} return r; }; }); window.addEventListener('popstate', onNavigate); } /* ================================================================== * * boot * ================================================================== */ if (window.top !== window.self && !IS_MULTI) return; try { console.log('[cep] Chaturbate Enhanced Plus 0.9.6 loaded on ' + location.pathname); } catch (e) {} installInlinePreview(); installSoundBlock(); installBgMute(); applyDark(); applySiteCSS(); onReady(function () { if (IS_MULTI) { buildMulti(); return; } if ('ontouchstart' in window) document.documentElement.classList.add('cbx-touch'); applySiteCSS(); buildUI(); installHoverPreview(); hookHistory(); installDuration(); decorateCards(); renderBioInfo(); installExclusiveAudio(); startAlerts(); startQualityWatchdog(); setTimeout(applyQuality, 2500); playerTick(); applyTipMute(0); (function pump() { var scrub = $('#cbx-scrub'); if (!document.hidden) { try { updateBar(); } catch (e) {} } setTimeout(pump, scrub && scrub._held ? 250 : 1000); })(); installKeys(); new MutationObserver(scheduleWork).observe(document.body, { childList: true, subtree: true }); }); })();