Seekable live playback for Chaturbate: a DVR-style buffer of several minutes (rewind, scrub, timeshift) from a single stream, a full player bar with preview frames, zoom, speed, quality, snapshot and recording, a multi-cam viewer, hover previews, chat translation, notes, live notifications, dark theme and site cleanup. Desktop and phones. Not affiliated with Chaturbate.
// ==UserScript== // @name Chaturbate Enhanced Plus // @namespace chaturbate.enhanced.plus // @version 2.0.3 // @description Seekable live playback for Chaturbate: a DVR-style buffer of several minutes (rewind, scrub, timeshift) from a single stream, a full player bar with preview frames, zoom, speed, quality, snapshot and recording, a multi-cam viewer, hover previews, chat translation, notes, live notifications, dark theme and site cleanup. Desktop and phones. Not affiliated with Chaturbate. // @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 MULTI_SETS_KEY = 'cbx-multi-sets'; 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 CC_KEY = 'cbx-hidden-countries'; 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, siteDvr: true, siteKeep: 300, swipeSeek: true, holdFast: true, phoneHeight: 0, multiFill: false, rotateFullscreen: true, zoomByRoom: {}, tipMarks: true, mediaSession: true, fsAutoHide: true, statsOverlay: false, scrubThumbs: true, dataSaver: false, dataSaverAuto: true, chatMuted: [], chatKeywords: '', chatTipsOnly: false, chatFont: 0, uiGen: 4, clipSave: false, keyShortcuts: true, dblTapSeek: true, autoQuality: true, qualityCap: 0, // appearance forceDark: true, hideAds: true, hideSocials: true, hideMerch: true, hideSurveys: true, darkLegacy: true, hidePlayerLogo: true, tightMargins: true, hideBadges: true, 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, multiHideOffline: true, multiAutoRemove: false, multiHoverAudio: false, multiHidePrivate: false, multiCols: 0, multiMaxHeight: 720, // tabs + alerts inactivePause: false, inactiveQuality: true, inactiveLoad: false, errorQuality: false, exclusiveAudio: false, alertsOn: false, alertEvery: 60, trackSchedule: true, // layout gridSize: 0, moreGridSize: 0, hideGenderF: false, hideGenderM: false, hideGenderC: false, hideGenderT: false, time24: false, // panel uiLang: 'auto', randomGenders: 'f', randomLink: true, randomCount: 6, showLauncher: true, edgeSwipe: true, // toolbar under the player volumeBoost: 100, voiceBoost: false, recAudioOnly: false, recVideoOnly: false, recLowPerf: false, recWarnLeave: true, debug: false }; var MINIMAL_SET = ['hideAds', 'hideSocials', 'hidePlayerLogo', 'tightMargins', 'hideBadges', 'cleanProfile', 'chatHideNotices', 'chatHideSubject']; var S = readSettings(); // version from the userscript header (GM_info is available even with @grant none); fallback for other loaders var VERSION = (function () { try { return GM_info.script.version; } catch (e) { return '2.0.3'; } })(); 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; if (gen < 3) { out.bioInfo = false; } if (gen < 4) { out.cardWatchBtn = true; out.openNewTab = true; } // 1.3.0: one Rewind switch; the engine is chosen from the page, never by a setting if (gen < 6) { out.siteDvr = true; } out.uiGen = 6; } catch (e) {} 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) {} } // the multi cam page is this same origin with a flag: the phone site (m.chaturbate.com) redirects // chaturbate.com links to itself and drops the query on the way, so the flag rides in the hash too var IS_MULTI = /[?&]cbx-multi=1/.test(location.search) || /(^|[#&])cbx-multi(=1)?(&|$)/.test(location.hash); function multiUrl() { return location.origin + '/?cbx-multi=1#cbx-multi'; } var WATCH_POP = (location.search.match(/[?&]cbx-watch=([a-z0-9_]+)/i) || [])[1] || ''; /* ================================================================== * * 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); if (this._cbxClock && P.armed && P.video && this === P.site) { log('site play() held: parked under our player'); return Promise.resolve(); } } 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; } 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); } /* ================================================================== * * site player rewind (step 1 of retiring the second player) * * Every MSE player trims its back buffer with SourceBuffer.remove(0, t), * frees space on QuotaExceededError and retries, and snaps a playhead * that fell behind its playlist window back to live by setting * currentTime. Hooking those three at the browser level turns the * site's own player into the rewind buffer: one stream, one decoder, * whatever engine the site runs. Off means every hook passes through. * ================================================================== */ var siteDvr = { hooked: false, halvings: 0, trims: 0, quota: 0, dropped: 0, skips: 0, lastDrop: '', beats: 0, rebuilds: 0, quietUntil: 0, lastSrc: '', lastRoom: '' }; var siteUiTap = 0; // the bar and the rewind live on the site's own player: no second player at all // true: the site player runs on Media Source (hls.js) and can be hooked. false: it plays // the .m3u8 natively (the mobile site) and cannot. null: no source yet, wait. function siteIsMse() { var v = P.site && P.site.isConnected ? P.site : siteVideo(); if (!v) return null; if (v.srcObject) return true; var s = v.currentSrc || v.src || ''; return s ? /^blob:/.test(s) : null; } // the deep (hls.js) player runs when asked for, or as the fallback for a native site player // rewind on: the site's own MSE player holds the window; where the site player is native (mobile), ours steps in function wantDeep() { return !!S.siteDvr && siteIsMse() === false; } function autoDeep() { return wantDeep(); } function siteDvrActive() { return !!S.siteDvr && !P.forcedSite && siteIsMse() === true; } function siteFlavor() { return !!(P.block && P.block.classList.contains('cbx-site')); } // the video that holds the rewind window: ours with deep rewind, the site's otherwise function dvrVideo() { return siteDvrActive() ? (P.site && P.site.isConnected ? P.site : siteVideo()) : P.video; } function heldTarget() { return siteDvrActive() ? keepBack() : (S.siteKeep || 300); } // seconds kept behind the playhead; halved each time the browser's buffer quota is hit function keepBack() { var s = S.siteKeep || 300; if (isPhone()) s = Math.min(s, 120); if (dataSaverOn()) s = Math.min(s, 60); return Math.max(30, s >> siteDvr.halvings); } function isOurVideo(v) { return !!(v._cbxHls || v.classList.contains('cbx-dvr') || v.classList.contains('cbx-inline-prev') || (v.closest && v.closest('#cbx-hover,#cbx-watch,#cbx-multi,#cbx-block'))); } // the site <video> fed by this buffer's MediaSource; decided once per buffer, // null for buffers that belong to our own players (previews, multi cam, deep rewind) function siteBufferVideo(sb) { if (sb._cbxSite !== undefined) return sb._cbxSite; var ms = sb._cbxMs, url = ms && ms._cbxUrl; if (!ms) { sb._cbxSite = null; return null; } var vids = $$('video'), v = null; for (var i = 0; i < vids.length; i++) { var x = vids[i]; if (x.srcObject === ms || (url && (x.src === url || x.currentSrc === url))) { v = x; break; } } if (!v) return null; // not attached yet; ask again on the next call sb._cbxSite = isOurVideo(v) ? null : v; return sb._cbxSite; } function installSiteDvr() { var MS = window.MediaSource, MMS = window.ManagedMediaSource, SB = window.SourceBuffer; if (siteDvr.hooked || !SB || !SB.prototype.remove || !(MS || MMS)) return; siteDvr.hooked = true; var ocu = URL.createObjectURL; URL.createObjectURL = function (o) { var u = ocu.apply(URL, arguments); try { if ((MS && o instanceof MS) || (MMS && o instanceof MMS)) o._cbxUrl = u; } catch (e) {} return u; }; [MS, MMS].forEach(function (Ctor) { if (!Ctor || !Ctor.prototype.addSourceBuffer) return; var asb = Ctor.prototype.addSourceBuffer; Ctor.prototype.addSourceBuffer = function () { var sb = asb.apply(this, arguments); try { sb._cbxMs = this; } catch (e) {} return sb; }; }); var orem = SB.prototype.remove; SB.prototype.remove = function (start, end) { if (S.siteDvr) { var v = siteBufferVideo(this); if (v) { var now = v.currentTime, floor = Math.max(0, now - keepBack()); if (end <= now) end = Math.min(end, floor); // back-buffer trim: stop at our floor else if (start < now) start = now; // full flush (quality change): keep what is behind if (end <= start) end = start + 0.001; // a no-op remove still fires updateend, so the engine's queue keeps moving siteDvr.trims++; } } return orem.call(this, start, end); }; var oapp = SB.prototype.appendBuffer; SB.prototype.appendBuffer = function () { try { return oapp.apply(this, arguments); } catch (e) { if (e && e.name === 'QuotaExceededError' && S.siteDvr && siteBufferVideo(this)) { siteDvr.quota++; if (keepBack() > 30) { siteDvr.halvings++; log('site buffer quota hit; now keeping ' + keepBack() + 's'); } } throw e; } }; // a tap on one of the site's own control buttons (its Live button, say) may move the // playhead forward; a tap on the picture itself (to focus the page) may not document.addEventListener('pointerdown', function (e) { var t = e.target; if (!t || !t.closest || t.closest('#cbx-block,#cbx-dd,#cbx-panel')) return; if (t.closest(MAIN_PLAYER_SEL) && t.closest('.vjs-control-bar,.theater-video-controls,button,[role="button"]')) siteUiTap = Date.now(); }, true); } /* ---- seeks ---- */ var timeDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'currentTime'); // every seek of ours goes through here so the guard can tell it from the site's function setTime(v, t) { P.ourSeek = true; try { v.currentTime = t; } finally { P.ourSeek = false; } } function bufferedRanges(v) { var out = [], b = v.buffered; for (var i = 0; i < b.length; i++) out.push([b.start(i), b.end(i)]); return out; } // keep seeks on held video: inside a hole the playhead would starve and the site would snap to live function snapToBuffered(v, t) { var r = bufferedRanges(v); if (!r.length) return t; var best = t, dist = Infinity; for (var i = 0; i < r.length; i++) { if (t >= r[i][0] && t <= r[i][1]) return t; var edge = t < r[i][0] ? r[i][0] + 0.1 : r[i][1] - 0.3, d = Math.abs(edge - t); if (d < dist) { dist = d; best = edge; } } return best; } function nextBufferedStart(v, now) { var r = bufferedRanges(v); for (var i = 0; i < r.length; i++) if (r[i][0] > now + 0.2) return r[i][0]; return null; } // while the user is rewound, only our own seeks (and the user's taps on the // site's controls) may move the playhead forward. The exception is a real // stall: at a hole in the held video the playhead is moved to the next // piece; when the held video has truly run out the site may go live. function guardSiteSeeks() { var v = siteVideo(); if (!v || v._cbxGuarded || !timeDesc || !timeDesc.set) return; v._cbxGuarded = true; Object.defineProperty(v, 'currentTime', { configurable: true, // while the site's player is parked under ours, the clock it reports keeps running: // its dead-man timer only wants to see currentTime advance (nothing else reads it then) get: function () { var c = v._cbxClock; return c ? c.base + (Date.now() - c.at) / 1000 : timeDesc.get.call(v); }, set: function (t) { if (S.siteDvr && !P.ourSeek && Date.now() - siteUiTap > 800) { var want = Number(t), now = timeDesc.get.call(v), w = seekWindow(v); if (w && now < w.end - 3 && want > now + 2) { var stalled = !v.seeking && v.readyState < 3; if (!stalled) { siteDvr.dropped++; var frames = String(new Error().stack || '').split('\n').slice(2, 4).map(function (l) { return l.trim(); }).join(' < '); // a full stack the first time each caller shows up; one line after that if (frames !== siteDvr.lastDrop) { siteDvr.lastDrop = frames; log('site seek to ' + want.toFixed(1) + ' dropped while rewound at ' + now.toFixed(1) + '\n' + new Error().stack); } else if (S.debug && siteDvr.dropped % 10 === 1) log('site seeks dropped: ' + siteDvr.dropped + ' (latest to ' + want.toFixed(1) + ' at ' + now.toFixed(1) + ')'); return; } var nx = nextBufferedStart(v, now); if (nx != null && nx + 0.1 < want) { siteDvr.skips++; log('starved at a hole; moving to ' + nx.toFixed(1)); t = nx + 0.1; } } } timeDesc.set.call(v, t); } }); } // The site arms a 10 s dead-man timer that only 'playing', 'waiting', or currentTime // advancing 5 s past its last checkpoint will reset. A rewound or paused player never // passes that checkpoint, so after 10 s the site rebuilds the player and the held // window is gone (twice in a row and it falls back to its JPEG player). While we are // behind live or paused, a synthetic 'playing' every 4 s keeps the timer reset; it is // what the browser fires after every real seek anyway, and the site's listeners for // it are the timer, a once-only metric and a class toggle. At live it is left alone. function siteHeartbeat() { if (!S.siteDvr) return; var v = siteVideo(); if (!v || !v._cbxGuarded || !v.isConnected) return; // parked native player: a timeupdate every tick lets its watchdog read the running clock if (v._cbxClock) { if (v.paused) { try { v.dispatchEvent(new Event('timeupdate')); siteDvr.beats++; } catch (e) {} } return; } if (siteIsMse() !== true) return; if (Date.now() - (siteDvr.lastBeat || 0) < 3900) return; var w = seekWindow(v); // nothing to keep alive until the stream is actually running; a video that has not // started yet is 'paused' too, and a playing event then only confuses the site's metrics if (!w || v.readyState < 2 || v.currentTime < 1) return; var behind = v.currentTime < w.end - 3; if (!behind && !v.paused) return; try { v.dispatchEvent(new Event('playing')); siteDvr.beats++; siteDvr.lastBeat = Date.now(); } catch (e) {} } // the site swapping its MediaSource (a rebuild, a room change) means a fresh hls.js that // is still finding its feet: leave its quality menu alone for a while function watchSiteRebuild() { var v = siteVideo(); if (!v) return; var src = v.currentSrc || v.src || ''; if (src === siteDvr.lastSrc) return; var room = roomName() || ''; if (siteDvr.lastSrc && src) { if (room === siteDvr.lastRoom) { siteDvr.rebuilds++; log('site player rebuilt (#' + siteDvr.rebuilds + ')'); } else log('room changed; site player reloaded'); } siteDvr.lastSrc = src; siteDvr.lastRoom = room; siteDvr.quietUntil = Date.now() + 15000; } // site flavor: is the picture actually on screen? names the first ancestor that hides it function sitePictureHidden() { var v = P.site && P.site.isConnected ? P.site : siteVideo(); if (!v) return ''; var n = v, hops = 0; while (n && n !== document.body && hops++ < 14) { var cs = getComputedStyle(n); var why = cs.display === 'none' ? 'display:none' : cs.visibility === 'hidden' ? 'visibility:hidden' : cs.opacity === '0' ? 'opacity:0' : ''; if (why) return (n === v ? 'the video itself' : (n.id ? '#' + n.id : n.tagName.toLowerCase() + (typeof n.className === 'string' && n.className.trim() ? '.' + n.className.trim().split(/\s+/).slice(0, 2).join('.') : ''))) + ' (' + why + ')'; n = n.parentElement; } var r = v.getBoundingClientRect(); if (r.width < 10 || r.height < 10) return 'the video has no size (' + Math.round(r.width) + 'x' + Math.round(r.height) + ')'; return ''; } // the parked native site player: paused, a clock that keeps running, its watchdog fed function startSiteClock(site) { if (site._cbxClock) return; var base = 0; try { base = timeDesc.get.call(site) || 0; } catch (e) {} site._cbxClock = { base: base, at: Date.now() }; log('site player clock started at ' + base.toFixed(1)); } function clearSiteClock() { var v = P.site && P.site.isConnected ? P.site : siteVideo(); if (v && v._cbxClock) { delete v._cbxClock; log('site player clock stopped'); } } function siteDvrNote() { var v = siteVideo(); if (!v) return 'site player: not on this page'; var src = v.currentSrc || v.src || ''; var kind = /^blob:/.test(src) ? 'MSE' : (src ? 'native HLS (nothing to hook' + (autoDeep() ? ' — deep rewind player used instead' : '') + ')' : 'no source yet'); var r = bufferedRanges(v), w = seekWindow(v); var hidden = siteFlavor() ? sitePictureHidden() : ''; var lines = [ 'site player: ' + kind + (v._cbxGuarded ? ' · seek guard on' : '') + (siteFlavor() ? ' · picture ' + (hidden ? 'HIDDEN by ' + hidden : 'visible') : ''), 'held: ' + (w ? fmtTime(w.end - w.start) : '—') + (r.length > 1 ? ' in ' + r.length + ' pieces' : '') + ' · keep ' + fmtTime(keepBack()) + (siteDvr.halvings ? ' (halved ' + siteDvr.halvings + '×)' : ''), 'trims clamped ' + siteDvr.trims + ' · quota hits ' + siteDvr.quota + ' · site seeks dropped ' + siteDvr.dropped + ' · hole skips ' + siteDvr.skips, 'heartbeats ' + siteDvr.beats + ' · site rebuilds ' + siteDvr.rebuilds + (Date.now() < siteDvr.quietUntil ? ' · quality changes paused ' + Math.ceil((siteDvr.quietUntil - Date.now()) / 1000) + 's' : '') ]; if (siteDvr.lastDrop) lines.push('last snap from: ' + siteDvr.lastDrop); if (!S.siteDvr) lines.unshift('off — hooks pass through'); return lines.join('\n'); } function refreshSiteDvrNote() { if (!panelEl || !panelEl.classList.contains('cbx-on')) return; var n = $('#cbx-sitedvr-note', panelEl); if (!n) return; var txt = siteDvrNote(); if (n.textContent !== txt) n.textContent = txt; } /* ================================================================== * * 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, ourSeek: false, seekSeq: 0 }; var userHasInteracted = false; ['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) { document.addEventListener(ev, function () { userHasInteracted = true; }, { capture: true, once: true }); }); function isTouch() { return document.documentElement.classList.contains('cbx-touch'); } // a phone-sized coarse-pointer screen; touch laptops and DevTools device mode on a wide window do not count function isPhone() { return isTouch() && innerWidth < 900; } function wantsTouchLayout() { if (!('ontouchstart' in window) && !(navigator.maxTouchPoints > 0)) return false; var coarse = false; try { coarse = matchMedia('(pointer:coarse)').matches; } catch (e) {} return coarse || innerWidth < 900; } function applyTouchClass() { var want = wantsTouchLayout(), had = isTouch(); document.documentElement.classList.toggle('cbx-touch', want); if (want !== had && P.block) { log('layout changed to', want ? 'touch' : 'desktop', '- rebuilding the player'); removeDvr(); scheduleWork(); } } // data saver: on by hand, or automatically on cellular / browser save-data / low battery var envSaver = false; function dataSaverOn() { return !!S.dataSaver || (!!S.dataSaverAuto && envSaver); } function watchEnvSaver() { var c = navigator.connection, batt = null; var eval_ = function () { var was = envSaver; envSaver = !!(c && (c.saveData || c.type === 'cellular')) || !!(batt && !batt.charging && batt.level <= 0.2); if (was !== envSaver) { log('data saver', envSaver ? 'on' : 'off'); if (P.eng && P.armed) pinTrack(bestTrackUnder(dvrCap())); updatePlayerToggle(); } }; if (c && c.addEventListener) c.addEventListener('change', eval_); if (navigator.getBattery) navigator.getBattery().then(function (b) { batt = b; ['levelchange', 'chargingchange'].forEach(function (ev) { b.addEventListener(ev, eval_); }); eval_(); }).catch(function () {}); eval_(); } function dvrBudget() { var mb = isPhone() ? 60 : 120; if (dataSaverOn()) mb = Math.round(mb / 2); return mb * 1024 * 1024; } // the site keeps a hidden theater-mode copy with the same ids: take the one that has a size var siteVideoCache = { at: 0, v: null }; function siteVideo() { if (siteVideoCache.v && siteVideoCache.v.isConnected && Date.now() - siteVideoCache.at < 400) return siteVideoCache.v; var out = siteVideoUncached(); siteVideoCache = { at: Date.now(), v: out }; return out; } function siteVideoUncached() { 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.siteKeep, liveDurationInfinity: true, capLevelToPlayerSize: false, lowLatencyMode: false, // sit ~4 segments behind live so jitter never drains the forward buffer; // drift back toward the edge at 1.05x instead of stalling and reloading maxBufferLength: 20, maxMaxBufferLength: 60, liveSyncDurationCount: 4, // liveMaxLatencyDurationCount stays at its default (Infinity): with a limit, hls.js seeks a // rewound player back to live on the next playlist load, which is exactly the rewind we hold maxBufferHole: 1.5, nudgeMaxRetry: 10, startFragPrefetch: true, abrEwmaDefaultEstimate: 2500000, 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: buf, start: f.start, end: f.start + (f.duration || 0) }); ring.bytes += buf.byteLength; while (ring.frags.length && ring.bytes > dvrBudget() / 2) ring.bytes -= ring.frags.shift().buf.byteLength; }); }; return E; } // phones: 480p keeps the forward buffer healthy on a 60 MB budget function dvrCap() { var want = S.qualityCap ? Math.min(S.qualityCap, 720) : 720; if (isPhone()) want = Math.min(want, 480); if (dataSaverOn()) want = want ? Math.min(want, 360) : 360; return want; } 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; // reserve room for ~20s of forward buffer before sizing the rewind window var fwd = 20 * bps / 8; var use = Math.max(60, Math.min(S.siteKeep, Math.floor(Math.max(0, budget - fwd) / (bps / 8)))); P.eng.setBehind(use); P.heldSec = use; if (use < S.siteKeep) { // 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.siteKeep; })[0]; P.heldNote = 'holds ~' + fmtTime(use) + ' at ' + (t.label || t.height + 'p') + ' (asked for ' + fmtTime(S.siteKeep) + ')' + (fits ? ' — ' + (fits.label || fits.height + 'p') + ' would hold the full ' + fmtTime(S.siteKeep) : ' — 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; target = snapToBuffered(v, target); if (Math.abs(target - before) < 0.3) { if (!quiet) toast(target <= before ? 'Already at the start of the buffer' : 'Already live'); return false; } try { setTime(v, target); } catch (e) { toast('This player refused the seek'); return false; } var seq = ++P.seekSeq; setTimeout(function () { if (seq !== P.seekSeq) return; // a newer seek took over; judging this one would be wrong 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(S.siteDvr ? 'The site player pulled back to live — see the note in the Rewind tab' : '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 setRate(v, r) { try { v.playbackRate = r; } catch (e) {} syncRateButtons(); } function toggleCatchUp() { var v = activeVideo(); if (!v) return; setRate(v, v.playbackRate > 1 ? 1 : 2); toast(v.playbackRate > 1 ? 'Catching up at 2×' : 'Normal speed'); } function toggleSlow() { var v = activeVideo(); if (!v) return; setRate(v, v.playbackRate < 1 ? 1 : 0.5); toast(v.playbackRate < 1 ? 'Slow motion 0.5×' : 'Normal speed'); } function syncRateButtons() { var v = activeVideo(), r = v ? v.playbackRate : 1; $$('[data-act="fast"]').forEach(function (b) { b.classList.toggle('cbx-on', r > 1); }); $$('[data-act="slow"]').forEach(function (b) { b.classList.toggle('cbx-on', r < 1); }); $$('[data-act="loop"]').forEach(function (b) { b.classList.toggle('cbx-on', P.loopA != null); b.title = loopTitle(); }); } // A-B loop over the held buffer: press once for A, again for B, again to clear function loopTitle() { return P.loopB != null ? 'Loop set (click to clear)' : P.loopA != null ? 'A set — click to set B' : 'Set loop start (A)'; } function cycleLoop() { var v = activeVideo(); if (!v) return; if (P.loopA == null) { P.loopA = v.currentTime; toast('Loop start set — press again for the end'); } else if (P.loopB == null) { if (v.currentTime <= P.loopA + 0.5) { toast('Loop end must be after the start'); return; } P.loopB = v.currentTime; toast('Looping ' + fmtTime(Math.round(P.loopB - P.loopA))); if (!v._cbxLoop) { v._cbxLoop = function () { if (P.loopA != null && P.loopB != null && v.currentTime >= P.loopB) { try { v.currentTime = P.loopA; } catch (e) {} } }; v.addEventListener('timeupdate', v._cbxLoop); } } else { clearLoop(); toast('Loop cleared'); } syncRateButtons(); updateBar(); } function clearLoop() { P.loopA = P.loopB = null; syncRateButtons(); } // bookmarks: B drops one at the current time, [ and ] jump between them function addBookmark() { var v = activeVideo(), w = seekWindow(v); if (!v || !w) return; P.marks = P.marks || []; var t = v.currentTime, near = P.marks.filter(function (m) { return Math.abs(m - t) < 1.5; })[0]; if (near != null) { P.marks = P.marks.filter(function (m) { return m !== near; }); toast('Bookmark removed'); } else { P.marks.push(t); P.marks.sort(function (a, b) { return a - b; }); toast('Bookmark ' + P.marks.length + ' at −' + fmtTime(Math.max(0, w.end - t))); } updateBar(); } function jumpBookmark(dir) { var v = activeVideo(); if (!v || !P.marks || !P.marks.length) { toast('No bookmarks yet — press B to add one'); return; } var t = v.currentTime, list = dir > 0 ? P.marks.filter(function (m) { return m > t + 0.8; }) : P.marks.filter(function (m) { return m < t - 0.8; }).reverse(); if (!list.length) { toast(dir > 0 ? 'No later bookmark' : 'No earlier bookmark'); return; } seekTo(v, list[0], true); updateBar(); } function pruneMarks(w) { if (P.marks) P.marks = P.marks.filter(function (m) { return m >= w.start; }); if (P.tips) P.tips = P.tips.filter(function (m) { return m >= w.start; }); } // small frames every 10s for the scrub preview; kept in memory only var THUMB_EVERY = 10, THUMB_W = 160, THUMB_COLS = 8; // all frames share one sprite-sheet canvas; slots are reused in a ring function thumbSheet(v) { var need = Math.ceil(heldTarget() / THUMB_EVERY) + 2, th = Math.round(THUMB_W * v.videoHeight / v.videoWidth) || 90; var sh = P.sheet; if (!sh || sh.slots !== need || sh.h !== th) { var c = document.createElement('canvas'); c.width = THUMB_W * THUMB_COLS; c.height = th * Math.ceil(need / THUMB_COLS); sh = P.sheet = { c: c, ctx: c.getContext('2d'), slots: need, h: th, next: 0 }; P.thumbs = []; } return sh; } function captureThumb() { var v = dvrVideo(); if (!S.scrubThumbs || !v || !(P.armed || siteDvrActive()) || v.paused || v.readyState < 2 || !v.videoWidth) return; var w = seekWindow(v); if (!w) return; P.thumbs = P.thumbs || []; var last = P.thumbs[P.thumbs.length - 1]; if (last && v.currentTime - last.t < THUMB_EVERY - 0.5) return; try { var sh = thumbSheet(v), slot = sh.next; sh.next = (sh.next + 1) % sh.slots; var x = (slot % THUMB_COLS) * THUMB_W, y = Math.floor(slot / THUMB_COLS) * sh.h; sh.ctx.drawImage(v, x, y, THUMB_W, sh.h); P.thumbs = P.thumbs.filter(function (th) { return th.t >= w.start && th.slot !== slot; }); P.thumbs.push({ t: v.currentTime, slot: slot }); } catch (e) { S.scrubThumbs = false; log('thumbs off:', e && e.message); } } function showThumbAt(scrub, clientX) { var v = activeVideo(), w = seekWindow(v); var box = $('#cbx-thumb-prev', P.bar); if (!box || !v || !w || !S.scrubThumbs || !P.thumbs || !P.thumbs.length || v !== dvrVideo()) return false; var r = scrub.getBoundingClientRect(), frac = Math.min(1, Math.max(0, (clientX - r.left) / Math.max(1, r.width))); var t = w.end - scrubWindow(v, w) * (1 - frac); if (t < w.start) return false; var best = null; P.thumbs.forEach(function (th) { if (!best || Math.abs(th.t - t) < Math.abs(best.t - t)) best = th; }); if (!best || Math.abs(best.t - t) > THUMB_EVERY) return false; var sh = P.sheet, pc = box.firstChild; if (sh && box._slot !== best.slot) { box._slot = best.slot; pc.width = THUMB_W; pc.height = sh.h; pc.getContext('2d').drawImage(sh.c, (best.slot % THUMB_COLS) * THUMB_W, Math.floor(best.slot / THUMB_COLS) * sh.h, THUMB_W, sh.h, 0, 0, THUMB_W, sh.h); } return true; } function hideThumb() { var b = $('#cbx-thumb-prev'); if (b) b.style.display = 'none'; } // hover on the timeline: a marker on the track, a time caption above it and the nearest thumbnail function showHoverAt(scrub, clientX) { var v = activeVideo(), w = seekWindow(v); if (!v || !w) { hideHover(scrub); 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), behind = Math.max(0, w.end - t); scrub.classList.add('cbx-hovering'); var mk = $('.cbx-hover', scrub); if (mk) mk.style.left = (frac * 100).toFixed(2) + '%'; var box = $('#cbx-thumb-prev', P.bar); if (!box) { box = el('div', { id: 'cbx-thumb-prev' }); box.appendChild(document.createElement('canvas')); box.appendChild(el('span')); P.bar.appendChild(box); } var cap = box.lastChild; cap.textContent = t < w.start ? 'not held' : (behind < liveEps() ? 'live' : '−' + fmtTime(behind)); var hasThumb = showThumbAt(scrub, clientX); box.firstChild.style.display = hasThumb ? 'block' : 'none'; var barR = P.bar.getBoundingClientRect(), x = clientX - barR.left, half = (hasThumb ? THUMB_W : 48) / 2; box.style.left = Math.max(half + 4, Math.min(barR.width - half - 4, x)) + 'px'; box.style.display = 'block'; } function hideHover(scrub) { if (scrub) scrub.classList.remove('cbx-hovering'); hideThumb(); } function toggleStats() { S.statsOverlay = !S.statsOverlay; save(); var st = $('#cbx-stats'); if (st && !S.statsOverlay) st.remove(); updateBar(); toast(S.statsOverlay ? 'Stats on' : 'Stats off'); } function renderStats(v, w, behind) { if (!P.shell) return; var st = $('#cbx-stats', P.shell); if (!st) { st = el('div', { id: 'cbx-stats' }); P.shell.appendChild(st); } var t = P.eng && P.eng.active(), h = P.eng && P.eng.h, q = v.getVideoPlaybackQuality ? v.getVideoPlaybackQuality() : null; var lat = h && typeof h.latency === 'number' && isFinite(h.latency) ? h.latency : null; var lines = [ v.videoWidth + '×' + v.videoHeight + (t && t.fps ? ' @ ' + Math.round(t.fps) : ''), t && t.bw ? Math.round(t.bw / 1000) + ' kbps' + (P.eng.isAuto() ? ' (auto)' : ' (pinned)') : '', lat != null ? 'latency ' + lat.toFixed(1) + 's' : (behind ? 'behind ' + fmtTime(Math.round(behind)) : 'live'), 'ahead ' + Math.round(P.fwdBuf || 0) + 's · held ' + fmtTime(Math.round(w.end - w.start)), q ? 'dropped ' + q.droppedVideoFrames + '/' + q.totalVideoFrames : '', (P.stalls ? P.stalls + ' stalls' : '') + (dataSaverOn() ? ' · data saver' : '') ].filter(Boolean); var html = lines.join('<br>'); if (st._html !== html) { st._html = html; st.innerHTML = html; } } 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.box || (activeVideo() && activeVideo().closest(MAIN_PLAYER_SEL)) || activeVideo(); if (!target) return; try { if (document.fullscreenElement) document.exitFullscreen(); else if (target.requestFullscreen) { var pr = target.requestFullscreen(); if (pr && pr.then && isPhone() && S.rotateFullscreen && screen.orientation && screen.orientation.lock) pr.then(function () { screen.orientation.lock('landscape').catch(function () {}); }, function () {}); } else if (P.video && P.video.webkitEnterFullscreen) P.video.webkitEnterFullscreen(); } catch (e) {} } document.addEventListener('fullscreenchange', function () { if (!document.fullscreenElement) { try { if (screen.orientation && screen.orientation.unlock) screen.orientation.unlock(); } catch (e) {} P.autoFs = false; } }); function saveClip() { var ring = P.ring; if (!ring) { toast('Turn on "Instant clip of the rewind window" in the Record menu under the player, then reload the room', 5000); return; } if (!ring.frags.length) { toast('Nothing held yet'); return; } var parts = [], frags = ring.frags, ranged = P.loopA != null && P.loopB != null; if (ranged) { frags = frags.filter(function (f) { return f.end > P.loopA && f.start < P.loopB; }); if (!frags.length) { toast('The loop range is not in the saved buffer'); return; } } if (ring.ext === 'mp4') { if (!ring.init) { toast('No init segment captured yet; wait a moment'); return; } parts.push(ring.init); } parts = parts.concat(frags.map(function (f) { return f.buf; })); 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' + (ranged ? ' (loop range, cut on segment edges)' : '')); } /* ---- block + bar ---- */ var I = { gear: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84a.48.48 0 0 0-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87a.48.48 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.01-1.58zM12 15.6a3.6 3.6 0 1 1 0-7.2 3.6 3.6 0 0 1 0 7.2z"/></svg>', 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>', prev: '<svg viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6M7 6v12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>', next: '<svg viewBox="0 0 24 24"><path d="M9 6l6 6-6 6M17 6v12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>', flag: '<svg viewBox="0 0 24 24"><path d="M6 21V4h10l-2 4 2 4H6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>', loop: '<svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3M18 3v4h-4M6 21v-4h4" 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">' + '<div id="cbx-scrub" role="slider" title="Drag to rewind. Right edge is live."><div class="cbx-held"></div><div class="cbx-fill"></div><i class="cbx-hover"></i><div class="cbx-thumb"></div><span id="cbx-scrub-at" class="cbx-time">live</span></div></div>' + '<div class="cbx-row cbx-row-btns">' + '<button data-act="pause" aria-label="Play or pause">' + I.play + '</button>' + '<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>' + '<span class="cbx-volwrap"><button data-act="mute" aria-label="Mute or unmute">' + I.vol + '</button>' + '<div id="cbx-vol" role="slider" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"><div class="cbx-vfill"></div><div class="cbx-vthumb"></div></div></span>' + '<button data-act="live" class="cbx-live" title="Go to live (End)"><i></i>LIVE</button>' + '<span id="cbx-behind"></span>' + '<span class="cbx-grow"></span>' + '<button data-act="snap" title="Save a frame (S)">' + I.cam + '</button>' + '<button data-act="rec" id="cbx-rec" title="Record (R)"><span id="cbx-rec-dot"></span><span id="cbx-rec-txt"></span></button>' + '<button data-act="quality" class="cbx-qbtn" title="Quality (Q)" aria-haspopup="true"><span>…</span></button>' + '<button data-act="gear" title="Settings and tools" aria-haspopup="true">' + I.gear + '</button>' + '<button data-act="fs" title="Fullscreen">' + I.fs + '</button></div>' + '<div id="cbx-recbadge" hidden>● <span></span></div>'; } function ensureBlock() { if (P.block && P.block.isConnected) return P.block; if (!roomName() || !(wantDeep() || siteDvrActive()) || 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); // One layout everywhere: the block overlays the site's own player box and never resizes it. // The picture (the site's video, or ours in the shell) fills the box exactly as the site laid // it out, and the bar floats over the bottom and fades when idle. On phones the site owns the // box height and rewrites it on its own layout passes; growing it only ever ended in a fight. var siteMode = siteDvrActive(); block.classList.add('cbx-overlay'); if (siteMode) block.classList.add('cbx-site'); if (getComputedStyle(P.box).position === 'static') P.box.style.position = 'relative'; P.box.appendChild(block); P.block = block; P.shell = shell; P.bar = bar; document.documentElement.classList.add('cep-block'); ensureStripCSS(); wireBar(bar, shell); if (!P.smoothRaf) P.smoothRaf = requestAnimationFrame(smoothLoop); // zoom: a dimmed vertical slider on the left edge; fades with the controls var zs = el('div', { id: 'cbx-zoomside', title: 'Zoom — double-click or double-tap the slider to reset' }, '<button type="button" id="cbx-zoom-val" title="Fit · Fill · 1.5× · 2×">Fit</button><span class="cbx-zwrap"><input type="range" id="cbx-zoom" min="50" max="300" step="5" value="100" aria-label="Zoom"></span>'); block.appendChild(zs); // a large play button in the middle while paused (also the tap target when autoplay was blocked) var big = el('button', { id: 'cbx-bigplay', type: 'button', 'aria-label': 'Play' }, I.play); block.appendChild(big); ['pointerdown', 'mousedown', 'touchstart'].forEach(function (ev) { big.addEventListener(ev, function (e) { e.stopPropagation(); }); }); big.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); togglePause(); }); ['mousedown', 'touchstart', 'click'].forEach(function (ev) { zs.addEventListener(ev, function (e) { e.stopPropagation(); }); }); var zin = $('#cbx-zoom', zs), zTapAt = 0, zTapY = 0, zResetAt = 0; // reset to Fit and make the slider agree: syncBar leaves a focused slider alone, and on a // phone the tap that triggered this also moved the thumb, so the value is written here var zoomReset = function () { zResetAt = Date.now(); setEffZoom(1); rememberZoom(); zin.value = Math.round(effZoom() * 100); try { zin.blur(); } catch (e) {} toast('Zoom reset'); }; var zoomSettled = function (e) { if (Date.now() - zResetAt < 500) { e.target.value = Math.round(effZoom() * 100); return false; } return true; }; zin.addEventListener('input', function (e) { if (zoomSettled(e)) setEffZoom(parseInt(e.target.value, 10) / 100); }); zin.addEventListener('change', function (e) { if (zoomSettled(e)) rememberZoom(); }); if (xform.zoom === 1) xform.zoom = roomZoom(); // mouse: double-click anywhere on the zoom column. Touch: two quick taps on the slider (a // range input does not raise dblclick reliably on phones, it just moves the thumb twice) zs.addEventListener('dblclick', function (e) { e.preventDefault(); if (Date.now() - zResetAt > 500) zoomReset(); }); zs.addEventListener('pointerdown', function (e) { e.stopPropagation(); if (e.pointerType === 'mouse' || e.target.closest('button')) return; var now = Date.now(); if (now - zTapAt < 350 && Math.abs(e.clientY - zTapY) < 40) { zTapAt = 0; zoomReset(); return; } zTapAt = now; zTapY = e.clientY; }); $('#cbx-zoom-val', zs).addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); cycleZoom(); }); wirePan(shell); wireOverlay(block); installPhoneLayout(); if (siteMode) setTimeout(function () { if (P.block === block && block.isConnected) { var h = sitePictureHidden(); if (h) { log('site picture hidden by ' + h); toast('The site picture is hidden by ' + h + ' — see the Rewind tab note', 6000); } } }, 2000); 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; } // site flavor: the bar floats over the picture and fades when the pointer rests on // the picture or leaves the player; any activity brings it back, and it stays while // paused, while scrubbing, while the quality menu is open or the pointer is on it function wireOverlay(block) { var box = P.box; ['pointermove', 'pointerdown', 'touchstart'].forEach(function (ev) { box.addEventListener(ev, function () { if (P.block === block) armIdleHide(); }, { passive: true }); }); box.addEventListener('pointerleave', function () { if (P.block === block) armIdleHide(800); }); armIdleHide(); } function videoAspect() { var v = P.video && P.video.videoWidth ? P.video : (P.site && P.site.videoWidth ? P.site : null); return v ? v.videoHeight / v.videoWidth : 9 / 16; } // phones: swipe across the picture to seek, press and hold for 2x (vertical drags still scroll) function wirePan(shell) { var x0 = 0, y0 = 0, t0 = 0, px0 = 0, py0 = 0, mode = null, hold = null, held2x = false; var SWIPE_SEC = 90; // a full-width swipe moves this many seconds var zoomed = panActive; shell.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse' && (e.button !== 0 || !zoomed())) return; x0 = e.clientX; y0 = e.clientY; mode = 'wait'; held2x = false; px0 = xform.panX || 0; py0 = xform.panY || 0; var v = activeVideo(); t0 = v ? v.currentTime : 0; clearTimeout(hold); if (zoomed()) { try { shell.setPointerCapture(e.pointerId); } catch (err) {} return; } if (S.holdFast) hold = setTimeout(function () { if (mode !== 'wait') return; var vv = activeVideo(); if (!vv || vv.paused) return; mode = 'hold'; held2x = true; setRate(vv, 2); toast('2× while held'); }, 450); }); shell.addEventListener('pointermove', function (e) { if (!mode || mode === 'hold') return; var dx = e.clientX - x0, dy = e.clientY - y0; if (mode === 'wait') { if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; clearTimeout(hold); if (zoomed()) { if (P.block && P.block.classList.contains('cbx-panx') && Math.abs(dy) > Math.abs(dx)) { mode = 'scroll'; return; } mode = 'pan'; } else if (e.pointerType === 'mouse') { mode = null; return; } else if (Math.abs(dy) > Math.abs(dx)) { mode = 'scroll'; return; } else mode = S.swipeSeek ? 'seek' : 'scroll'; } if (mode === 'pan') { xform.panX = px0 + dx; xform.panY = py0 + dy; applyTransform(); return; } if (mode === 'seek') { var v = activeVideo(), w = seekWindow(v); if (!v || !w) return; var t = Math.max(w.start + 0.3, Math.min(w.end - 0.5, t0 + dx / Math.max(1, shell.clientWidth) * SWIPE_SEC)); try { setTime(v, t); } catch (err) {} var scrub = $('#cbx-scrub', P.bar); if (scrub) scrub._pos = 1 - (w.end - t) / scrubWindow(v, w); updateBar(true); } }); ['pointerup', 'pointercancel', 'pointerleave'].forEach(function (ev) { shell.addEventListener(ev, function () { // touch fires pointerleave right after pointerup: a second pass here with mode already // cleared used to reset the swallow flag, and the click that followed a drag or a hold // then toggled pause if (!mode) return; clearTimeout(hold); if (held2x) { var v = activeVideo(); if (v) setRate(v, 1); held2x = false; } // a timestamp, not a flag: a drag usually produces no click at all, and a plain flag // would then swallow the next unrelated tap shell._swallow = mode === 'seek' || mode === 'hold' || mode === 'pan' ? Date.now() : 0; mode = null; }); }); } 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, ddEl, $('#cbx-toast')].forEach(function (n) { if (n && P.block.contains(n)) document.body.appendChild(n); }); try { P.block.remove(); } catch (e) {} } if (P.smoothRaf) { cancelAnimationFrame(P.smoothRaf); P.smoothRaf = null; } removePhoneLayout(); P.block = P.shell = P.bar = null; P.edge = null; document.documentElement.classList.remove('cep-block'); } function wireBar(bar, shell) { // keep the site from seeing presses on our bar, but only after our own controls have handled them: // a capture-phase stop here swallowed the scrubber's pointerdown for a year ['pointerdown', 'mousedown', 'touchstart'].forEach(function (ev) { bar.addEventListener(ev, function (e) { e.stopPropagation(); }, { passive: true }); }); bar.addEventListener('click', function (e) { var b = e.target.closest('button'); if (!b) return; e.preventDefault(); e.stopPropagation(); runAct(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); showHoverAt(scrub, e.clientX); }); scrub.addEventListener('pointermove', function (e) { if (scrub._held) seekAt(e.clientX); if (scrub._held || e.pointerType === 'mouse') showHoverAt(scrub, e.clientX); }); ['pointerup', 'pointercancel'].forEach(function (ev) { scrub.addEventListener(ev, function (e) { scrub._held = false; if (e.pointerType !== 'mouse') hideHover(scrub); }); }); scrub.addEventListener('pointerleave', function () { if (!scrub._held) hideHover(scrub); }); var vol = $('#cbx-vol', bar); // no hover on touch screens: a tap on the mute button also shows the slider for a moment var volwrap = $('.cbx-volwrap', bar); if (volwrap) volwrap.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse') return; volwrap.classList.add('cbx-open'); clearTimeout(volwrap._t); volwrap._t = setTimeout(function () { volwrap.classList.remove('cbx-open'); }, 3000); }); var volAt = function (clientX) { var r = vol.getBoundingClientRect(), f = Math.min(1, Math.max(0, (clientX - r.left) / Math.max(1, r.width))); var v = activeVideo(); if (!v) return; userHasInteracted = true; v.volume = f; v.muted = f === 0; paintVol(vol, f); }; vol.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); vol._held = true; vol.classList.add('cbx-held'); try { vol.setPointerCapture(e.pointerId); } catch (err) {} volAt(e.clientX); }); vol.addEventListener('pointermove', function (e) { if (vol._held) volAt(e.clientX); }); ['pointerup', 'pointercancel'].forEach(function (ev) { vol.addEventListener(ev, function () { vol._held = false; vol.classList.remove('cbx-held'); }); }); var lastTap = 0, lastX = 0, single = null; shell.addEventListener('click', function (e) { var swallowed = shell._swallow && Date.now() - shell._swallow < 700; shell._swallow = 0; if (swallowed) return; 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); var cx = e.clientX, cy = e.clientY; single = setTimeout(function () { single = null; // controls hidden: the first tap brings them back and does nothing else if (P.block && P.block.classList.contains('cbx-idle')) { armIdleHide(); return; } if (!siteFlavor()) { togglePause(); return; } // the site's own prompts sit under our transparent shell; hand them the tap var under = null; try { P.block.style.pointerEvents = 'none'; under = document.elementFromPoint(cx, cy); } catch (err) {} finally { P.block.style.pointerEvents = ''; } if (under && under.tagName !== 'VIDEO' && under.closest && under.closest(MAIN_PLAYER_SEL) && !under.closest('#cbx-block')) { try { under.click(); } catch (err) {} return; } togglePause(); }, S.dblTapSeek ? 330 : 0); }); } var ACTS = { pause: togglePause, mute: toggleMute, back: function () { seekBack(10); }, fwd: function () { seekForward(10); }, live: goLive, snap: snapshotFrame, clip: function () { saveClip(); }, pip: togglePiP, fs: goFullscreen, fast: toggleCatchUp, slow: toggleSlow, loop: cycleLoop, mark: addBookmark, stats: toggleStats, rec: toggleRecording, quality: function () { var b = P.bar && $('button[data-act="quality"]', P.bar); if (b) openDD('quality', b, { gear: true }); }, gear: function () { var b = P.bar && $('button[data-act="gear"]', P.bar); if (b) openDD('menu', b, { gear: true }); } }; function runAct(a) { var f = ACTS[a]; if (f) f(); } function scrubWindow(v, w) { return Math.max(w.end - w.start, v === P.video ? (P.heldSec || S.siteKeep) : (siteDvrActive() ? keepBack() : 0), 1); } // within this of the buffered edge counts as live: the edge grows in segment-sized steps // while the playhead moves smoothly, so a live player is always a second or two "behind" function liveEps() { return 6; } // the timeline's playhead, fill and label. Called once a second with everything else and // once per frame while the controls are visible, so the thumb glides instead of stepping. // At live the thumb is pinned to the right edge; it only moves once you are actually behind. function paintScrub(R, v, w, dragPos) { var win = scrubWindow(v, w), span = Math.max(0.1, w.end - w.start); // the buffered edge advances in steps; between steps assume it keeps growing at 1x (capped) var E = P.edge || (P.edge = { v: null, end: 0, at: 0 }); if (E.v !== v || Math.abs(w.end - E.end) > 0.01) { E.v = v; E.end = w.end; E.at = performance.now(); } var edge = w.end + Math.min(2.5, Math.max(0, (performance.now() - E.at) / 1000)); var behind = Math.max(0, edge - v.currentTime); // our hls.js engine runs a few seconds behind the buffered edge on purpose (its live-sync target), // so ask it where live is; the site's player is judged by its buffer var h = v === P.video && P.eng ? P.eng.h : null, lsp = h && h.liveSyncPosition; var atLive = dragPos == null && (lsp > 0 ? v.currentTime > lsp - 3 : behind < liveEps()); var pos = dragPos != null ? dragPos : (atLive ? 1 : 1 - behind / win); pos = Math.min(1, Math.max(0, pos)); var heldFrom = Math.max(0, 1 - span / win); var l = (pos * 100).toFixed(2) + '%'; if (R._posL !== l) { R._posL = l; R.fill.style.left = (heldFrom * 100).toFixed(2) + '%'; R.fill.style.width = (Math.max(0, pos - heldFrom) * 100).toFixed(2) + '%'; R.thumb.style.left = l; R.at.style.left = l; R.at.style.transform = pos < 0.08 ? 'translateX(-10%)' : pos > 0.92 ? 'translateX(-90%)' : 'translateX(-50%)'; } var txt = atLive ? 'live' : '−' + fmtTime(behind); if (R.at.textContent !== txt) R.at.textContent = txt; return { pos: pos, behind: behind, atLive: atLive }; } // per-frame paint while the controls are showing; stops itself when the bar goes away function smoothLoop() { P.smoothRaf = null; var bar = P.bar; if (!bar || !bar.isConnected) return; // nothing to animate while hidden or idle: stop, and kickSmooth() restarts us when the controls show if (document.hidden || !P.block || P.block.classList.contains('cbx-idle')) return; if (bar._refs && !bar._refs.scrub._held) { var v = activeVideo(), w = seekWindow(v); if (v && w && !v.paused) paintScrub(bar._refs, v, w, null); } P.smoothRaf = requestAnimationFrame(smoothLoop); } function kickSmooth() { if (!P.smoothRaf && P.bar && P.bar.isConnected && !document.hidden) P.smoothRaf = requestAnimationFrame(smoothLoop); } function paintVol(vol, f) { var pct = (Math.min(1, Math.max(0, f)) * 100).toFixed(1) + '%'; if (vol._pct === pct) return; vol._pct = pct; var fill = vol.firstChild, th = vol.lastChild; if (fill) fill.style.width = pct; if (th) th.style.left = pct; vol.setAttribute('aria-valuenow', Math.round(f * 100)); } function updateBar(fromScrub) { var bar = P.bar; if (!bar || !bar.isConnected) return; var v = activeVideo(), w = seekWindow(v); var R = bar._refs || (bar._refs = { out: $('#cbx-behind', bar), scrub: $('#cbx-scrub', bar), at: $('#cbx-scrub-at', bar), held: $('.cbx-held', bar), fill: $('.cbx-fill', bar), thumb: $('.cbx-thumb', bar), pause: $('button[data-act="pause"]', bar), mute: $('button[data-act="mute"]', bar), vol: $('#cbx-vol', bar), qbtn: $('button[data-act="quality"] span', bar) }); var out = R.out, scrub = R.scrub, at = R.at; 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), win = scrubWindow(v, w); var st = paintScrub(R, v, w, (scrub._held || fromScrub) && scrub._pos != null ? scrub._pos : null); var behind = st.behind, atLive = st.atLive; var held = R.held; // each buffered range is its own band, so a hole after a quality change shows as a gap var bands = bufferedRanges(v).map(function (rg) { return '<i style="left:' + (Math.max(0, 1 - (w.end - rg[0]) / win) * 100).toFixed(2) + '%;width:' + (Math.min(1, (rg[1] - rg[0]) / win) * 100).toFixed(2) + '%"></i>'; }).join(''); if (held._html !== bands) { held._html = bands; held.innerHTML = bands; } var t = ours && P.eng ? P.eng.active() : null, mb = t && t.bw ? ' · ' + Math.round(span * t.bw / 8 / 1048576) + ' MB' : ''; var sdvr = !ours && siteDvrActive(); var atMax = ours ? span >= (P.heldSec || S.siteKeep) - 8 : (sdvr && span >= keepBack() - 8), warming = (ours && !P.armed) || (sdvr && span < 10); bar.classList.toggle('cbx-warming', warming); var rate = v.playbackRate !== 1 ? v.playbackRate + '× · ' : ''; out.textContent = warming ? 'Rewind ready in ' + Math.max(0, Math.ceil(10 - span)) + 's…' : rate + (atLive ? fmtTime(span) + (atMax ? ' (max)' : (win > span + 1 ? ' / ' + fmtTime(win) : '')) + ' held' + mb : '−' + fmtTime(behind) + ' of ' + fmtTime(span) + (atMax ? ' (max)' : '')); out.title = atMax && P.heldNote ? P.heldNote : ''; bar.classList.toggle('cbx-behind-live', !atLive); if (v.playbackRate > 1 && behind < 1.5) setRate(v, 1); // loop markers on the timeline; drop the loop once its start is trimmed away if (P.loopA != null && P.loopA < w.start) { clearLoop(); toast('Loop dropped — its start left the buffer'); } var mk = R.marks || (R.marks = (function () { var d = el('div', { 'class': 'cbx-marks' }); scrub.appendChild(d); return d; })()); var markAt = function (t) { return ((1 - (w.end - t) / win) * 100).toFixed(2) + '%'; }; pruneMarks(w); var mkHtml = (P.loopA != null ? '<i style="left:' + markAt(P.loopA) + '"></i>' : '') + (P.loopB != null ? '<i style="left:' + markAt(P.loopB) + '"></i>' : '') + (P.marks || []).map(function (t) { return '<i class="cbx-bm" style="left:' + markAt(t) + '"></i>'; }).join('') + (S.tipMarks ? (P.tips || []).map(function (t) { return '<i class="cbx-tip" style="left:' + markAt(t) + '"></i>'; }).join('') : ''); if (mk._html !== mkHtml) { mk._html = mkHtml; mk.innerHTML = mkHtml; } if (S.statsOverlay) renderStats(v, w, behind); var pIcon = v.paused ? I.play : I.pause; if (R.pause._icon !== pIcon) { R.pause._icon = pIcon; R.pause.innerHTML = pIcon; } if (P.block) P.block.classList.toggle('cbx-paused', !!v.paused && v.readyState > 0); kickSmooth(); var mIcon = (v.muted || v.volume === 0) ? I.muted : I.vol; if (R.mute._icon !== mIcon) { R.mute._icon = mIcon; R.mute.innerHTML = mIcon; } var vol = R.vol; if (!vol._held) paintVol(vol, v.muted ? 0 : v.volume); paintQualityBtn(R.qbtn, v); } /* ---- deep rewind ---- */ function installDvr() { var user = roomName(); if (!wantDeep() || 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; // the site's picture stays up until ours has a frame; the block is see-through meanwhile site.muted = true; if (P.block) P.block.classList.add('cbx-warm'); v.addEventListener('playing', function onFirst() { v.removeEventListener('playing', onFirst); if (v === P.video && v.readyState > 2) handover(); }); // the fit (cover or contain) and the pan range depend on the stream's aspect: refresh them // once it is known, and again if the broadcaster switches between portrait and landscape ['loadedmetadata', 'resize'].forEach(function (ev) { v.addEventListener(ev, function () { if (v === P.video) applyTransform(); }); }); 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) {} }); installMediaSession(v, user); 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; }); } // lock screen / headset controls on phones function installMediaSession(v, user) { if (!S.mediaSession || !('mediaSession' in navigator)) return; try { navigator.mediaSession.metadata = new MediaMetadata({ title: user, artist: 'Chaturbate — Enhanced Plus' }); var ms = navigator.mediaSession, set = function (a, f) { try { ms.setActionHandler(a, f); } catch (e) {} }; set('play', function () { if (v === P.video) { var p = v.play(); if (p && p.catch) p.catch(function () {}); } }); set('pause', function () { if (v === P.video) v.pause(); }); set('seekbackward', function (d) { seekBack((d && d.seekOffset) || 10); }); set('seekforward', function (d) { seekForward((d && d.seekOffset) || 10); }); set('seekto', function (d) { if (d && typeof d.seekTime === 'number' && v === P.video) seekTo(v, d.seekTime, true); }); } catch (e) { log('media session', e && e.message); } } // our player has its first frame: swap the pictures in one tick function handover() { var site = P.site && P.site.isConnected ? P.site : null; if (P.block) P.block.classList.remove('cbx-warm'); if (site) { site.style.opacity = '0'; site.style.pointerEvents = 'none'; } } 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; handover(); 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'); 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; removeBlock(); clearSiteClock(); 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); } // the site's own watchdog treats a paused player as stuck and rebuilds it, // which tears our block down; slowing it instead keeps it "progressing" // while it fetches about a quarter of the data function parkSite(on) { var site = P.site && P.site.isConnected ? P.site : null; if (!site) return; try { if (on) { // our player carries the picture. The site's native player is parked: paused, so it // downloads and decodes nothing. Its 10 s dead-man timer wants currentTime to keep // advancing between timeupdate events; the element's clock keeps running while // parked (see the getter) and the heartbeat sends the events. One stream. site.muted = true; if (site.playbackRate !== 1) site.playbackRate = 1; startSiteClock(site); if (!site.paused) { site.pause(); log('site player parked: paused, clock running'); } } } 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(dvrCap())); updateBar(); log('dvr armed: ' + fmtTime(span) + ' held, quality pinned, rewind on'); setTimeout(function () { if (v === P.video) parkSite(true); }, 1500); watchDvr(v); } }, 1000); } function watchDvr(v) { var lastEnd = 0, stuck = 0, unlocked = false, ticks = 0, waitingSince = 0, lastT = v.currentTime; var onWaiting = function () { if (!waitingSince) waitingSince = Date.now(); }; var onPlaying = function () { waitingSince = 0; P.stalls = P.stalls || 0; }; v.addEventListener('waiting', onWaiting); v.addEventListener('playing', onPlaying); var wd = setInterval(function () { if (v !== P.video || !v.isConnected) { clearInterval(wd); v.removeEventListener('waiting', onWaiting); v.removeEventListener('playing', onPlaying); 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 (P.site && !P.site.paused && ticks % 5 === 0) parkSite(true); if (v.paused || document.hidden) { waitingSince = 0; lastT = v.currentTime; return; } var end = v.buffered.length ? v.buffered.end(v.buffered.length - 1) : 0; var progressed = v.currentTime > lastT + 0.2; lastT = v.currentTime; // a stall is: the browser said 'waiting', nothing has moved, and the // loader has not added anything for two ticks in a row var stalled = waitingSince && !progressed && end <= lastEnd + 0.2; lastEnd = end; stuck = stalled ? stuck + 1 : 0; P.fwdBuf = Math.max(0, end - v.currentTime); if (stalled && Date.now() - waitingSince > 4000 && stuck === 2 && P.eng) { P.stalls = (P.stalls || 0) + 1; log('stalled 4s, nudging loader'); P.eng.retry(); } if (stuck >= 5 && P.eng && !unlocked) { unlocked = true; log('pinned rendition starving, back to auto'); P.eng.auto(); toast('This rendition stalled — quality set to auto'); } if (!stalled) unlocked = false; }, 2000); } function dropDvr() { var v = P.video, E = P.eng; P.video = null; P.eng = null; P.armed = false; P.ring = null; P.native = false; P.fwdBuf = null; P.marks = []; P.tips = []; P.thumbs = []; P.sheet = null; clearLoop(); 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) { try { if (site.playbackRate !== 1) site.playbackRate = 1; } catch (e) {} 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) {} // handing over to the site player with rewind: it was parked behind live at its // lowest rendition while deep rewind ran, so catch it up and raise the quality if (siteDvrActive()) setTimeout(function () { var s2 = P.site && P.site.isConnected ? P.site : siteVideo(), w2 = s2 && seekWindow(s2); if (w2 && s2.currentTime < w2.end - 3) { try { setTime(s2, w2.end - 0.5); } catch (e) {} } qualityBusy = false; applyQuality(); }, 800); } updateBar(); } function removeDvr() { dropDvr(); removeBlock(); } function backToSitePlayer() { P.suspended = true; P.forcedSite = true; removeDvr(); clearSiteClock(); toast('Plain site player for this page — press P for rewind'); updatePlayerToggle(); } function onPlayerNavigate() { P.suspended = false; P.forcedSite = false; P.attempt = 0; removeDvr(); xform.zoom = roomZoom(); xform.panX = xform.panY = 0; } /* ---- phones: picture height and rotation ---- */ // The site lays the player box out itself and rewrites its height on its own passes; an inline // height of ours only ever fought it. A stylesheet rule with !important beats inline styles for // good, so the height lives in one CSS variable: the box takes it, the panel under the box moves // with it. Portrait only; nothing is touched until the handle is dragged. function phoneNaturalHeight() { return Math.max(120, Math.round((P.box ? P.box.clientWidth : innerWidth) * 9 / 16)); } function installPhoneLayout() { if (!isPhone() || !P.box || !P.box.isConnected) return; P.box.setAttribute('data-cbx-box', ''); if (P.hgrip) return; var below = P.box.nextElementSibling, parent = P.box.parentElement; if (!below || !parent || getComputedStyle(below).position !== 'absolute') return; if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; below.setAttribute('data-cbx-below', ''); var grip = el('div', { id: 'cbx-hgrip', title: 'Drag to change the picture height (double-tap to reset)' }, '<i></i>'); parent.appendChild(grip); P.hgrip = grip; P.hgripBelow = below; var y0 = 0, h0 = 0, taps = 0; grip.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); y0 = e.clientY; h0 = S.phoneHeight || phoneNaturalHeight(); grip.classList.add('cbx-dragging'); try { grip.setPointerCapture(e.pointerId); } catch (err) {} }); grip.addEventListener('pointermove', function (e) { if (!grip.classList.contains('cbx-dragging')) return; var h = Math.round(Math.max(120, Math.min(innerHeight - 240, h0 + (e.clientY - y0)))); S.phoneHeight = Math.abs(h - phoneNaturalHeight()) < 8 ? 0 : h; applyPhoneHeight(); }); ['pointerup', 'pointercancel'].forEach(function (ev) { grip.addEventListener(ev, function () { if (!grip.classList.contains('cbx-dragging')) return; grip.classList.remove('cbx-dragging'); save(); taps++; setTimeout(function () { taps = 0; }, 350); if (taps === 2) { S.phoneHeight = 0; save(); applyPhoneHeight(); toast('Picture height reset'); } }); }); applyPhoneHeight(); } function applyPhoneHeight() { var portrait = matchMedia('(orientation: portrait)').matches, on = !!P.hgrip && P.hgrip.isConnected && portrait; var root = document.documentElement; root.classList.toggle('cbx-ph', on); if (on) root.style.setProperty('--cbx-ph', (S.phoneHeight || phoneNaturalHeight()) + 'px'); else root.style.removeProperty('--cbx-ph'); if (P.hgrip) P.hgrip.style.display = on ? '' : 'none'; applyTransform(); } function removePhoneLayout() { if (P.hgrip) { try { P.hgrip.remove(); } catch (e) {} P.hgrip = null; } if (P.hgripBelow) { P.hgripBelow.removeAttribute('data-cbx-below'); P.hgripBelow = null; } if (P.box) P.box.removeAttribute('data-cbx-box'); document.documentElement.classList.remove('cbx-ph', 'cbx-land'); document.documentElement.style.removeProperty('--cbx-ph'); } // rotate to landscape: real fullscreen when the browser allows it (it needs a recent tap), else a // full-viewport layout of the same box; rotating back undoes either function landscapeMode(on) { var root = document.documentElement; if (on) { var t = P.box; if (t && t.requestFullscreen && !document.fullscreenElement) { var pr = t.requestFullscreen(); if (pr && pr.then) pr.then(function () { P.autoFs = true; }, function () { root.classList.add('cbx-land'); }); else root.classList.add('cbx-land'); } else root.classList.add('cbx-land'); } else { root.classList.remove('cbx-land'); if (P.autoFs && document.fullscreenElement) { try { document.exitFullscreen(); } catch (e) {} } P.autoFs = false; } } function watchRotation() { var mq = matchMedia('(orientation: landscape)'); var onChange = function () { applyPhoneHeight(); if (!isPhone() || !S.rotateFullscreen || !P.block || !P.block.isConnected) { if (document.documentElement.classList.contains('cbx-land')) landscapeMode(false); return; } landscapeMode(mq.matches); }; try { if (mq.addEventListener) mq.addEventListener('change', onChange); else if (mq.addListener) mq.addListener(onChange); } catch (e) {} } /* ---- player mode (off / site / deep) ---- */ /* ---- floating player pill (beside the gear): Deep ⇄ Site ---- */ function playerIsDeep() { return wantDeep() && !P.forcedSite; } var REW_SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>'; function updatePlayerToggle() { if (!dockEl) return; var deep = playerIsDeep(), nat = deep && P.native, on = deep || siteDvrActive(); // one look everywhere: rewind is on, or it is the plain site player. Which engine holds // the window is automatic and lives in the Rewind tab note, not on the pill. dockEl.innerHTML = '<i class="cbx-dot cbx-dot-' + (nat ? 'nat' : on ? 'on' : 'off') + '">' + REW_SVG + '</i>' + (nat ? 'Native' : on ? 'Rewind' : 'Site'); dockEl.title = nat ? 'This browser has no Media Source support, so rewind is limited to what the native player allows (needs iOS 17.1+ / Safari 17.1+)' : on ? 'Rewind is on — click for the plain site player (P)' : 'Plain site player — click for rewind (P)'; dockEl.setAttribute('aria-pressed', on ? 'true' : 'false'); } // P: rewind on this page, or the plain site player function togglePlayer() { if (!S.siteDvr) { S.siteDvr = true; save(); refreshPanel(); } else if (!P.forcedSite) { backToSitePlayer(); return; } P.forcedSite = false; P.suspended = false; P.attempt = 0; removeDvr(); playerTick(); updatePlayerToggle(); toast('Rewind on'); } /* ---- 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 playerTick() { if (!roomName()) { if (P.block) removeDvr(); placeDock(); return; } guardSiteSeeks(); if (P.block && (!P.box || !P.box.isConnected)) { log('player box replaced; rebuilding'); removeDvr(); } if (P.block && P.block.classList.contains('cbx-site') !== siteDvrActive()) { log('player flavor changed; 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; if (S.siteDvr && Date.now() < siteDvr.quietUntil) { qualityNote = 'waiting for the site player to settle'; 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); } // the bar's quality label: what is decoding now, or the engine's pinned track function paintQualityBtn(span, v) { if (!span) return; var label; if (v === P.video && P.eng) { var cur = P.eng.active(); label = P.eng.isAuto() ? 'Auto' + (cur ? ' · ' + cur.label.replace(/\s.*$/, '') : '') : (cur ? cur.label.replace(/\s.*$/, '') : '…'); } else label = v.videoHeight ? v.videoHeight + 'p' : '…'; if (span.textContent !== label) span.textContent = label; } 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 (wantDeep() && !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() || Date.now() < errorHold) return; if (P.video && P.armed) return; // deep player: rendition is pinned through hls.js already 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 · ↑ ↓ volume · Home start · End / 0 live · > 2× · < ½× · A loop · B bookmark · [ ] jump bookmarks · I stats · Q quality · P rewind/site player · S frame · D save buffer · R record · ? 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 'ArrowUp': case 'ArrowDown': { var vv = activeVideo(); if (!vv) { handled = false; break; } userHasInteracted = true; vv.muted = false; vv.volume = Math.max(0, Math.min(1, Math.round((vv.volume + (k === 'ArrowUp' ? 0.05 : -0.05)) * 100) / 100)); updateBar(); toast('Volume ' + Math.round(vv.volume * 100) + '%', 900); break; } case 'Home': seekToStart(); break; case 'End': case '0': goLive(); break; case '>': toggleCatchUp(); break; case '<': toggleSlow(); break; case 'a': case 'A': cycleLoop(); break; case 'b': case 'B': addBookmark(); break; case '[': jumpBookmark(-1); break; case ']': jumpBookmark(1); break; case 'i': case 'I': toggleStats(); 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 'r': case 'R': toggleRecording(); break; case '?': toast(KEY_HELP, 7000); break; default: handled = false; } if (handled) { e.preventDefault(); e.stopPropagation(); } }, true); } /* ================================================================== * * watch without joining chat * ================================================================== */ // a room in its own window: the stream with the browser's controls and sound, nothing else function openTheatre(user) { var w = Math.min(1280, Math.round(screen.availWidth * 0.8)), h = Math.round(w * 9 / 16) + 52; var win = window.open('https://chaturbate.com/?cbx-watch=' + encodeURIComponent(user), 'cbx-theatre-' + user, 'popup=1,width=' + w + ',height=' + h); if (!win) toast('The browser blocked the popup — allow popups for chaturbate.com'); } function openWatchOnly(user, popup) { user = user || roomName(); if (!user) { toast('You are not in a room'); return; } closeWatchOnly(); if (popup) applySiteCSS(); 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', function () { if (popup) { try { window.close(); } catch (e) {} } closeWatchOnly(); }); if (popup) { $('.cbx-note', box).textContent = 'Theatre window — Esc closes'; document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { try { window.close(); } catch (err) {} } }); } 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 (String(d.room_status).toLowerCase() === 'offline' && d.room_title) bits.push(['Last subject', d.room_title]); if (d.allow_private_shows != null) { if (!d.allow_private_shows) bits.push(['Private shows', 'off']); else { if (d.private_show_price != null) bits.push(['Private show', d.private_show_price + ' tk/min' + (d.private_min_minutes ? ', ' + d.private_min_minutes + ' min minimum' : '')]); if (d.premium_private_price) bits.push(['Premium private', d.premium_private_price + ' tk/min' + (d.premium_private_min_minutes ? ', ' + d.premium_private_min_minutes + ' min minimum' : '')]); if (d.spy_private_show_price != null) { var spy = d.spy_private_show_price ? d.spy_private_show_price + ' tk/min' : 'free'; if (d.fan_club_spy_private_show_price != null && d.fan_club_spy_private_show_price !== d.spy_private_show_price) spy += ' (fan club ' + (d.fan_club_spy_private_show_price ? d.fan_club_spy_private_show_price + ' tk/min' : 'free') + ')'; bits.push(['Spy on privates', spy]); } if (d.allow_show_recordings != null) bits.push(['Privates recorded', d.allow_show_recordings ? 'yes' : 'no']); } } if (!bits.length) return; paintBio(bits); // joined date and fan club price live on the bio endpoint fetch('/api/biocontext/' + encodeURIComponent(user) + '/', { credentials: 'include' }) .then(function (r) { return r.json(); }) .then(function (b) { if (roomName() !== user || !b) return; var more = []; if (b.joined) more.push(['Joined', b.joined]); if (d.performer_has_fanclub && b.fan_club_cost != null) more.push(['Fan club price', b.fan_club_cost + ' tk / month']); if (more.length) paintBio(bits.concat(more)); }).catch(function () {}); }) .catch(function (e) { log('bio', e); }); function paintBio(bits) { 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); } } /* ================================================================== * * 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; }); } var TR_SEP = '\n'; function applyTranslation(job, res) { if (!res || (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; job.node.appendChild(el('span', { 'class': 'cbx-tr' }, esc(res.text))); } // up to 8 short messages go in one request, split back on line breaks; // when the line count does not match, each one is retried on its own function pumpQueue() { if (trBusy || !trQueue.length) return; trBusy = true; var jobs = [], chars = 0; while (trQueue.length && jobs.length < 8 && chars < 1400) { var j = trQueue[0]; if ((j.solo || j.text.indexOf(TR_SEP) !== -1) && jobs.length) break; jobs.push(trQueue.shift()); chars += j.text.length; if (j.solo) break; } var single = jobs.length === 1; var req = single ? translate(jobs[0].text) : translate(jobs.map(function (j) { return j.text.replace(/\s*\n\s*/g, ' '); }).join(TR_SEP)); req.then(function (res) { if (single) { applyTranslation(jobs[0], res); return; } var parts = (res.text || '').split(TR_SEP); if (parts.length !== jobs.length) { trQueue = jobs.concat(trQueue); jobs.forEach(function (j) { j.solo = true; }); return; } jobs.forEach(function (j, i) { applyTranslation(j, { text: parts[i], src: res.src }); }); }).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); }); } // one pass over new chat messages: mute, highlight, tip markers, then translation var chatFirstPass = true; function chatUserOf(m) { var u = m.querySelector('[data-testid="chat-message-username"],.username,a[href^="/"]'); return u ? (u.textContent || '').trim().replace(/[:\s]+$/, '').toLowerCase() : ''; } function keywordRe() { var src = (S.chatKeywords || '').split(/[,\n]/).map(function (k) { return k.trim(); }).filter(Boolean); var key = src.join('|'); if (keywordRe._key !== key) { keywordRe._key = key; keywordRe._re = key ? new RegExp(src.map(function (k) { return k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|'), 'i') : null; } return keywordRe._re; } function rescanChat() { $$('div[data-testid="chat-message"]').forEach(function (m) { m._cbxSeen = false; m.classList.remove('cbx-muted', 'cbx-hl'); }); chatFirstPass = true; processChat(); } function processChat() { var muted = S.chatMuted || [], re = keywordRe(), first = chatFirstPass; chatFirstPass = false; $$('div[data-testid="chat-message"]').forEach(function (m) { if (m._cbxSeen) return; m._cbxSeen = true; var user = chatUserOf(m); if (user && muted.indexOf(user) !== -1) { m.classList.add('cbx-muted'); return; } var body = m.querySelector('.message,[data-testid="chat-message-text"]') || m; if (re && re.test(body.textContent || '')) m.classList.add('cbx-hl'); var dv = dvrVideo(); if (!first && S.tipMarks && dv && (P.armed || siteDvrActive()) && m.querySelector('.isTip')) { P.tips = P.tips || []; P.tips.push(dv.currentTime); } }); translateNewMessages(); } function translateNewMessages() { if (!S.translateChat) return; $$('div[data-testid="chat-message"]').forEach(function (m) { if (m._cbxTr || m.classList.contains('cbx-muted')) 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"])', hideMerch: 'a#merch,#cbswag,li:has(> a#cbswag),a[href*="/cbswag"]', hideSurveys: '.dismissibleMessage:has(a[href*="surveymonkey"]),.feedback_notice,a.feedbackLink,[data-testid="suggest-other-payment-methods-btn"],.upgrade_footer_text:has(a[href*="surveymonkey"])', 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}', 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}' }; // the older server-rendered pages (fan club, supporter, followers, account // forms) ignore the darkmode class; give them the same palette by hand var LEGACY_DARK_CSS = 'body.darkmode #main .content_body,body.darkmode #main .form_body,body.darkmode .fanclub_container .frame,body.darkmode #supporter_upgrade_content .frame,' + 'body.darkmode .accounts_list_followers .room_list_room{background:#202c39!important;border-color:#2d3e50!important;color:#f0f0f0!important}' + 'body.darkmode #main .content_body h1,body.darkmode #main h1,body.darkmode #main h2,body.darkmode .fanclub_desc,body.darkmode .fanclub_benefits td,body.darkmode .fanclub_benefits th,' + 'body.darkmode #supporter_upgrade_content td,body.darkmode #supporter_upgrade_content .price,body.darkmode .fieldset_main th label,' + 'body.darkmode .accounts_list_followers .room_list_room :is(.age,.sub-info,.subject){color:#f0f0f0!important}' + 'body.darkmode #main .content_body a,body.darkmode #main .form_body a{color:#68b5f0!important}' + 'body.darkmode .fanclub_benefits .blue_background,body.darkmode #supporter_upgrade_content .blue_background{background:#0d2a4a!important}' + 'body.darkmode .fanclub_benefits tr:not(.blue_background):not(:first-child),body.darkmode #supporter_upgrade_content tr:not(.blue_background):not(:first-child){background:#1a2531!important}' + 'body.darkmode .support_message .highlight{background:#002249!important;border-color:#004795!important}' + 'body.darkmode form input[type=text],body.darkmode form input[type=password],body.darkmode form textarea,body.darkmode form select{background:#17202a!important;color:#f0f0f0!important;border-color:#2d3e50!important}'; var CARD_CSS = '.RoomCard,.roomCard,.FollowedDropdown__room{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,.FollowedDropdown__room:hover .cbx-tools,.cbx-touch .cbx-tools{opacity:1}' + '.FollowedDropdown__room .cbx-tools{top:2px;right:2px}.FollowedDropdown__room .cbx-note-strip{display:none}' + '.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-tools button[data-act="more"]{width:26px;font-size:15px;letter-spacing:-1px}' + '.cbx-touch .cbx-tools{gap:5px;top:6px;right:6px}.cbx-touch .cbx-tools button{width:34px;height:34px;font-size:15px;background:rgba(15,18,21,.88)}' + '.cbx-touch .cbx-tools button[data-act="more"]{width:38px;font-size:20px}' + '.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;overflow:hidden;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-stats{position:absolute;left:8px;top:8px;z-index:5;background:rgba(0,0,0,.6);color:#e8ebed;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;padding:5px 7px;border-radius:5px;pointer-events:none;white-space:nowrap}' + '#cbx-shell video.cbx-dvr{position:absolute;inset:0;width:100%;height:100%;background:#000;object-fit:contain;display:block}' + 'html.cbx-touch #cbx-shell{touch-action:pan-y}#cbx-block.cbx-zoomed #cbx-shell{touch-action:none;cursor:grab}#cbx-block.cbx-panx #cbx-shell{touch-action:pan-y;cursor:grab}#cbx-block.cbx-zoomed #cbx-shell:active,#cbx-block.cbx-panx #cbx-shell:active{cursor:grabbing}' + '#cbx-block:fullscreen{position:fixed;inset:0}' + '#cbx-block #cbx-bar{transition:opacity .3s ease}' + '#cbx-block.cbx-idle:fullscreen #cbx-bar{opacity:0;pointer-events:none}' + '#cbx-block.cbx-idle:fullscreen{cursor:none}:fullscreen #cbx-block.cbx-idle{cursor:none}' + '#cbx-block.cbx-site,#cbx-block.cbx-site #cbx-shell{background:transparent}' + /* warming up: the site's picture shows through until our player has a frame */ '#cbx-block.cbx-warm,#cbx-block.cbx-warm #cbx-shell{background:transparent}#cbx-block.cbx-warm #cbx-shell video.cbx-dvr{opacity:0}' + /* overlay: shell fills the box, the bar floats over its bottom edge */ '#cbx-block.cbx-overlay #cbx-shell{position:absolute;inset:0}' + '#cbx-block.cbx-overlay #cbx-bar{position:absolute;left:0;right:0;bottom:0;border-top:0;padding-top:10px;background:linear-gradient(to top,rgba(0,0,0,.82) 0%,rgba(0,0,0,.55) 55%,rgba(0,0,0,0) 100%)}' + '#cbx-bigplay{position:absolute;left:50%;top:50%;width:68px;height:68px;margin:-34px 0 0 -34px;border:0;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;display:none;align-items:center;justify-content:center;cursor:pointer;z-index:4;transition:background .15s ease,transform .15s ease}' + '#cbx-bigplay:hover{background:rgba(0,0,0,.75);transform:scale(1.06)}#cbx-bigplay svg{width:32px;height:32px;margin-left:4px}#cbx-block.cbx-paused #cbx-bigplay{display:flex}' + '#cbx-bar button{transition:background .12s ease}' + '@media (prefers-reduced-motion:reduce){#cbx-bigplay,#cbx-bar button,#cbx-scrub,#cbx-vol,#cbx-zoomside{transition:none}}' + /* phones: the picture height variable wins over the site's inline layout; the panel under the box follows */ 'html.cbx-ph [data-cbx-box]{height:var(--cbx-ph)!important}' + 'html.cbx-ph [data-cbx-below]{top:calc(var(--cbx-ph) + 16px)!important;height:calc(100% - var(--cbx-ph) - 16px)!important}' + 'html.cbx-ph #cbx-block.cbx-cover #cbx-shell video.cbx-dvr{object-fit:cover}' + '#cbx-hgrip{position:absolute;left:0;right:0;top:var(--cbx-ph,0px);height:16px;z-index:2147482990;display:flex;align-items:center;justify-content:center;background:#14171a;border-top:1px solid #2a3138;touch-action:none;cursor:ns-resize;user-select:none}' + '#cbx-hgrip i{width:44px;height:4px;border-radius:2px;background:#5a646c}#cbx-hgrip.cbx-dragging i{background:#f67300}' + /* rotated: the box fills the viewport when real fullscreen was not allowed */ 'html.cbx-land [data-cbx-box]{position:fixed!important;inset:0!important;width:100%!important;height:100%!important;z-index:2147482900!important;background:#000}html.cbx-land body{overflow:hidden!important}' + '#cbx-recbadge{position:absolute;top:8px;right:8px;z-index:5;padding:3px 8px;border-radius:5px;background:rgba(0,0,0,.6);color:#e33;font:12px/1.2 system-ui,sans-serif;pointer-events:none;animation:cbx-blink 1.2s ease-in-out infinite}#cbx-recbadge span{color:#fff}' + /* zoom sits top-left and shrinks on short pictures so it never reaches the bar */ '#cbx-zoomside{position:absolute;left:6px;top:8px;bottom:84px;z-index:4;display:flex;flex-direction:column;align-items:center;gap:4px;opacity:.5;transition:opacity .25s ease}' + '#cbx-zoomside:hover,#cbx-zoomside:active{opacity:1}#cbx-block.cbx-idle #cbx-zoomside{opacity:0;pointer-events:none}' + '#cbx-zoomside button{appearance:none;border:0;border-radius:4px;background:rgba(0,0,0,.45);color:#fff;font:11px/1 system-ui,sans-serif;font-weight:600;padding:4px 6px;cursor:pointer;min-width:34px}#cbx-zoomside button:hover{background:rgba(0,0,0,.7)}' + '#cbx-zoomside .cbx-zwrap{width:24px;flex:0 1 120px;min-height:44px;max-height:120px;display:flex;align-items:center;justify-content:center}' + '#cbx-zoomside input{writing-mode:vertical-lr;direction:rtl;width:20px;height:100%;margin:0;accent-color:#f67300;cursor:pointer;touch-action:none}' + '#cbx-block.cbx-overlay.cbx-idle #cbx-bar{opacity:0;pointer-events:none}' + '#cbx-bar{flex:none;position:relative;display:flex;flex-direction:column;gap:0;padding:2px 6px 3px;background:#14171a;border-top:1px solid #2a3138}' + '#cbx-thumb-prev{display:none;position:absolute;bottom:100%;margin-bottom:6px;transform:translateX(-50%);border:2px solid #f67300;border-radius:6px;overflow:hidden;background:#000;box-shadow:0 4px 16px rgba(0,0,0,.6);pointer-events:none;z-index:6}' + '#cbx-thumb-prev canvas{display:block;width:160px;height:auto}#cbx-thumb-prev span{display:block;text-align:center;font:11px/1 system-ui,sans-serif;color:#fff;padding:4px 8px;white-space:nowrap}' + '.cbx-row{display:flex!important;align-items:center;gap:6px;flex-wrap:nowrap;min-width:0;width:100%;box-sizing:border-box}' + '#cbx-scrub .cbx-marks i{position:absolute;top:-4px;bottom:-4px;width:2px;margin-left:-1px;background:#fff;box-shadow:0 0 2px #000}' + '#cbx-scrub .cbx-marks i.cbx-bm{background:#3ad07a;top:-5px;bottom:-1px}#cbx-scrub .cbx-marks i.cbx-tip{background:#ffd23f;top:100%;bottom:auto;height:4px;margin-top:2px;width:3px}' + '.cbx-row-scrub{gap:0;min-height:14px;padding-top: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-scrub-at{position:absolute;bottom:100%;left:100%;margin-bottom:5px;transform:translateX(-90%);font-size:10px;line-height:1;padding:2px 5px;border-radius:3px;' + 'background:rgba(0,0,0,.65);color:#fff;pointer-events:none;transition:none}' + '#cbx-bar.cbx-behind-live #cbx-scrub-at{background:#f67300}' + /* the panel's .cbx-row span{flex:1} must never reach the bar: only the spacer is flexible here */ '#cbx-bar .cbx-row > span{flex:0 0 auto;min-width:0}#cbx-bar .cbx-row > span.cbx-grow{flex:1 1 auto}.cbx-volwrap{display:inline-flex;align-items:center;gap:2px}' + '#cbx-bar:not(.cbx-behind-live) #cbx-scrub-at{display:none}' + '#cbx-bar button{position:relative;flex:0 0 auto;min-width:32px;min-height:32px;border:0;border-radius:6px;background:transparent;color:#fff;' + '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}' + /* the LIVE pill: red at the edge, grey once you are behind; click goes back to live */ '#cbx-bar .cbx-live{background:#d92c2c;color:#fff;padding:0 9px;font-weight:600;letter-spacing:.3px;font-size:11px;border-radius:4px}#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:rgba(255,255,255,.16);color:#d7dde2}#cbx-bar.cbx-behind-live .cbx-live:hover{background:rgba(255,255,255,.28)}' + '#cbx-bar button.cbx-dot::after{content:"";position:absolute;top:5px;right:5px;width:6px;height:6px;border-radius:50%;background:#f67300}' + '#cbx-bar button.cbx-on{background:rgba(255,255,255,.16)}' + '#cbx-rec{gap:5px}#cbx-rec-txt:empty{display:none}#cbx-rec.cbx-rec-on{color:#ff6b6b}' + '#cbx-rec-dot{width:12px;height:12px;border-radius:50%;border:2px solid currentColor;box-sizing:border-box;flex:none}' + '#cbx-rec.cbx-rec-on #cbx-rec-dot{background:#e33;border-color:#e33;animation:cbx-blink 1.2s ease-in-out infinite}@keyframes cbx-blink{50%{opacity:.35}}' + '#cbx-scrub{flex:1 1 0%!important;width:auto!important;min-width:40px;position:relative;height:4px;border-radius:2px;margin:6px 8px;background:rgba(255,255,255,.14);cursor:pointer;touch-action:none;user-select:none;display:block!important;transition:height .12s ease,margin .12s ease}' + /* a 4px line is not a click target: a 12px halo above and below catches the pointer */ '#cbx-scrub::before{content:"";position:absolute;left:0;right:0;top:-12px;bottom:-12px}' + '#cbx-bar:hover #cbx-scrub,#cbx-scrub.cbx-hovering{height:6px;margin-top:5px;margin-bottom:5px}' + '#cbx-scrub .cbx-hover{position:absolute;top:-3px;bottom:-3px;width:2px;margin-left:-1px;background:#fff;opacity:0;pointer-events:none;transition:opacity .1s}' + '#cbx-scrub.cbx-hovering .cbx-hover{opacity:.9}' + '#cbx-scrub .cbx-held{position:absolute;left:0;top:0;bottom:0;width:100%}#cbx-scrub .cbx-held i{position:absolute;top:0;bottom:0;border-radius:3px;background:rgba(255,255,255,.28)}' + '#cbx-scrub .cbx-fill{position:absolute;left: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}' + /* volume: folded away next to the mute button, slides out on hover (or a tap on touch) */ /* volume: our own track/fill/thumb (like the timeline), folded to nothing beside the mute button and unfolding to the right on hover, or after a tap on touch */ '#cbx-vol{position:relative;display:block;width:0;height:4px;margin:0;border-radius:2px;background:rgba(255,255,255,.3);cursor:pointer;touch-action:none;user-select:none;opacity:0;visibility:hidden;transition:width .18s ease,opacity .18s ease,margin .18s ease,visibility 0s linear .18s}' + '.cbx-volwrap:hover #cbx-vol,.cbx-volwrap.cbx-open #cbx-vol,#cbx-vol.cbx-held{width:80px;opacity:1;visibility:visible;margin:0 12px 0 8px;transition:width .18s ease,opacity .18s ease,margin .18s ease,visibility 0s}' + '#cbx-vol::before{content:"";position:absolute;left:-8px;right:-8px;top:-12px;bottom:-12px}' + '#cbx-vol .cbx-vfill{position:absolute;left:0;top:0;bottom:0;width:100%;border-radius:2px;background:#f67300;pointer-events:none}' + '#cbx-vol .cbx-vthumb{position:absolute;top:50%;left:100%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.6);pointer-events:none}' + '#cbx-bar .cbx-qbtn{width:auto;min-width:0;padding:0 9px;font-size:12px;font-weight:600;color:#e8ebed}#cbx-bar.cbx-ours .cbx-qbtn{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}' + /* site controls out of the way while our block is up */ /* the site's controls and quality menu are hidden while our block is up; nothing that contains the picture itself is ever touched */ 'html.cep-block .theater-video-controls:not(:has(video)){opacity:0!important;pointer-events:none!important}' + 'html.cep-block :is(div,ul,section):has(> [data-testid="quality-option"]):not(:has(video)),html.cep-block :is(div,ul,section):has(> * > [data-testid="quality-option"]):not(:has(video)){visibility:hidden!important}' + 'html.cep-quiet-menu [data-testid="quality-option"],html.cep-quiet-menu :is(div,ul,section):has(> [data-testid="quality-option"]):not(:has(video)),' + 'html.cep-quiet-menu :is(div,ul,section):has(> * > [data-testid="quality-option"]):not(:has(video)){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-behind,#cbx-bar .cbx-qbtn{display:none}' + '.cbx-volwrap.cbx-open #cbx-vol,#cbx-vol.cbx-held{width:56px;margin:0 8px 0 4px}' + '.cbx-row-btns{flex-wrap:nowrap;justify-content:space-between;gap:2px}' + '#cbx-bar button{min-width:0;padding:0;flex:0 0 auto;width:38px;min-height:44px}#cbx-bar button b{display:none}' + '#cbx-bar .cbx-live{width:auto;padding:0 6px;margin:0 1px;font-size:10px}' + '}@media (max-width:379.98px){#cbx-bar button[data-act="back"],#cbx-bar button[data-act="fwd"]{display:none}}' + 'html.cbx-touch #cbx-bar button{min-height:34px}' + 'html.cbx-touch #cbx-scrub{height:6px;margin:8px 10px 10px}html.cbx-touch #cbx-scrub .cbx-thumb{width:18px;height:18px;margin:-9px 0 0 -9px}' + 'html.cbx-touch #cbx-scrub::before{top:-16px;bottom:-16px}' + '#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:5px 9px 6px;display:flex;flex-direction:column;gap:1px}' + '#cbx-hover figcaption strong{font-size:13px;font-weight:600}#cbx-hover figcaption b{font-weight:400;color:#cfd6db}' + '#cbx-hover figcaption span{color:#8b969e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '#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}'; // chat-only rules live apart so chat toggles do not restyle the whole page function applyChatCSS() { var st = $('#cbx-chat-css'); if (!st) { st = el('style', { id: 'cbx-chat-css' }); (document.head || document.documentElement).appendChild(st); } var css = '.cbx-muted{display:none!important}.cbx-hl{background:rgba(246,115,0,.16)!important;box-shadow:inset 3px 0 0 #f67300}'; if (S.chatTipsOnly) css += 'div[data-testid="chat-message"]:not(:has(.isTip)){display:none!important}'; if (S.chatFont) css += 'div[data-testid="chat-message"],div[data-testid="chat-message"] *{font-size:' + S.chatFont + 'px!important}'; if (st.textContent !== css) st.textContent = css; } 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{grid-template-columns:repeat(auto-fill,minmax(' + S.gridSize + 'px,1fr))!important}'; var more = S.moreGridSize || S.gridSize; if (more) css += '.MoreRooms .list{grid-template-columns:repeat(auto-fill,minmax(' + more + 'px,1fr))!important}'; Object.keys(EXTRA_RULES).forEach(function (k) { if (S[k]) css += EXTRA_RULES[k]; }); applyChatCSS(); var custom = jsonGet(HIDE_KEY, []); if (custom.length) hides = hides.concat(custom); if (hides.length) css = hides.join(',') + '{display:none!important}' + css; if (S.forceDark && S.darkLegacy) css += LEGACY_DARK_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 * ================================================================== */ // room cards on listing pages plus the rows of the header's Followed dropdown var CARD_SEL = '.RoomCard,.roomCard,.FollowedDropdown__room'; 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; } // the flag on a card is a flag-icons span: .thumbnail_flag > .fi.fi-xx function cardCountry(card) { var f = card.querySelector('.thumbnail_flag .fi, .thumbnail_flag [class*="fi-"]'); if (!f) return null; var m = /(?:^|\s)fi-([a-z]{2})(?:\s|$)/.exec(f.className || ''); return m ? m[1] : null; } var ccNames = null; function countryName(cc) { if (!cc) return ''; try { if (!ccNames && window.Intl && Intl.DisplayNames) ccNames = new Intl.DisplayNames([uiLangResolved() || 'en'], { type: 'region' }); return (ccNames && ccNames.of(cc.toUpperCase())) || cc.toUpperCase(); } catch (e) { return cc.toUpperCase(); } } function hiddenCountries() { return jsonGet(CC_KEY, []); } function hideCountry(cc) { var l = hiddenCountries(); if (l.indexOf(cc) === -1) l.push(cc); jsonSet(CC_KEY, l); decorateCards(); refreshPanel(); toast(countryName(cc) + ' hidden'); } function ensureRandomLink() { var old = $('#cbx-random-link'); if (!S.randomLink) { if (old) old.remove(); return; } if (old && old.isConnected) return; var nav = $('.HeaderNavBar__links, nav[class*="HeaderNavBar"] ul, .HeaderNavBar'); if (!nav) return; var sib = nav.querySelector('a.HeaderNavBar__link'); var a = el('a', { id: 'cbx-random-link', href: '#', 'class': sib ? sib.className : 'HeaderNavBar__link', title: 'Open a random live room' }, '<div class="' + (sib && sib.firstElementChild ? sib.firstElementChild.className : '') + '">random</div>'); a.addEventListener('click', function (e) { e.preventDefault(); toast('Picking a room…'); var genders = (S.randomGenders || 'f').split(''); roomsForGender(genders[Math.floor(Math.random() * genders.length)]).then(function (names) { var block = jsonGet(BLOCK_KEY, []); names = names.filter(function (u) { return block.indexOf(u) === -1 && u !== roomName(); }); if (!names.length) { toast('No rooms found'); return; } var u = names[Math.floor(Math.random() * names.length)]; if (S.openNewTab) window.open('/' + u + '/', '_blank'); else location.href = '/' + u + '/'; }).catch(function () { toast('Could not fetch the room list'); }); }); (sib && sib.parentElement === nav ? nav : (sib ? sib.parentElement : nav)).appendChild(a); } function decorateCards() { var block = jsonGet(BLOCK_KEY, []), note = jsonGet(NOTES_KEY, {}), ccs = hiddenCountries(); var stamp = [block.length, JSON.stringify(note).length, ccs.join(','), S.openNewTab, S.cardWatchBtn, S.cardTools].join('|'); $$(CARD_SEL).forEach(function (card) { var user = cardUser(card); if (!user) return; if (card._cbxStamp === stamp && card._cbxUser === user && (!S.cardTools || card.querySelector('.cbx-tools'))) return; card._cbxStamp = stamp; card._cbxUser = user; if (block.indexOf(user) !== -1) { card.style.display = 'none'; return; } var cc = ccs.length ? cardCountry(card) : null; if (cc && ccs.indexOf(cc) !== -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' }, (S.cardWatchBtn ? '<button data-act="watch" title="Watch without chat">▶</button>' : '') + '<button data-act="more" title="More" aria-haspopup="true">⋯</button>'); card.appendChild(tools); var fire = function (b) { var act = b.getAttribute('data-act'); if (act === 'watch') openWatchOnly(user); else if (act === 'more') openDD('card', b, { user: user, cc: cardCountry(card), card: card }); }; // the whole card is a link with its own touch handling on phones; swallow // our taps at touchstart/touchend so the site never sees them, and act on touchend ['touchstart', 'touchmove', 'pointerdown', 'mousedown'].forEach(function (ev) { tools.addEventListener(ev, function (e) { e.stopPropagation(); }, { passive: true }); }); tools.addEventListener('touchend', function (e) { var b = e.target.closest('button'); if (!b) return; e.preventDefault(); e.stopPropagation(); tools._touched = Date.now(); fire(b); }); tools.addEventListener('click', function (e) { var b = e.target.closest('button'); if (!b) return; e.preventDefault(); e.stopPropagation(); if (Date.now() - (tools._touched || 0) < 700) return; // already handled on touchend fire(b); }); }); } // actions for one room, used by the ⋯ menu on cards and the Room menu under the player function roomAction(what, user, ctx) { ctx = ctx || {}; if (!user) { toast('You are not in a room'); return; } if (what === 'watch') openWatchOnly(user); else if (what === 'url') copyStreamUrl(); else if (what === 'alert') toggleAlertFor(user); else if (what === '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(); toast(val.trim() ? 'Note saved' : 'Note removed'); } else if (what === 'multi') { var m = jsonGet(MULTI_KEY, []); if (m.indexOf(user) === -1) m.push(user); jsonSet(MULTI_KEY, m); toast(user + ' added — ' + m.length + ' saved'); } else if (what === 'hide') { if (ctx.confirm && !confirm('Hide ' + user + ' from every room list?')) return; var l = jsonGet(BLOCK_KEY, []); if (l.indexOf(user) === -1) l.push(user); jsonSet(BLOCK_KEY, l); if (ctx.card) ctx.card.style.display = 'none'; decorateCards(); refreshPanel(); toast(user + ' hidden'); } else if (what === 'country') { if (ctx.cc) hideCountry(ctx.cc); } } /* ================================================================== * * 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; } // what the card itself says about the room, for the corner preview function cardFacts(card) { if (!card) return ''; var txt = function (sel) { var n = card.querySelector(sel); return n ? (n.textContent || '').trim() : ''; }; var g = card.querySelector('[data-testid="room-card-gender"],.gender,[class*="gender"]'); var gm = g && /gender(f|m|c|s|t)\b/.exec(g.className || ''); var gender = gm ? ({ f: 'F', m: 'M', c: 'Couple', s: 'Trans', t: 'Trans' })[gm[1]] : ''; var bits = [txt('.age'), gender, txt('.location'), txt('.cams,.viewers,[class*="viewer"]')].filter(Boolean); var subject = txt('.subject,.title,[data-testid="room-card-subject"],[class*="subject"]'); return (bits.length ? '<b>' + esc(bits.join(' · ')) + '</b>' : '') + (subject ? '<span>' + esc(subject) + '</span>' : ''); } function showPreview(user, card) { if (hoverUser === user) return; hoverUser = user; var box = ensureHoverBox(), v = $('video', box); $('figcaption', box).innerHTML = '<strong>' + esc(user) + '</strong>' + cardFacts(card); box.classList.add('cbx-on'); v.muted = S.previewMuted; hlsFor(user).then(function (url) { if (hoverUser !== user || !url) return; return attachStream(v, url, { reuse: true }).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._reusable) { v._cbxHls.stopLoad(); v._cbxHls.detachMedia(); } else if (v._cbxHls) { v._cbxHls.destroy(); v._cbxHls = null; v.pause(); v.removeAttribute('src'); v.load(); } else { v.pause(); v.removeAttribute('src'); v.load(); } } catch (e) {} } function startPreview(card, user) { if (S.previewInline) inlinePreviewOn(card, user); else showPreview(user, card); } function stopPreview() { inlinePreviewOff(); hidePreview(); } function installHoverPreview() { document.addEventListener('mouseover', function (e) { if (!S.hoverPreview) return; var card = e.target.closest && e.target.closest(CARD_SEL); 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(CARD_SEL); if (!card) { stopPreview(); return; } if (e.target.closest('.cbx-tools')) 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(CARD_SEL); 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()) { // previews keep their hls.js instance between hovers: swap the source instead of rebuilding if (opts && opts.reuse && video._cbxHls && video._cbxHls._reusable) { try { var rh = video._cbxHls; rh.stopLoad(); rh.detachMedia(); rh.loadSource(url); rh.attachMedia(video); return Promise.resolve(); } catch (e) { try { video._cbxHls.destroy(); } catch (e2) {} video._cbxHls = null; } } 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, reusable = !!(opts && opts.reuse); if (opts) for (var k in opts) if (k !== 'onFatal' && k !== 'reuse') 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); h._reusable = reusable; 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 Rewind tab'); } }); } if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; log('attached natively (no back buffer control)'); if (video.classList.contains('cbx-dvr')) { P.native = true; updatePlayerToggle(); 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 * ================================================================== */ var MI = { plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>', reload: '<svg viewBox="0 0 24 24"><path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', mute: '<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9H4zM16 9l5 6M21 9l-5 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', random: '<svg viewBox="0 0 24 24"><path d="M3 7h4l10 10h4M17 5l4 2-4 2M3 17h4l3-3M14 10l3-3h4M17 15l4 2-4 2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', broom: '<svg viewBox="0 0 24 24"><path d="M15 3l6 6-8 8-6-6zM7 11l-4 4 3 3 4-4M13 13l4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', trash: '<svg viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', fs: '<svg viewBox="0 0 24 24"><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', link: '<svg viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.5 1.5M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.5-1.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', pause: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7 5h4v14H7zm6 0h4v14h-4z"/></svg>', play: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>', cols: '<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4zM10 5v14M15 5v14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>', chev: '<svg viewBox="0 0 24 24" class="cbx-chev"><path d="M7 10l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', heart: '<svg viewBox="0 0 24 24"><path d="M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>', sets: '<svg viewBox="0 0 24 24"><path d="M6 3h12v18l-6-4-6 4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>', open: '<svg viewBox="0 0 24 24"><path d="M14 4h6v6M20 4l-9 9M18 14v5H5V6h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', big: '<svg viewBox="0 0 24 24"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', close: '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>' }; function buildMulti() { document.head.appendChild(el('style', null, UI_CSS)); 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">' + MI.plus + 'Add</button>' + '<button id="cbx-multi-reload" class="cbx-b cbx-ib" title="Reload all" aria-label="Reload all">' + MI.reload + '</button>' + '<button id="cbx-multi-mute" class="cbx-b cbx-it" title="Mute every tile (also: click an empty spot)">' + MI.mute + '<span>Mute all</span></button>' + '<button id="cbx-multi-random" class="cbx-b cbx-it" title="Add random rooms">' + MI.random + '<span>Random</span></button>' + '<button id="cbx-multi-rm-off" class="cbx-b cbx-it" title="Remove the rooms that are offline">' + MI.broom + '<span>Remove offline</span></button>' + '<button id="cbx-multi-rm-all" class="cbx-b cbx-it" title="Remove every room">' + MI.trash + '<span>Remove all</span></button>' + '<button id="cbx-multi-follow" class="cbx-b cbx-it" title="Add the rooms you follow that are online">' + MI.heart + '<span>Followed</span></button>' + '<span class="cbx-setswrap"><button id="cbx-multi-sets" class="cbx-b cbx-it" title="Save this set of rooms, or load one">' + MI.sets + '<span>Sets</span></button></span>' + '<span class="cbx-grow"></span>' + '<span class="cbx-ddwrap"><button id="cbx-multi-cols" class="cbx-b cbx-it" title="Layout: columns, and whether the tiles fill the window" aria-haspopup="true">' + MI.cols + '<span id="cbx-multi-cols-lbl">Auto columns</span><b id="cbx-multi-cols-short">Auto</b>' + MI.chev + '</button></span>' + '<button id="cbx-multi-fs" class="cbx-b cbx-ib" title="Fullscreen (F11 · F10 hides the toolbar)" aria-label="Fullscreen">' + MI.fs + '</button>' + '<button id="cbx-multi-share" class="cbx-b cbx-ib" title="Copy a link that opens this set of rooms" aria-label="Copy share link">' + MI.link + '</button>' + '</header><div id="cbx-multi-hot"></div><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); var dragCell = null, hoverPrev = null; function sync() { empty.style.display = grid.children.length ? 'none' : 'block'; if (S.multiFill && typeof applyFill === 'function') applyFill(); } 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>' + '<span class="cbx-state">Loading</span>' + '<span class="cbx-tools">' + '<button class="cbx-tb cbx-tb-open" title="Open the room in a new tab">' + MI.open + '</button>' + '<button class="cbx-tb cbx-tb-play" title="Pause">' + MI.pause + '</button>' + '<button class="cbx-tb cbx-tb-big" title="Enlarge (or double-click the picture)">' + MI.big + '</button>' + '<button class="cbx-tb cbx-x" aria-label="Remove ' + user + '" title="Remove">' + MI.close + '</button></span>' + (S.multiShowSubject ? '<span class="cbx-subject"></span>' : '')); cell.draggable = true; grid.appendChild(cell); sync(); var video = $('video', cell), state = $('.cbx-state', cell); cell.addEventListener('dragstart', function (e) { dragCell = cell; cell.classList.add('cbx-dragging'); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', user); } catch (err) {} }); cell.addEventListener('dragend', function () { dragCell = null; cell.classList.remove('cbx-dragging'); clearDrop(); }); cell.addEventListener('dragover', function (e) { if (!dragCell || dragCell === cell) return; e.preventDefault(); var r = cell.getBoundingClientRect(), before = e.clientX < r.left + r.width / 2; clearDrop(); cell.classList.add(before ? 'cbx-drop-before' : 'cbx-drop-after'); }); cell.addEventListener('drop', function (e) { if (!dragCell || dragCell === cell) return; e.preventDefault(); var r = cell.getBoundingClientRect(), before = e.clientX < r.left + r.width / 2; grid.insertBefore(dragCell, before ? cell : cell.nextSibling); clearDrop(); saveOrder(); }); cell.addEventListener('mouseenter', function () { if (!S.multiHoverAudio || video.paused) return; hoverPrev = $$('.cbx-cam', grid).filter(function (c) { return !$('video', c).muted; }); $$('.cbx-cam video', grid).forEach(function (v) { v.muted = v !== video; }); }); cell.addEventListener('mouseleave', function () { if (!S.multiHoverAudio || !hoverPrev) return; $$('.cbx-cam video', grid).forEach(function (v) { v.muted = true; }); hoverPrev.forEach(function (c) { var v = $('video', c); if (v && c.isConnected) v.muted = false; }); hoverPrev = null; }); $('.cbx-x', cell).addEventListener('click', function () { if (bigEl && bigEl._cell === cell) closeBig(); try { if (video._cbxHls) video._cbxHls.destroy(); } catch (e) {} cell.remove(); jsonSet(MULTI_KEY, jsonGet(MULTI_KEY, []).filter(function (u) { return u !== user; })); sync(); }); $('.cbx-tb-play', cell).addEventListener('click', function (e) { e.stopPropagation(); if (video.paused) { var pp = video.play(); if (pp && pp.catch) pp.catch(function () {}); } else video.pause(); }); var syncPlay = function () { var b = $('.cbx-tb-play', cell); if (b) { b.innerHTML = video.paused ? MI.play : MI.pause; b.title = video.paused ? 'Play' : 'Pause'; } cell.classList.toggle('cbx-paused', video.paused); }; video.addEventListener('play', syncPlay); video.addEventListener('pause', syncPlay); $('.cbx-tb-big', cell).addEventListener('click', function (e) { e.stopPropagation(); enlarge(cell); }); $('.cbx-tb-open', cell).addEventListener('click', function (e) { e.stopPropagation(); window.open('https://chaturbate.com/' + user + '/', '_blank'); }); video.addEventListener('dblclick', function (e) { e.preventDefault(); enlarge(cell); }); // touch: a tap shows the buttons for a moment cell.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse') return; cell.classList.add('cbx-show'); clearTimeout(cell._t); cell._t = setTimeout(function () { cell.classList.remove('cbx-show'); }, 3000); }); 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); }); // status: the subject line, and a badge when the room is in a private, away or password show var readStatus = function () { return fetch('/api/chatvideocontext/' + encodeURIComponent(user) + '/', { credentials: 'include' }) .then(function (r) { return r.json(); }) .then(function (d) { if (!d) return ''; var sub = $('.cbx-subject', cell); if (sub && d.room_title) sub.textContent = d.room_title; var st = String(d.room_status || '').toLowerCase(); cell.setAttribute('data-status', st); if (st && st !== 'public' && st !== 'offline' && cell.getAttribute('data-offline')) state.textContent = st.charAt(0).toUpperCase() + st.slice(1) + ' show'; if (S.multiHidePrivate && st && st !== 'public') cell.style.display = 'none'; return st; }).catch(function () { return ''; }); }; readStatus(); cell._readStatus = readStatus; hlsFor(user).then(function (url) { if (!url) { var st0 = cell.getAttribute('data-status'); state.textContent = st0 && st0 !== 'public' && st0 !== 'offline' ? st0.charAt(0).toUpperCase() + st0.slice(1) + ' show' : 'Offline'; cell.setAttribute('data-offline', '1'); 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); }); function muteAll() { $$('.cbx-cam video', grid).forEach(function (v) { v.muted = true; }); $$('.cbx-cam', grid).forEach(function (c) { c.classList.remove('cbx-live-audio'); }); } $('#cbx-multi-mute', root).addEventListener('click', muteAll); root.addEventListener('click', function (e) { if (e.target === root || e.target === grid || e.target === empty) muteAll(); }); $('#cbx-multi-random', root).addEventListener('click', function () { fillRandom(addCam); }); // followed rooms that are online now $('#cbx-multi-follow', root).addEventListener('click', function () { toast('Reading your followed rooms…'); var tryUrl = function (u) { return fetch(u, { credentials: 'include' }).then(function (r) { if (!r.ok) throw new Error('bad status'); return r.json(); }).then(function (d) { return harvestUsernames(d, []); }); }; // signed out, the site answers the follow filter with the plain public list; compare the two and refuse that Promise.all([tryUrl('/api/ts/roomlist/room-list/?follow=true&limit=90'), tryUrl('/api/ts/roomlist/room-list/?limit=90')]) .then(function (r) { var names = r[0], plain = r[1]; var same = names.length && names.length === plain.length && names.every(function (u, i) { return u === plain[i]; }); if (same) { toast('That is the public room list, not your followed rooms — sign in first'); return; } var have = $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); }); var add = names.filter(function (u) { return have.indexOf(u) === -1; }); if (!add.length) { toast(names.length ? 'All your online followed rooms are here already' : 'No followed rooms online'); return; } if (add.length > 12 && !confirm('Add ' + add.length + ' followed rooms that are online now?')) return; add.forEach(addCam); toast('Added ' + add.length + ' followed room' + (add.length === 1 ? '' : 's')); }).catch(function () { toast('Could not read your followed rooms — are you signed in?'); }); }); // named sets: save the current rooms under a name, load one back, delete one var setsBtn = $('#cbx-multi-sets', root), setsMenu = null; function closeSets() { if (setsMenu) { setsMenu.remove(); setsMenu = null; } } function loadSet(users) { $$('.cbx-cam', grid).forEach(removeCell); jsonSet(MULTI_KEY, []); users.forEach(addCam); sync(); } setsBtn.addEventListener('click', function (e) { e.stopPropagation(); if (setsMenu) { closeSets(); return; } var sets = jsonGet(MULTI_SETS_KEY, {}), names = Object.keys(sets).sort(); setsMenu = el('div', { id: 'cbx-multi-setsmenu' }, (names.length ? names.map(function (n) { return '<div class="cbx-setrow"><button class="cbx-setload" data-set="' + esc(n) + '">' + esc(n) + '<small>' + sets[n].length + ' rooms</small></button><button class="cbx-setdel" data-set="' + esc(n) + '" title="Delete this set">' + MI.close + '</button></div>'; }).join('') : '<p class="cbx-note">No saved sets yet.</p>') + '<button class="cbx-b cbx-b-wide cbx-b-accent" id="cbx-setsave">Save current rooms as…</button>'); placeMenu(setsMenu, setsBtn); setsMenu.addEventListener('click', function (ev) { ev.stopPropagation(); var load = ev.target.closest('.cbx-setload'), del = ev.target.closest('.cbx-setdel'); if (load) { var u = (jsonGet(MULTI_SETS_KEY, {})[load.getAttribute('data-set')] || []); closeSets(); loadSet(u); toast('Loaded ' + u.length + ' rooms'); } else if (del) { var all = jsonGet(MULTI_SETS_KEY, {}); delete all[del.getAttribute('data-set')]; jsonSet(MULTI_SETS_KEY, all); closeSets(); setsBtn.click(); } else if (ev.target.id === 'cbx-setsave') { var users = $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); }); if (!users.length) { toast('Add some rooms first'); return; } var name = prompt('Name for this set of ' + users.length + ' rooms', ''); if (!name) return; var all2 = jsonGet(MULTI_SETS_KEY, {}); all2[name.trim().slice(0, 40)] = users; jsonSet(MULTI_SETS_KEY, all2); closeSets(); toast('Saved "' + name.trim() + '"'); } }); }); document.addEventListener('click', closeSets); // tiles that have no picture (offline, private, away) ask once a minute; when the room is public again they reload in place setInterval(function () { if (document.hidden) return; $$('.cbx-cam[data-offline]', grid).forEach(function (c) { if (!c._readStatus) return; c._readStatus().then(function (st) { if (st !== 'public') return; var u = c.getAttribute('data-user'), next = c.nextSibling; removeCell(c); addCam(u); var fresh = $('.cbx-cam[data-user="' + u + '"]', grid); if (fresh && next && next.parentNode === grid) grid.insertBefore(fresh, next); toast(u + ' is back'); }); }); }, 60000); // one tile at full size, with the browser's own controls and sound; the tile gets its video back on close var bigEl = null; function enlarge(cell) { var video = $('video', cell); if (!video) return; if (bigEl) closeBig(); bigEl = el('div', { id: 'cbx-multi-big' }, '<span class="cbx-big-top"><span class="cbx-big-name">' + esc(cell.getAttribute('data-user')) + '</span><button class="cbx-big-pop" title="Open in its own window">' + MI.open + '<span>Pop out</span></button><button class="cbx-big-x" aria-label="Close">' + MI.close + '</button></span>'); bigEl._cell = cell; bigEl._video = video; bigEl._muted = video.muted; video.controls = true; video.muted = false; bigEl.insertBefore(video, bigEl.firstChild); root.appendChild(bigEl); $('.cbx-big-x', bigEl).addEventListener('click', closeBig); $('.cbx-big-pop', bigEl).addEventListener('click', function () { var u = cell.getAttribute('data-user'); closeBig(); openTheatre(u); }); bigEl.addEventListener('click', function (e) { if (e.target === bigEl) closeBig(); }); video.addEventListener('dblclick', function once(e) { e.preventDefault(); closeBig(); }); var p = video.play(); if (p && p.catch) p.catch(function () {}); } function closeBig() { if (!bigEl) return; var video = bigEl._video, cell = bigEl._cell; video.controls = false; video.muted = bigEl._muted; if (cell && cell.isConnected) cell.insertBefore(video, cell.firstChild); else { try { if (video._cbxHls) video._cbxHls.destroy(); } catch (e) {} } bigEl.remove(); bigEl = null; } function removeCell(c) { if (bigEl && bigEl._cell === c) closeBig(); var v = $('video', c); try { if (v && v._cbxHls) v._cbxHls.destroy(); } catch (e) {} c.remove(); } function saveOrder() { jsonSet(MULTI_KEY, $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); })); sync(); } function clearDrop() { $$('.cbx-drop-before,.cbx-drop-after', grid).forEach(function (c) { c.classList.remove('cbx-drop-before', 'cbx-drop-after'); }); } $('#cbx-multi-rm-off', root).addEventListener('click', function () { $$('.cbx-cam[data-offline]', grid).forEach(removeCell); saveOrder(); }); $('#cbx-multi-rm-all', root).addEventListener('click', function () { if (!grid.children.length || !confirm('Remove every room from the multi cam list?')) return; $$('.cbx-cam', grid).forEach(removeCell); saveOrder(); }); var colsEl = $('#cbx-multi-cols', root), colsMenu = null; function applyCols() { if (S.multiCols && !S.multiFill) grid.setAttribute('data-cols', S.multiCols); else grid.removeAttribute('data-cols'); $('#cbx-multi-cols-lbl', root).textContent = (S.multiCols ? S.multiCols + ' column' + (S.multiCols === 1 ? '' : 's') : 'Auto columns') + (S.multiFill ? ' · Fill' : ''); $('#cbx-multi-cols-short', root).textContent = (S.multiCols ? String(S.multiCols) : 'Auto') + (S.multiFill ? '·F' : ''); } // the bar scrolls sideways, and a scroll container clips anything hanging below it: menus are // placed with fixed coordinates from their button and appended to the page instead function placeMenu(menu, btn) { var r = btn.getBoundingClientRect(); root.appendChild(menu); var w = menu.offsetWidth || 200, left = Math.min(Math.max(8, r.left), innerWidth - w - 8); menu.style.left = Math.round(left) + 'px'; menu.style.top = Math.round(r.bottom + 6) + 'px'; } function closeCols() { if (colsMenu) { colsMenu.remove(); colsMenu = null; } } colsEl.addEventListener('click', function (e) { e.stopPropagation(); if (colsMenu) { closeCols(); return; } colsMenu = el('div', { 'class': 'cbx-mini' }, [[0, 'Auto'], [1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5'], [6, '6'], [7, '7'], [8, '8']].map(function (c) { return '<button data-cols="' + c[0] + '"' + ((S.multiCols || 0) === c[0] ? ' class="cbx-on"' : '') + '>' + c[1] + (c[0] ? ' columns' : ' columns (by width)') + '</button>'; }).join('') + '<hr><button data-fill="1"' + (S.multiFill ? ' class="cbx-on"' : '') + '>Fill the screen<small>rows sized to the window, no scrolling</small></button>'); placeMenu(colsMenu, colsEl); colsMenu.addEventListener('click', function (ev) { ev.stopPropagation(); var b = ev.target.closest('[data-cols]'), f = ev.target.closest('[data-fill]'); if (b) { S.multiCols = parseInt(b.getAttribute('data-cols'), 10) || 0; save(); applyCols(); applyFill(); closeCols(); } else if (f) { S.multiFill = !S.multiFill; save(); applyCols(); applyFill(); f.classList.toggle('cbx-on', S.multiFill); } }); }); document.addEventListener('click', closeCols); // fill screen: pick the column count that gives the biggest 16:9 tiles for this many rooms and this window function applyFill() { root.classList.toggle('cbx-fill', !!S.multiFill); if (!S.multiFill) { grid.style.gridTemplateColumns = ''; grid.style.gridAutoRows = ''; grid.style.height = ''; return; } var cells = $$('.cbx-cam', grid).filter(function (c) { return c.style.display !== 'none'; }), n = cells.length || 1; var bar = $('#cbx-multi-bar', root), bare = root.classList.contains('cbx-bare'); var W = root.clientWidth - (bare ? 0 : 20), H = innerHeight - (bare ? 0 : bar.offsetHeight + 30), gap = bare ? 2 : 8, best = 1, bestSize = 0; for (var cols = 1; cols <= n; cols++) { var rows = Math.ceil(n / cols), tw = (W - gap * (cols - 1)) / cols, th = (H - gap * (rows - 1)) / rows; var size = Math.min(tw, th * 16 / 9); if (size > bestSize) { bestSize = size; best = cols; } } if (S.multiCols) best = Math.min(S.multiCols, n); var rowsN = Math.ceil(n / best); grid.style.gridTemplateColumns = 'repeat(' + best + ',1fr)'; grid.style.gridAutoRows = Math.floor((H - gap * (rowsN - 1)) / rowsN) + 'px'; grid.style.height = H + 'px'; } window.addEventListener('resize', function () { if (S.multiFill) applyFill(); }); applyCols(); applyFill(); function toggleBare(on) { if (on == null) on = !root.classList.contains('cbx-bare'); root.classList.toggle('cbx-bare', on); } function toggleFs() { try { if (document.fullscreenElement) document.exitFullscreen(); else root.requestFullscreen(); } catch (e) {} } $('#cbx-multi-fs', root).addEventListener('click', toggleFs); // fullscreen keeps the toolbar where it is (F10 hides it); the fill layout re-measures document.addEventListener('fullscreenchange', function () { root.classList.remove('cbx-bare'); if (S.multiFill) applyFill(); }); document.addEventListener('keydown', function (e) { if (e.target.matches && e.target.matches('input,textarea,select')) return; if (e.key === 'F11') { e.preventDefault(); toggleFs(); } else if (e.key === 'F10') { e.preventDefault(); toggleBare(); } else if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) { var shown = $$('.cbx-cam', grid).filter(function (c) { return c.style.display !== 'none'; }), pick = shown[parseInt(e.key, 10) - 1]; if (!pick) return; muteAll(); var pv = $('video', pick); if (pv) { pv.muted = false; pick.classList.add('cbx-live-audio'); } } else if (e.key === '0') muteAll(); else if (e.key === 'Escape' && bigEl) closeBig(); else if (e.key === 'Escape' && root.classList.contains('cbx-bare') && !document.fullscreenElement) toggleBare(false); }); $('#cbx-multi-share', root).addEventListener('click', function () { var users = $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); }); if (!users.length) { toast('Nothing to share yet'); return; } var url = location.origin + '/?cbx-multi=1&cbx-rooms=' + users.join(',') + '#cbx-multi'; var done = function () { toast('Link copied — it works for anyone with Enhanced Plus'); }; if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done, function () { prompt('Share link', url); }); else prompt('Share link', url); }); 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 = []; } } // repeated stalls: step the stream one rung down and keep it there for a while var stallLog = [], errorHold = 0; function stepQualityDown(v) { if (P.video && P.eng && v === P.video) { var cur = P.eng.active && P.eng.active(); var next = bestTrackUnder((cur && cur.height ? cur.height : 9999) - 1); if (next && (!cur || next.height < cur.height)) { pinTrack(next); return next.label || (next.height + 'p'); } return null; } var h = v._cbxHls; if (h && h.levels && h.levels.length) { var lvl = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel; if (lvl > 0) { h.loadLevel = lvl - 1; return h.levels[lvl - 1].height + 'p'; } } return null; } function installErrorQuality() { ['stalled', 'error'].forEach(function (ev) { document.addEventListener(ev, function (e) { if (!S.errorQuality || !roomName()) return; var v = e.target; if (!(v instanceof HTMLVideoElement) || v !== activeVideo()) return; var now = Date.now(); stallLog = stallLog.filter(function (t) { return now - t < 60000; }); stallLog.push(now); if (stallLog.length < 3 || now < errorHold) return; stallLog = []; var to = stepQualityDown(v); if (!to) return; errorHold = now + 5 * 60000; toast('Stream kept stalling — dropped to ' + to + ' for a few minutes'); }, true); }); } // a room opened in a background tab: the site waits for the tab; we do not function installInactiveLoad() { if (!S.inactiveLoad || !document.hidden || !roomName()) return; var tries = 0, t = setInterval(function () { if (++tries > 60) { clearInterval(t); return; } // the site parks a "click to play" image over background-tab rooms var btn = $('img[src*="play-inactive"]'); if (btn) { clickHard(btn.closest('button,a,div') || btn); log('inactive: clicked the site play button'); } var v = siteVideo(); if (!v) return; if (!v.paused && v.readyState >= 3) { clearInterval(t); return; } if (!document.hidden) return; if (!priorMuted.has(v)) priorMuted.set(v, v.muted); v.muted = true; var p = v.play(); if (p && p.catch) p.catch(function () {}); }, 2000); } 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, panX: 0, panY: 0 }; // picture filters live for the page only; a reload gives a clean picture var VF_DEFAULT = { bright: 100, contrast: 100, sat: 100, invert: false }; var vf = {}; for (var vfk in VF_DEFAULT) vf[vfk] = VF_DEFAULT[vfk]; function filterCSS() { var parts = []; if (vf.bright !== 100) parts.push('brightness(' + vf.bright / 100 + ')'); if (vf.contrast !== 100) parts.push('contrast(' + vf.contrast / 100 + ')'); if (vf.sat !== 100) parts.push('saturate(' + vf.sat / 100 + ')'); if (vf.invert) parts.push('invert(1)'); return parts.join(' '); } function pictureTouched() { return xform.rot !== 0 || xform.flip !== 1 || xform.zoom !== 1 || !!filterCSS(); } // how far the zoomed picture may be dragged before its edge would show function panLimits() { var sh = P.shell, va = videoAspect(), z = xform.zoom; if (!sh || !va) return { x: 0, y: 0 }; var W = sh.clientWidth || 1, H = sh.clientHeight || 1, picW, picH; // no early exit at zoom <= 1: a cover-fit picture overhangs the box sideways at zoom 1, and // that overhang is exactly what the phone pan drags through (the max(0, …) below covers the // contain case on its own) var cover = coverCrops(); if ((H / W > va) !== cover) { picW = W; picH = W * va; } else { picH = H; picW = H / va; } return { x: Math.max(0, (picW * z - W) / 2), y: Math.max(0, (picH * z - H) / 2) }; } function clampPan() { var L = panLimits(); xform.panX = Math.max(-L.x, Math.min(L.x, xform.panX || 0)); xform.panY = Math.max(-L.y, Math.min(L.y, xform.panY || 0)); } // on phones a taller box shows the picture cover-fit: it is cropped sideways, so it can pan too function coverCrops() { if (!P.shell || !document.documentElement.classList.contains('cbx-ph')) return false; var va = videoAspect(), W = P.shell.clientWidth, H = P.shell.clientHeight; return W > 0 && H / W > va + 0.01; } function panActive() { return xform.zoom > 1.02 || coverCrops(); } function applyTransform() { var v = activeVideo(); if (!v) return; clampPan(); var pan = panActive() && (xform.panX || xform.panY) ? 'translate(' + Math.round(xform.panX) + 'px,' + Math.round(xform.panY) + 'px) ' : ''; var t = pictureTouched() || pan ? pan + 'rotate(' + xform.rot + 'deg) scaleX(' + xform.flip + ') scale(' + xform.zoom + ')' : ''; if (v.style.transform !== t) v.style.transform = t; if (t) v.style.transformOrigin = 'center center'; if (P.block) { var cc = coverCrops(); // cover-fit only when the box is taller than the picture (the sideways crop the pan handles); // a box wider than the picture keeps contain-fit, otherwise a portrait or 4:3 stream lost its // top and bottom for good and "Fit" still showed a cropped picture P.block.classList.toggle('cbx-cover', cc); P.block.classList.toggle('cbx-zoomed', xform.zoom > 1.02); P.block.classList.toggle('cbx-panx', xform.zoom <= 1.02 && cc); } var f = filterCSS(); if (v.style.filter !== f) v.style.filter = f; syncBar(); } // the scale at which the picture just covers the box (letterbox gone) for this stream's aspect function coverScale() { var sh = P.shell, va = videoAspect(); if (!sh || !va) return 1; var ba = sh.clientHeight / Math.max(1, sh.clientWidth); if (!ba) return 1; return Math.max(ba / va, va / ba); } // the zoom the viewer sees, relative to the whole frame: a cover-fit box already counts as Fill function coverFactor() { return coverCrops() ? coverScale() : 1; } function effZoom() { return xform.zoom * coverFactor(); } function setEffZoom(z) { xform.zoom = Math.max(0.5, Math.min(3, z)) / coverFactor(); applyTransform(); } function zoomLabel() { var z = effZoom(), c = coverScale(); if (Math.abs(z - 1) < 0.03) return 'Fit'; if (c > 1.03 && Math.abs(z - c) < 0.03) return 'Fill'; return z.toFixed(1) + '×'; } // Fit -> Fill -> 1.5x -> 2x -> Fit function roomZoom() { var m = S.zoomByRoom || {}, r = roomName(); var z = r && m[r]; return z && z > 0.5 && z <= 3 ? z : 1; } function rememberZoom() { var r = roomName(); if (!r) return; var m = S.zoomByRoom || (S.zoomByRoom = {}); if (Math.abs(xform.zoom - 1) < 0.03) delete m[r]; else m[r] = Math.round(xform.zoom * 100) / 100; save(); } function cycleZoom() { var c = coverScale(), steps = [1]; if (c > 1.03 && c < 1.45) steps.push(c); steps.push(1.5, 2); var z = effZoom(), i = 0; while (i < steps.length && steps[i] <= z + 0.03) i++; setEffZoom(steps[i % steps.length]); rememberZoom(); toast('Zoom ' + zoomLabel()); } function resetTransform() { xform = { rot: 0, flip: 1, zoom: 1, panX: 0, panY: 0 }; if (P.block) { P.block.classList.remove('cbx-zoomed'); P.block.classList.toggle('cbx-panx', coverCrops()); } for (var k in VF_DEFAULT) vf[k] = VF_DEFAULT[k]; var v = activeVideo(); if (v) { v.style.transform = ''; v.style.filter = ''; } syncBar(); } /* ================================================================== * * volume boost + voice boost * * The cam audio is routed through a small Web Audio graph: * source → high-pass → low-mid cut → presence lift → gain → out. * The three filters are flat unless voice boost is on. A media element * can only be wired once, so the graph is kept per video. * ================================================================== */ var chains = new WeakMap(); function audioChain(v) { if (!v) return null; if (chains.has(v)) return chains.get(v); var Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return null; // a cross-origin src that is not MSE is tainted: routing it would give silence if (!/^blob:/.test(v.currentSrc || v.src || '') && !v.srcObject) return null; try { var ctx = new Ctx(); var src = ctx.createMediaElementSource(v); var hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 10; hp.Q.value = 0.7; var mid = ctx.createBiquadFilter(); mid.type = 'peaking'; mid.frequency.value = 300; mid.Q.value = 1; mid.gain.value = 0; var pres = ctx.createBiquadFilter(); pres.type = 'peaking'; pres.frequency.value = 3000; pres.Q.value = 0.9; pres.gain.value = 0; var gain = ctx.createGain(); var dest = ctx.createMediaStreamDestination(); src.connect(hp); hp.connect(mid); mid.connect(pres); pres.connect(gain); gain.connect(ctx.destination); gain.connect(dest); var ch = { ctx: ctx, gain: gain, hp: hp, mid: mid, pres: pres, dest: dest }; chains.set(v, ch); return ch; } catch (e) { log('audio chain refused', e && e.message); chains.set(v, null); return null; } } function applyAudioChain() { var v = activeVideo(); if (!v) return; var wanted = S.volumeBoost !== 100 || S.voiceBoost; if (!wanted && !chains.has(v)) return; var ch = audioChain(v); if (!ch) return; if (ch.ctx.state === 'suspended' && userHasInteracted) { var p = ch.ctx.resume(); if (p && p.catch) p.catch(function () {}); } ch.gain.gain.value = Math.max(0, S.volumeBoost) / 100; ch.hp.frequency.value = S.voiceBoost ? 120 : 10; ch.mid.gain.value = S.voiceBoost ? -6 : 0; ch.pres.gain.value = S.voiceBoost ? 5 : 0; } /* ================================================================== * * record what is playing * ================================================================== */ var rec = { r: null, chunks: [], since: 0, timer: null, kind: 'video' }; function recMime(audioOnly) { var list = audioOnly ? ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg'] : ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4']; for (var i = 0; i < list.length; i++) if (window.MediaRecorder && MediaRecorder.isTypeSupported(list[i])) return list[i]; return ''; } function recording() { return !!(rec.r && rec.r.state === 'recording'); } function startRecording() { var v = activeVideo(); if (!v || v.readyState < 2) { toast('Nothing is playing to record'); return; } if (!window.MediaRecorder) { toast('This browser cannot record'); return; } var cap = v.captureStream ? v.captureStream() : (v.mozCaptureStream ? v.mozCaptureStream() : null); if (!cap) { toast('This browser cannot capture the player'); return; } var tracks = []; if (!S.recAudioOnly) tracks = tracks.concat(cap.getVideoTracks()); if (!S.recVideoOnly) { var ch = chains.get(v); tracks = tracks.concat(ch ? ch.dest.stream.getAudioTracks() : cap.getAudioTracks()); } if (!tracks.length) { toast('Nothing to record with those options'); return; } var stream = new MediaStream(tracks); var audioOnly = !stream.getVideoTracks().length; var opts = { mimeType: recMime(audioOnly) }; if (!opts.mimeType) delete opts.mimeType; if (S.recLowPerf && !audioOnly) opts.videoBitsPerSecond = 1200000; try { rec.r = new MediaRecorder(stream, opts); } catch (e) { toast('Recording could not start'); rec.r = null; return; } rec.chunks = []; rec.since = Date.now(); rec.kind = audioOnly ? 'audio' : 'video'; rec.r.ondataavailable = function (e) { if (e.data && e.data.size) rec.chunks.push(e.data); }; rec.r.onstop = finishRecording; rec.r.onerror = function () { toast('Recording stopped by the browser'); }; rec.r.start(1000); addTick('rec', syncStrip, 1000); syncBar(); toast('Recording'); } function stopRecording() { if (!rec.r) return; try { if (rec.r.state !== 'inactive') rec.r.stop(); } catch (e) { finishRecording(); } } function finishRecording() { removeTick('rec'); rec.timer = null; var r = rec.r; rec.r = null; var chunks = rec.chunks; rec.chunks = []; syncBar(); if (!chunks.length) { toast('Nothing was recorded'); return; } var type = (r && r.mimeType) || (rec.kind === 'audio' ? 'audio/webm' : 'video/webm'); var ext = /mp4/.test(type) ? (rec.kind === 'audio' ? 'm4a' : 'mp4') : (/ogg/.test(type) ? 'ogg' : 'webm'); var blob = new Blob(chunks, { type: type }); var a = el('a', { download: (roomName() || 'cam') + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + 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'); } function toggleRecording() { if (recording()) stopRecording(); else startRecording(); } window.addEventListener('beforeunload', function (e) { if (!recording() || !S.recWarnLeave) return; e.preventDefault(); e.returnValue = ''; }); /* ================================================================== * * 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 * ================================================================== */ // each cell is [times seen, last date counted] so a day counts once per hour function seenCell(v) { if (Array.isArray(v)) return v; return v ? [1, ''] : [0, '']; } function recordSeen(user) { if (!S.trackSchedule || !user) return; var d = new Date(), key = d.getDay() + '-' + d.getHours(); var ymd = d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); var all = jsonGet(SEEN_KEY, {}); if (!all[user]) all[user] = {}; var cell = seenCell(all[user][key]); if (cell[1] === ymd) return; all[user][key] = [cell[0] + 1, ymd]; 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 hourLabel(h) { if (S.time24) return ('0' + h).slice(-2) + ':00'; return (h % 12 || 12) + (h < 12 ? ' am' : ' pm'); } 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 max = 0, d, h, k; for (k in grid) max = Math.max(max, seenCell(grid[k])[0]); var now = new Date(), nowKey = now.getDay() + '-' + now.getHours(); var out = '<div class="cbx-sched">'; for (d = 0; d < 7; d++) { out += '<div class="cbx-sched-row"><b>' + DAYS[d] + '</b>'; for (h = 0; h < 24; h++) { k = d + '-' + h; var n = seenCell(grid[k])[0]; var lvl = n ? Math.max(1, Math.ceil(n / max * 4)) : 0; out += '<i class="' + (lvl ? 'cbx-on cbx-l' + lvl : '') + (k === nowKey ? ' cbx-now' : '') + '" title="' + DAYS[d] + ' ' + hourLabel(h) + (n ? ' · live ' + n + (n === 1 ? ' time' : ' times') : ' · never seen live') + '"></i>'; } out += '</div>'; } out += '<div class="cbx-sched-row cbx-sched-hours"><b></b>'; for (h = 0; h < 24; h++) out += '<i>' + (h % 6 === 0 ? hourLabel(h).replace(':00', '').replace(' ', '') : '') + '</i>'; out += '</div></div><p class="cbx-note">Darker means seen live on more days at that hour. The outlined cell is now.</p>'; return out; } /* ================================================================== * * 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 todo = [], nodes = [], node; [panelEl, ddEl].forEach(function (root) { if (!root) return; var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); 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'); }); } /* ================================================================== * * toolbar under the player * * One row beneath the player on room pages: a zoom slider and four * menus — Picture, Sound, Record, Room. Each menu carries the live * controls and the settings that only make sense there, so the drawer * keeps site-wide preferences only. Menus are fixed-positioned and * live on body, so the page's overflow rules cannot clip them. * ================================================================== */ var ddEl = null, ddOpen = null, ddAnchor = null, ddCtx = null; var STRIP_CSS = [ '#cbx-dd{position:fixed;z-index:2147483550;width:320px;max-width:calc(100vw - 16px);max-height:70vh;overflow-y:auto;box-sizing:border-box;', 'background:var(--cbx-bg);color:var(--cbx-fg);border:1px solid var(--cbx-line);border-radius:12px;padding:10px 14px 12px;', 'font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;box-shadow:0 10px 30px rgba(0,0,0,.5);display:none}', '#cbx-dd.cbx-on{display:block}', '#cbx-dd h3{font-size:12px;font-weight:600;color:var(--cbx-dim);margin:12px 0 2px}#cbx-dd h3:first-child{margin-top:0}', '#cbx-dd .cbx-range{padding:4px 0}#cbx-dd .cbx-range label{width:78px;font-size:12px;color:var(--cbx-dim)}', '#cbx-dd .cbx-chips{margin-top:4px}', '#cbx-dd .cbx-b-wide{margin-top:6px}', '#cbx-dd .cbx-row{padding:6px 0}', '#cbx-dd .cbx-act{display:block;width:100%;text-align:left;appearance:none;font:inherit;font-size:13px;padding:9px 10px;margin:0 -10px;width:calc(100% + 20px);', 'box-sizing:border-box;border:0;border-radius:8px;background:transparent;color:var(--cbx-fg);cursor:pointer}', '#cbx-dd .cbx-act:hover,#cbx-dd .cbx-act:focus-visible{background:var(--cbx-bg-2);outline:0}', '#cbx-dd .cbx-act small{display:block;color:var(--cbx-dim);font-size:11px;margin-top:2px;white-space:pre-wrap}', '#cbx-dd .cbx-mrow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:2px 0 8px;font-size:13px}#cbx-dd .cbx-mrow .cbx-chips{margin:0}', '#cbx-dd .cbx-nav{display:flex;align-items:center;gap:8px}#cbx-dd .cbx-nav small{display:inline;margin:0 0 0 auto;padding-right:2px}#cbx-dd .cbx-nav::after{content:"\\203A";color:var(--cbx-dim);font-size:16px;line-height:1}', '#cbx-dd .cbx-back{color:var(--cbx-dim);font-size:12px;padding-top:4px;padding-bottom:4px;margin-bottom:2px}', '#cbx-dd .cbx-pick.cbx-on{background:var(--cbx-bg-2);box-shadow:inset 3px 0 0 var(--cbx-accent)}', '#cbx-dd.cbx-sheet{left:0;right:0;top:auto;bottom:0;width:100%;max-width:none;max-height:65vh;border-radius:14px 14px 0 0;border-width:1px 0 0;padding-bottom:calc(14px + env(safe-area-inset-bottom,0px))}', '@media (prefers-reduced-motion:reduce){#cbx-rec.cbx-rec-on #cbx-rec-dot{animation:none}}' ].join(''); function rangeHTML(id, label, min, max, step, val, unit) { return '<div class="cbx-range"><label for="' + id + '">' + label + '</label><input type="range" id="' + id + '" min="' + min + '" max="' + max + '" step="' + step + '" value="' + val + '"><b id="' + id + '-val">' + val + unit + '</b></div>'; } function chipHTML(id, label, on) { return '<button class="cbx-chip' + (on ? ' cbx-on' : '') + '" data-chip="' + id + '">' + label + '</button>'; } // what the quality page lists: our engine's tracks, or the site's menu (read once, cached) function qualityOptions() { var v = activeVideo(); if (!v) return []; if (v === P.video && P.eng) { var cur = P.eng.active(), auto = P.eng.isAuto(); return [{ value: 'auto', label: 'Auto', on: auto }].concat(P.eng.tracks().map(function (t) { return { value: 't' + t.id, label: t.label, on: !auto && !!cur && cur.id === t.id }; })); } var h = v._cbxHls; if (h && h.levels && h.levels.length) { var lvl = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel; return [{ value: 'auto', label: 'Auto', on: h.autoLevelEnabled }].concat(hlsLevelOptions(h).map(function (o) { return { value: o.value, label: o.label, on: !h.autoLevelEnabled && o.i === lvl }; })); } return siteQualityCache.opts.map(function (o) { return { value: o.value, label: o.label, on: !!v.videoHeight && o.height === v.videoHeight }; }); } function speedChips() { var v = activeVideo(), r = v ? v.playbackRate : 1; return '<div class="cbx-chips cbx-speed">' + [[0.5, '½×'], [1, '1×'], [2, '2×']].map(function (s) { return '<button class="cbx-chip' + (Math.abs(r - s[0]) < 0.01 ? ' cbx-on' : '') + '" data-speed="' + s[0] + '">' + s[1] + '</button>'; }).join('') + '</div>'; } function ddHTML(id) { var user = roomName(); var back = ddCtx && ddCtx.gear ? '<button class="cbx-act cbx-back" data-page="menu">‹ Back</button>' : ''; if (id === 'menu') { var q = qualityOptions().filter(function (o) { return o.on; })[0], v0 = activeVideo(); var qNow = q ? q.label : (v0 && v0.videoHeight ? v0.videoHeight + 'p' : '…'); return '<div class="cbx-mrow"><span>Speed</span>' + speedChips() + '</div>' + '<button class="cbx-act cbx-nav" data-page="quality">Quality<small>' + esc(qNow) + '</small></button>' + '<button class="cbx-act cbx-nav" data-page="pic">Picture' + (pictureTouched() ? '<small>adjusted</small>' : '') + '</button>' + '<button class="cbx-act cbx-nav" data-page="snd">Sound' + (S.volumeBoost !== 100 || S.voiceBoost ? '<small>boosted</small>' : '') + '</button>' + '<button class="cbx-act cbx-nav" data-page="rec">Record' + (recording() ? '<small>recording</small>' : '') + '</button>' + '<button class="cbx-act cbx-nav" data-page="tools">Tools</button>' + '<button class="cbx-act cbx-nav" data-page="room">Room</button>'; } if (id === 'quality') { var opts = qualityOptions(); if (!opts.length) return back + '<h3>Quality</h3><p class="cbx-note" id="cbx-q-wait">Reading the site\'s quality menu…</p>'; return back + '<h3>Quality</h3>' + opts.map(function (o) { return '<button class="cbx-act cbx-pick' + (o.on ? ' cbx-on' : '') + '" data-q="' + esc(o.value) + '">' + esc(o.label) + '</button>'; }).join(''); } if (id === 'pic') return back + '<h3>Picture</h3>' + rangeHTML('cbx-vf-bright', 'Brightness', 20, 250, 5, vf.bright, '%') + rangeHTML('cbx-vf-contrast', 'Contrast', 20, 250, 5, vf.contrast, '%') + rangeHTML('cbx-vf-sat', 'Saturation', 0, 250, 5, vf.sat, '%') + rangeHTML('cbx-zoom-dd', 'Zoom', 50, 300, 5, Math.round(effZoom() * 100), '%') + '<div class="cbx-chips">' + chipHTML('rot', 'Rotate', xform.rot !== 0) + chipHTML('flip', 'Flip', xform.flip === -1) + chipHTML('invert', 'Invert', vf.invert) + '</div>' + '<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-pic-reset">Reset picture</button>'; if (id === 'snd') return back + '<h3>Cam audio</h3>' + rangeHTML('cbx-boost', 'Boost', 100, 400, 10, S.volumeBoost, '%') + rowHTML('voiceBoost', 'Voice boost', 'Trims rumble and lifts speech.') + '<div class="cbx-note" id="cbx-boost-note"></div>' + '<h3>Tip sounds</h3>' + rangeHTML('cbx-tipvol', 'Tip volume', 0, 100, 5, S.tipVolume, '%') + '<h3>Other tabs</h3>' + rowHTML('bgMute', 'Mute when this tab is hidden', null) + rowHTML('exclusiveAudio', 'Only one tab plays sound', 'Unmuting here mutes the other CB tabs.'); if (id === 'tools') return back + '<h3>Tools</h3>' + '<button class="cbx-act" data-do="snap">Save a frame<small>S</small></button>' + '<button class="cbx-act" data-do="pip">Picture in picture</button>' + (S.clipSave ? '<button class="cbx-act" data-do="clip">Save the buffer<small>D · the last minutes at source quality</small></button>' : '') + '<button class="cbx-act" data-do="loop">' + (P.loopB != null ? 'Clear loop' : P.loopA != null ? 'Set loop end' : 'Set loop start') + '<small>A</small></button>' + '<button class="cbx-act" data-do="mark">Bookmark this moment<small>B · [ ] to jump</small></button>' + (P.marks && P.marks.length ? '<button class="cbx-act" data-do="bmprev">Previous bookmark</button><button class="cbx-act" data-do="bmnext">Next bookmark</button>' : '') + '<button class="cbx-act" data-do="stats">' + (S.statsOverlay ? 'Hide stats' : 'Show stats') + '<small>I</small></button>'; if (id === 'rec') return back + '<h3>Recording</h3>' + '<button class="cbx-act" data-do="rec">' + (recording() ? 'Stop and save' : 'Start recording') + '<small>R</small></button>' + rowHTML('recAudioOnly', 'Audio only', null) + rowHTML('recVideoOnly', 'Video only', null) + rowHTML('recLowPerf', 'Low performance mode', 'Lower bitrate; easier on slow machines.') + rowHTML('recWarnLeave', 'Warn before leaving while recording', null) + '<small class="cbx-note">Record re-encodes what plays from the moment you press it, forward. It costs CPU and battery, and on Firefox for Android audio only comes through with the Sound menu active.</small>' + '<h3>Instant clip</h3>' + rowHTML('clipSave', 'Instant clip of the rewind window', 'Keeps the original stream segments already downloaded for rewind, so "Save the buffer" writes the last minutes in a second at source quality, no re-encoding. Set an A-B loop to save just that range. Uses extra memory.') + '<button class="cbx-b cbx-b-wide" id="cbx-dd-clip">Save the buffer now</button>'; if (id === 'card') { var cu = ddCtx.user, cnote = jsonGet(NOTES_KEY, {})[cu] || ''; return '<button class="cbx-act" data-room="hide">Hide this cam</button>' + (ddCtx.cc ? '<button class="cbx-act" data-room="country">Hide ' + esc(countryName(ddCtx.cc)) + '</button>' : '') + '<button class="cbx-act" data-room="note">' + (cnote ? 'Edit note' : 'Add a note') + (cnote ? '<small>' + esc(cnote) + '</small>' : '') + '</button>' + '<button class="cbx-act" data-room="alert">' + (alertList().indexOf(cu) !== -1 ? 'Stop alerting when live' : 'Alert me when live') + '</button>' + (S.multiCam ? '<button class="cbx-act" data-room="multi">Add to multi cam</button>' : ''); } if (id === 'room') { var note = jsonGet(NOTES_KEY, {})[user] || ''; var alerting = alertList().indexOf(user) !== -1; return back + '<h3>Room</h3><button class="cbx-act" data-room="watch">Watch without chat</button>' + '<button class="cbx-act" data-room="url">Copy stream URL</button>' + '<button class="cbx-act" data-room="alert">' + (alerting ? 'Stop alerting when live' : 'Alert me when live') + '</button>' + '<button class="cbx-act" data-room="note">' + (note ? 'Edit note' : 'Add a note') + (note ? '<small>' + esc(note) + '</small>' : '') + '</button>' + (S.multiCam ? '<button class="cbx-act" data-room="multi">Add to multi cam</button>' : '') + '<button class="cbx-act" data-room="hide">Hide this room</button>'; } return ''; } // the toolbar and the dropdown menus share one stylesheet; the card menu // needs it on list pages where no toolbar is ever built function ensureStripCSS() { if (!$('#cbx-strip-css')) (document.head || document.documentElement).appendChild(el('style', { id: 'cbx-strip-css' }, STRIP_CSS)); } function openDD(id, anchor, ctx) { if (!ddEl) { ensureStripCSS(); ddEl = el('div', { id: 'cbx-dd', role: 'menu' }); uiHost().appendChild(ddEl); bindDD(); } if (ddOpen === id && ddAnchor === anchor) { closeDD(); return; } ddOpen = id; ddAnchor = anchor; ddCtx = ctx || null; ddEl._openedAt = Date.now(); ddEl.innerHTML = ddHTML(id); ddEl.classList.add('cbx-on'); qualityRead(id); if (P.bar) $$('button[data-act="gear"]', P.bar).forEach(function (b) { b.classList.toggle('cbx-on', !!(ctx && ctx.gear)); }); placeDD(anchor); syncBar(); localizePanel(); } // the Quality page reads the site's menu once per opening; the answer repaints the page, never re-asks function qualityRead(id) { if (id !== 'quality' || qualityOptions().length || ddEl._qReading) return; ddEl._qReading = true; setTimeout(function () { readSiteQualities(function (opts) { ddEl._qReading = false; if (ddOpen !== 'quality') return; ddEl.innerHTML = opts.length ? ddHTML('quality') : (ddCtx && ddCtx.gear ? '<button class="cbx-act cbx-back" data-page="menu">‹ Back</button>' : '') + '<h3>Quality</h3><p class="cbx-note">This player has no quality menu to read.</p>'; placeDD(); localizePanel(); }); }, 0); } // a page change inside the open menu function showDD(id) { if (!ddEl || !ddOpen) return; ddOpen = id; ddEl.innerHTML = ddHTML(id); qualityRead(id); placeDD(); syncBar(); localizePanel(); } function placeDD(anchor) { if (!ddEl || !ddOpen) return; anchor = anchor || ddAnchor; if (!anchor || !anchor.isConnected) { closeDD(); return; } // phones: a bottom sheet, placed by the stylesheet if (isTouch()) { ddEl.style.left = ''; ddEl.style.top = ''; ddEl.classList.add('cbx-sheet'); return; } ddEl.classList.remove('cbx-sheet'); var r = anchor.getBoundingClientRect(), w = ddEl.offsetWidth || 320, h = ddEl.offsetHeight || 200; var left = Math.min(Math.max(8, r.right - w), innerWidth - w - 8); // from the bar the menu opens upward over the picture; elsewhere it drops down var fromBar = !!anchor.closest('#cbx-bar'), top = fromBar ? r.top - h - 8 : r.bottom + 6; if (fromBar && top < 8) top = r.bottom + 6; if (!fromBar && top + h > innerHeight - 8 && r.top - h - 6 > 8) top = r.top - h - 6; ddEl.style.left = Math.round(left) + 'px'; ddEl.style.top = Math.round(top) + 'px'; } function closeDD() { if (!ddEl) return; ddOpen = null; ddAnchor = null; ddCtx = null; ddEl._qReading = false; ddEl.classList.remove('cbx-on'); if (P.bar) $$('button[data-act="gear"]', P.bar).forEach(function (b) { b.classList.remove('cbx-on'); }); } function bindDD() { document.addEventListener('pointerdown', function (e) { if (!ddOpen) return; if (ddEl.contains(e.target) || (ddAnchor && ddAnchor.contains(e.target))) return; closeDD(); }, true); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && ddOpen) { closeDD(); e.stopPropagation(); } }, true); window.addEventListener('resize', function () { placeDD(); }); var ddScrollY = 0; window.addEventListener('scroll', function () { if (!ddOpen) return; // phones nudge the page by a few pixels on tap (address bar, focus); only a real scroll closes the menu if (Date.now() - (ddEl._openedAt || 0) < 500) { ddScrollY = scrollY; return; } if (Math.abs(scrollY - ddScrollY) > 40) closeDD(); }, { passive: true }); ddEl.addEventListener('input', function (e) { var t = e.target, id = t.id, v = parseFloat(t.value), lbl = $('#' + id + '-val', ddEl); if (id === 'cbx-vf-bright') { vf.bright = v; if (lbl) lbl.textContent = v + '%'; applyTransform(); } else if (id === 'cbx-vf-contrast') { vf.contrast = v; if (lbl) lbl.textContent = v + '%'; applyTransform(); } else if (id === 'cbx-vf-sat') { vf.sat = v; if (lbl) lbl.textContent = v + '%'; applyTransform(); } else if (id === 'cbx-zoom-dd') { setEffZoom(v / 100); if (lbl) lbl.textContent = v + '%'; } else if (id === 'cbx-boost') { S.volumeBoost = Math.round(v); if (lbl) lbl.textContent = S.volumeBoost + '%'; applyAudioChain(); boostNote(); } else if (id === 'cbx-tipvol') { if (lbl) lbl.textContent = t.value + '%'; } }); ddEl.addEventListener('change', function (e) { var t = e.target; if (t.id === 'cbx-boost') { save(); return; } if (t.id === 'cbx-tipvol') { S.tipVolume = parseInt(t.value, 10) || 0; save(); tipDone = false; applyTipMute(0); return; } var k = t.getAttribute && t.getAttribute('data-cbx'); if (!k) return; S[k] = !!t.checked; if (k === 'recAudioOnly' && S[k]) { S.recVideoOnly = false; } if (k === 'recVideoOnly' && S[k]) { S.recAudioOnly = false; } save(); if (k === 'voiceBoost') { applyAudioChain(); boostNote(); } onSettingChanged(k); $$('input[data-cbx]', ddEl).forEach(function (i) { i.checked = !!S[i.getAttribute('data-cbx')]; }); }); ddEl.addEventListener('dblclick', function (e) { var t = e.target; if (!(t instanceof HTMLInputElement) || t.type !== 'range') return; var def = { 'cbx-vf-bright': 100, 'cbx-vf-contrast': 100, 'cbx-vf-sat': 100, 'cbx-zoom-dd': 100, 'cbx-boost': 100 }[t.id]; if (def == null) return; t.value = def; t.dispatchEvent(new Event('input', { bubbles: true })); t.dispatchEvent(new Event('change', { bubbles: true })); }); ddEl.addEventListener('click', function (e) { var nav = e.target.closest('[data-page]'); if (nav) { e.preventDefault(); e.stopPropagation(); showDD(nav.getAttribute('data-page')); return; } var sp = e.target.closest('[data-speed]'); if (sp) { var vv = activeVideo(); if (vv) setRate(vv, parseFloat(sp.getAttribute('data-speed'))); $$('[data-speed]', ddEl).forEach(function (b) { b.classList.toggle('cbx-on', b === sp); }); return; } var pick = e.target.closest('[data-q]'); if (pick) { pickQuality(pick.getAttribute('data-q')); setTimeout(function () { if (ddOpen === 'quality') showDD('quality'); }, 700); return; } var doBtn = e.target.closest('[data-do]'); if (doBtn) { var what = doBtn.getAttribute('data-do'); e.preventDefault(); e.stopPropagation(); closeDD(); if (what === 'bmprev') jumpBookmark(-1); else if (what === 'bmnext') jumpBookmark(1); else runAct(what); return; } var chip = e.target.closest('.cbx-chip'); if (chip && chip.hasAttribute('data-chip')) { var c = chip.getAttribute('data-chip'); if (c === 'rot') xform.rot = (xform.rot + 90) % 360; else if (c === 'flip') xform.flip *= -1; else if (c === 'invert') vf.invert = !vf.invert; applyTransform(); chip.classList.toggle('cbx-on', c === 'rot' ? xform.rot !== 0 : c === 'flip' ? xform.flip === -1 : !!vf[c]); return; } if (e.target.id === 'cbx-pic-reset') { resetTransform(); ddEl.innerHTML = ddHTML('pic'); return; } if (e.target.id === 'cbx-dd-clip') { closeDD(); saveClip(); return; } var act = e.target.closest('.cbx-act'); if (!act) return; var what = act.getAttribute('data-room'), fromCard = ddOpen === 'card', ctx = ddCtx || {}; closeDD(); if (fromCard) roomAction(what, ctx.user, ctx); else roomAction(what, roomName(), { confirm: true }); }); } function boostNote() { var n = $('#cbx-boost-note', ddEl); if (!n) return; var v = activeVideo(); var wanted = S.volumeBoost !== 100 || S.voiceBoost; if (!wanted) { n.textContent = ''; return; } var ch = v && chains.get(v); n.textContent = ch ? '' : (v ? 'Not available for this player: the stream is not routed through the page.' : 'Starts when the video plays.'); } // bar state that is not the timeline: the record button and badge, the zoom slider, the gear dot function syncBar() { var bar = P.bar, on = recording(), elapsed = on ? fmtTime((Date.now() - rec.since) / 1000) : ''; if (bar && bar.isConnected) { var rb = $('#cbx-rec', bar), rt = $('#cbx-rec-txt', bar); if (rb) { rb.classList.toggle('cbx-rec-on', on); rb.setAttribute('aria-pressed', on ? 'true' : 'false'); } if (rt && rt.textContent !== elapsed) rt.textContent = elapsed; var g = $('button[data-act="gear"]', bar); if (g) g.classList.toggle('cbx-dot', pictureTouched() || S.volumeBoost !== 100 || !!S.voiceBoost); } var badge = P.block && $('#cbx-recbadge', P.block); if (badge) { badge.hidden = !on; var bs = badge.lastChild; if (bs && bs.textContent !== elapsed) bs.textContent = elapsed; } var z = P.block && $('#cbx-zoom', P.block); if (z && document.activeElement !== z) { var zv = Math.round(effZoom() * 100); if (String(z.value) !== String(zv)) z.value = zv; } var zl = P.block && $('#cbx-zoom-val', P.block); if (zl) { var zt = zoomLabel(); if (zl.textContent !== zt) zl.textContent = zt; } // a focused control that pokes past the box once made the site's container scroll sideways; never let that stick if (P.box && (P.box.scrollLeft || P.box.scrollTop)) { P.box.scrollLeft = 0; P.box.scrollTop = 0; } if (ddOpen) { boostNote(); placeDD(); } } /* ================================================================== * * 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;justify-content:center;gap:7px;height:40px;min-width:112px;padding:0 14px 0 12px;border-radius:20px;box-sizing:border-box;', '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,#cbx-multidock:hover,#cbx-multidock:focus-visible{opacity:1}', '#cbx-multidock{position:fixed;z-index:2147483400;display:none;align-items:center;gap:6px;opacity:.72;transition:opacity .15s ease}', '#cbx-multidock button{position:relative;display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:20px;padding:0;border:1px solid var(--cbx-line);background:var(--cbx-bg);color:var(--cbx-fg);cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.35);-webkit-tap-highlight-color:transparent}', '#cbx-multidock svg{width:18px;height:18px}#cbx-multidock .cbx-in{color:#3ad07a;border-color:rgba(58,208,122,.5)}', '#cbx-multidock b{display:none;position:absolute;top:-3px;right:-3px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:#f67300;color:#fff;font:600 10px/16px system-ui,sans-serif;text-align:center;box-shadow:0 0 0 2px var(--cbx-bg)}#cbx-multidock .cbx-has b{display:block}', '.cbx-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none;background:#8b969e}', '#cbx-dock .cbx-dot{width:16px;height:16px;border-radius:0;background:none;display:inline-flex}#cbx-dock .cbx-dot svg{width:16px;height:16px}.cbx-dot-on{color:#3ad07a}.cbx-dot-off{color:#8b969e}.cbx-dot-nat{color:#f6a25e}', '#cbx-launcher svg{width:18px;height:18px}#cbx-launcher svg path{fill:currentColor;stroke:none}', '#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;height:min(80vh,640px);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}', '#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-tab-reset{margin-top:16px;opacity:.75}', '#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-sched-row i.cbx-l1{opacity:.3}.cbx-sched-row i.cbx-l2{opacity:.55}.cbx-sched-row i.cbx-l3{opacity:.8}.cbx-sched-row i.cbx-l4{opacity:1}', '.cbx-sched-row i.cbx-now{outline:1px solid var(--cbx-fg);outline-offset:-1px}', '.cbx-sched-hours i{background:none;height:auto;font:9px/1 system-ui,sans-serif;color:var(--cbx-dim);overflow:visible;white-space:nowrap}', '.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 .cbx-ta{width:100%;box-sizing:border-box;resize:vertical;background:var(--cbx-bg-2);color:var(--cbx-fg);border:1px solid var(--cbx-line);border-radius:8px;padding:8px;font:13px/1.4 inherit;margin:2px 0 6px}', '#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:6px;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;align-items:center;margin-bottom:10px}', '#cbx-multi-bar::-webkit-scrollbar{display:none}#cbx-multi input[type=text]{flex:1 1 120px;min-width:110px;max-width:260px;padding:9px 11px;border-radius:8px;border:1px solid #2a3138;background:#1c2126;color:#e8ebed;font-size:16px}', '#cbx-multi-bar .cbx-grow{flex:1 1 8px}', '#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{cursor:grab;position:absolute;left:8px;top:7px;padding:2px 7px;border-radius:5px;background:rgba(0,0,0,.65);font-size:12px}', '.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-multi .cbx-ib{width:40px;height:40px;padding:0;display:inline-flex;align-items:center;justify-content:center}#cbx-multi .cbx-b svg{width:18px;height:18px;flex:none}#cbx-multi-add{display:inline-flex;align-items:center;gap:6px}#cbx-multi-add svg{width:16px;height:16px}', '#cbx-multi .cbx-it{display:inline-flex;align-items:center;gap:6px;height:40px;padding:0 10px;white-space:nowrap;flex:none}#cbx-multi .cbx-it.cbx-on{background:#2a3138;border-color:#f67300;color:#fff}#cbx-multi .cbx-b{flex:none}#cbx-multi-bar .cbx-grow{flex:1 1 4px;min-width:0}', '#cbx-multi .cbx-chev{width:14px!important;height:14px!important;margin-left:-2px;opacity:.7}', '#cbx-multi-cols-short{display:none;font-weight:600;font-size:12px}' + '@media (max-width:1400px){#cbx-multi .cbx-it span{display:none}#cbx-multi .cbx-it{width:40px;padding:0;justify-content:center}#cbx-multi .cbx-chev{display:none}#cbx-multi-cols{width:auto;padding:0 9px;gap:5px}#cbx-multi-cols-short{display:inline}}', '.cbx-ddwrap{flex:none}.cbx-mini{position:fixed;z-index:30;min-width:170px;padding:6px;border-radius:10px;border:1px solid #2a3138;background:#161a1e;box-shadow:0 10px 30px rgba(0,0,0,.5);display:flex;flex-direction:column;gap:2px}.cbx-mini button{text-align:left;border:0;border-radius:6px;background:transparent;color:#e8ebed;padding:8px 10px;cursor:pointer;font:inherit;font-size:13px}.cbx-mini button:hover{background:#252b31}.cbx-mini button.cbx-on{background:#2a3138;box-shadow:inset 3px 0 0 #f67300}.cbx-mini hr{border:0;border-top:1px solid #2a3138;margin:4px 2px}.cbx-mini button small{display:block;color:#8b969e;font-size:11px;margin-top:2px}', '.cbx-setswrap{flex:none}#cbx-multi-setsmenu{position:fixed;z-index:30;min-width:260px;padding:8px;border-radius:10px;border:1px solid #2a3138;background:#161a1e;box-shadow:0 10px 30px rgba(0,0,0,.5)}', '#cbx-multi-setsmenu .cbx-setrow{display:flex;gap:4px;align-items:stretch;margin-bottom:4px}#cbx-multi-setsmenu .cbx-setload{flex:1;text-align:left;border:0;border-radius:6px;background:#1c2126;color:#e8ebed;padding:8px 10px;cursor:pointer;font:inherit;font-size:13px}#cbx-multi-setsmenu .cbx-setload small{display:block;color:#8b969e;font-size:11px}#cbx-multi-setsmenu .cbx-setload:hover{background:#252b31}', '#cbx-multi-setsmenu .cbx-setdel{width:34px;border:0;border-radius:6px;background:#1c2126;color:#8b969e;cursor:pointer}#cbx-multi-setsmenu .cbx-setdel:hover{color:#fff;background:#3a2222}#cbx-multi-setsmenu .cbx-setdel svg{width:14px;height:14px}#cbx-multi-setsmenu #cbx-setsave{margin-top:6px}#cbx-multi-setsmenu .cbx-note{margin:4px 2px 8px}', '#cbx-multi.cbx-fill{overflow:hidden}#cbx-multi.cbx-fill .cbx-cam{aspect-ratio:auto;height:100%}#cbx-multi.cbx-fill #cbx-multi-grid{align-content:start}', '.cbx-cam[data-status]:not([data-status="public"]):not([data-status=""]):not([data-status="offline"])::after{content:attr(data-status);position:absolute;left:8px;bottom:8px;padding:2px 7px;border-radius:4px;background:rgba(200,60,60,.85);color:#fff;font:600 11px/1.4 system-ui,sans-serif;text-transform:capitalize;pointer-events:none}', '.cbx-cam .cbx-tools{position:absolute;right:6px;top:6px;display:flex;gap:4px;opacity:0;transition:opacity .15s ease}.cbx-cam:hover .cbx-tools,.cbx-cam.cbx-paused .cbx-tools,.cbx-cam.cbx-show .cbx-tools{opacity:1}', '.cbx-cam .cbx-tb{width:30px;height:30px;border:0;border-radius:15px;background:rgba(0,0,0,.65);color:#fff;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}.cbx-cam .cbx-tb svg{width:16px;height:16px}', /* lightbox: dimmed backdrop, the picture as large as fits, click outside to close */ '#cbx-multi-big{position:fixed;inset:0;z-index:20;background:rgba(6,8,10,.92);display:flex;align-items:center;justify-content:center;cursor:zoom-out}#cbx-multi-big video{max-width:94vw;max-height:90vh;width:94vw;height:auto;aspect-ratio:16/9;object-fit:contain;background:#000;border-radius:8px;box-shadow:0 12px 40px rgba(0,0,0,.6);cursor:default}', '#cbx-multi-big .cbx-big-top{position:absolute;left:12px;right:12px;top:10px;display:flex;align-items:center;gap:8px;z-index:2}#cbx-multi-big .cbx-big-name{padding:5px 10px;border-radius:6px;background:rgba(0,0,0,.6);font-size:13px;margin-right:auto}', '#cbx-multi-big .cbx-big-pop,#cbx-multi-big .cbx-big-x{height:36px;border:0;border-radius:18px;background:rgba(0,0,0,.65);color:#fff;display:inline-flex;align-items:center;gap:6px;padding:0 12px;cursor:pointer;font:13px system-ui,sans-serif}#cbx-multi-big .cbx-big-x{width:36px;padding:0;justify-content:center}#cbx-multi-big .cbx-big-top svg{width:18px;height:18px}', '.cbx-cam.cbx-dragging{opacity:.4}.cbx-cam.cbx-drop-before{box-shadow:-4px 0 0 #f67300}.cbx-cam.cbx-drop-after{box-shadow:4px 0 0 #f67300}', '#cbx-multi.cbx-bare{padding:0}#cbx-multi.cbx-bare #cbx-multi-bar{position:fixed;left:0;right:0;top:0;z-index:5;margin:0;padding:8px;background:rgba(15,18,21,.92);transform:translateY(-100%);transition:transform .15s ease}', '#cbx-multi.cbx-bare #cbx-multi-bar:hover,#cbx-multi.cbx-bare #cbx-multi-hot:hover + #cbx-multi-bar{transform:none}', '#cbx-multi-hot{display:none}#cbx-multi.cbx-bare #cbx-multi-hot{display:block;position:fixed;left:0;right:0;top:0;height:10px;z-index:4}', '#cbx-multi.cbx-bare #cbx-multi-grid{gap:2px}#cbx-multi.cbx-bare .cbx-cam{border-radius:0;border:0}', '#cbx-multi-grid[data-cols="2"]{grid-template-columns:repeat(2,1fr)}#cbx-multi-grid[data-cols="3"]{grid-template-columns:repeat(3,1fr)}', '#cbx-multi-grid[data-cols="4"]{grid-template-columns:repeat(4,1fr)}#cbx-multi-grid[data-cols="5"]{grid-template-columns:repeat(5,1fr)}#cbx-multi-grid[data-cols="6"]{grid-template-columns:repeat(6,1fr)}#cbx-multi-grid[data-cols="7"]{grid-template-columns:repeat(7,1fr)}#cbx-multi-grid[data-cols="8"]{grid-template-columns:repeat(8,1fr)}#cbx-multi-grid[data-cols="1"]{grid-template-columns:1fr}', '@media (prefers-reduced-motion:reduce){#cbx-multi.cbx-bare #cbx-multi-bar{transition:none}}' ].join(''); var GEAR_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84a.48.48 0 0 0-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87a.48.48 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.01-1.58zM12 15.6a3.6 3.6 0 1 1 0-7.2 3.6 3.6 0 0 1 0 7.2z"/></svg>'; var TABS = [ { id: 'rewind', label: 'Rewind', groups: [ { title: null, items: [ ['siteDvr', 'Rewind', 'Holds minutes of the stream you can scrub back through, from one download.'], selectHTML('cbx-site-keep', 'Rewind window: ', [[120, '2 minutes'], [300, '5 minutes'], [600, '10 minutes']], S.siteKeep), ['scrubThumbs', 'Preview frames on the timeline', 'A small frame every 10 seconds while you hover or drag.'], ['tipMarks', 'Tip marks on the timeline', 'A yellow tick where each tip landed, so you can rewind to it.'], '<div class="cbx-mono" id="cbx-sitedvr-note"></div>' + '<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-siteplayer">Plain site player (this page)</button>' ] } ] }, { id: 'playback', label: 'Playback', groups: [ { title: 'Quality', items: [ selectHTML('cbx-quality-cap', 'Quality: ', [[0, 'best available'], [1080, 'up to 1080p'], [720, 'up to 720p'], [480, 'up to 480p']], S.qualityCap), ['autoQuality', 'Always use the best quality', 'Re-applies it when the player drops back to Auto.'], selectHTML('cbx-datasaver', 'Data saver: ', [['off', 'off'], ['auto', 'on cellular or low battery'], ['on', 'always']], S.dataSaver ? 'on' : S.dataSaverAuto ? 'auto' : 'off'), ['errorQuality', 'Drop quality when the stream keeps stalling', null], '<div class="cbx-mono" id="cbx-quality-note"></div>' ] }, { title: 'Background tabs', items: [ selectHTML('cbx-bgtabs', 'When this tab is hidden: ', [['play', 'keep playing'], ['quality', 'drop quality'], ['pause', 'pause']], S.inactivePause ? 'pause' : S.inactiveQuality ? 'quality' : 'play'), ['inactiveLoad', 'Start streams in background tabs', 'Rooms opened in a new tab load muted instead of waiting.'] ] }, { title: 'Extras', items: [ ['mediaSession', 'Lock screen and headset controls', null], ['showDuration', 'Show stream time', null], ['pipButton', 'Picture in picture controls', null] ] } ] }, { id: 'controls', label: 'Controls', groups: [ { title: 'Keyboard and touch', items: [ ['keyShortcuts', 'Keyboard shortcuts', 'Press ? on a room page for the list.'], ['dblTapSeek', 'Double-tap to skip', 'Left third −10s, right third +10s, middle fullscreen.'], ['swipeSeek', 'Swipe to rewind (phones)', null], ['holdFast', 'Hold for 2× (phones)', null] ] }, { title: 'On the picture', items: [ ['fsAutoHide', 'Hide controls in fullscreen', null], ['rotateFullscreen', 'Rotate the phone for fullscreen', 'Landscape fills the screen; portrait brings the page back.'], ['statsOverlay', 'Stats overlay', 'Resolution, bitrate, latency, buffers and dropped frames (I).'] ] } ] }, { id: 'sound', label: 'Sound', groups: [ { title: 'Effects', 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 chosen in the Sound menu.'] ] } ] }, { id: 'rooms', label: 'Rooms', groups: [ { title: 'Room pages', items: [ ['bioInfo', 'Show extra room info', 'Country, region, time online, private and spy prices, fan club price, joined date.'], ['autoChatRules', 'Accept room rules automatically', null], ['trackSchedule', 'Remember when rooms are live', null], ['time24', '24 hour times', null] ] }, { title: 'Room cards', items: [ ['cardTools', 'Menu on room cards', 'Hide cam, hide country, note, alert, add to multi cam.'], ['cardWatchBtn', 'Watch-without-chat button on cards', null], ['openNewTab', 'Open rooms in a new tab', null], ['randomLink', 'Random room link in the header', 'Uses the genders picked in the Multi tab.'] ] }, { title: 'Previews', items: [ selectHTML('cbx-previews', 'Preview on hover: ', [['off', 'off'], ['inline', 'in the thumbnail'], ['box', 'in a corner box']], !S.hoverPreview ? 'off' : S.previewInline ? 'inline' : 'box'), ['previewMuted', 'Previews start muted', null], ['inlinePreview', 'Keep previews inline', 'Stops previews jumping to fullscreen on iOS.'] ] } ] }, { id: 'chat', label: 'Chat', groups: [ { title: 'Translate', items: [ ['translateChat', 'Translate messages', null], selectHTML('cbx-tr-lang', 'Into ', LANGS, S.translateTo) ] }, { title: 'Hide', items: [ ['chatHideNotices', 'Room notices', null], ['chatHideSubject', 'Subject changes', null], ['chatHideTips', 'Tip messages', null], ['chatHideGreys', 'Grey users', null], ['chatTipsOnly', 'Everything but tips', null] ] }, { title: 'Text', items: [ selectHTML('cbx-chat-font', 'Chat text: ', [[0, 'site default'], [12, 'small'], [14, 'medium'], [16, 'large'], [18, 'extra large']], S.chatFont) ] } ] }, { id: 'look', label: 'Look', groups: [ { title: 'Theme', items: [ ['forceDark', 'Dark theme', null], ['darkLegacy', 'Dark theme on the older pages too', 'Fan club, supporter, followers and account forms.'], ['tightMargins', 'Tighter page margins', null], ['cleanProfile', 'Flatten profile styling', 'Strips absolute positioning, backgrounds and animation from bios.'] ] }, { title: 'Hide clutter', items: [ ['hideAds', 'Ads and banners', null], ['hideSocials', 'Social links', null], ['hideMerch', 'Merch links', null], ['hideSurveys', 'Surveys and feedback prompts', null], ['hidePlayerLogo', 'Logo on the player', null], ['hideBadges', 'Thumbnail badges', null] ] }, { title: 'Room cards', items: [ selectHTML('cbx-grid', 'Card size: ', [[0, 'site default'], [150, 'small'], [200, 'medium'], [260, 'large'], [340, 'extra large']], S.gridSize), selectHTML('cbx-grid-more', 'Cards under a stream: ', [[0, 'same as above'], [150, 'small'], [200, 'medium'], [260, 'large'], [340, 'extra large']], S.moreGridSize) ] }, { title: 'Who to show', items: [ ['hideGenderF', 'Hide women', null], ['hideGenderM', 'Hide men', null], ['hideGenderC', 'Hide couples', null], ['hideGenderT', 'Hide trans', null] ] } ] }, { id: 'multi', label: 'Multi cam', groups: [ { title: null, items: [ ['multiCam', 'Enable multi cam', 'Opens in its own tab.'], ['multiShowSubject', 'Show room subjects', null], ['multiHideOffline', 'Hide cams that are offline', null], ['multiHidePrivate', 'Hide cams in private, away or password shows', 'Off shows them with a badge; they come back by themselves when the show ends.'], ['multiAutoRemove', 'Drop offline cams from the list', null], ['multiHoverAudio', 'Play audio while hovering a tile', 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: 'More', groups: [ { title: 'This panel', items: [ ['showLauncher', 'Show the floating button', null], ['edgeSwipe', 'Open by swiping from the right edge', null] ] }, { title: 'Advanced', items: [ ['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 '<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 === 'look') return '<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 '<h3>When this room is usually live</h3><div id="cbx-sched"></div>' + '<div id="cbx-blocked-list"></div><div id="cbx-cc-list"></div>'; if (id === 'chat') return '<h3>Highlight keywords</h3><textarea class="cbx-ta" id="cbx-chat-kw" rows="2" placeholder="one per line or comma-separated">' + esc(S.chatKeywords || '') + '</textarea>' + '<h3>Muted users</h3><textarea class="cbx-ta" id="cbx-chat-mute" rows="3" placeholder="usernames, one per line">' + esc((S.chatMuted || []).join('\n')) + '</textarea>' + '<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>' + '<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) + '<div id="cbx-alert-list"></div>'; if (id === 'panel') return '<h3>Language and backup</h3><small class="cbx-note">Settings are kept in this browser\'s storage for chaturbate.com. They survive reloads and updates of the script, not a clear of site data or a different browser — Export makes a file you can Import anywhere.</small>' + 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" id="cbx-recommended">Apply recommended settings</button>' + '<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, multiDockEl = null; var GRID_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h6.5v6.5H4zm9.5 0H20v6.5h-6.5zM4 13.5h6.5V20H4zm9.5 0H20V20h-6.5z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg>'; var GRID_ADD_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 3h8v8H3zM3 13h8v8H3zm10 0h8v8h-8z"/><path d="M17 3v8M13 7h8" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>'; var GRID_IN_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 3h8v8H3zM3 13h8v8H3zm10 0h8v8h-8z"/><path d="M13.5 7l2.5 2.5L21 4" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'; 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) + '<button class="cbx-b cbx-b-wide cbx-b-quiet cbx-tab-reset" data-reset-tab="' + t.id + '">Reset this tab</button></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">' + VERSION + '</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); // multi cam: one button adds or removes this room, the other opens the viewer (with the count) multiDockEl = el('span', { id: 'cbx-multidock' }, '<button type="button" id="cbx-multi-toggle" title="Add to multi cam">' + GRID_ADD_SVG + '</button>' + '<button type="button" id="cbx-multi-open" title="Open the multi cam viewer">' + GRID_SVG + '<b></b></button>'); $('#cbx-multi-toggle', multiDockEl).addEventListener('click', function (e) { e.preventDefault(); var user = roomName(); if (!user) return; var list = jsonGet(MULTI_KEY, []), i = list.indexOf(user); if (i === -1) { list.push(user); toast('Added to multi cam (' + list.length + ')'); } else { list.splice(i, 1); toast('Removed from multi cam'); } jsonSet(MULTI_KEY, list); placeDock(); }); $('#cbx-multi-open', multiDockEl).addEventListener('click', function (e) { e.preventDefault(); window.open(multiUrl(), '_blank'); }); document.body.appendChild(multiDockEl); 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-datasaver') { var ds = e.target.value; S.dataSaver = ds === 'on'; S.dataSaverAuto = ds === 'auto'; save(); onSettingChanged('dataSaver'); return; } if (e.target.id === 'cbx-bgtabs') { var bg = e.target.value; S.inactivePause = bg === 'pause'; S.inactiveQuality = bg === 'quality'; save(); return; } if (e.target.id === 'cbx-previews') { var pv = e.target.value; S.hoverPreview = pv !== 'off'; S.previewInline = pv === 'inline'; save(); onSettingChanged('hoverPreview'); onSettingChanged('previewInline'); return; } if (e.target.id === 'cbx-quality-cap') { S.qualityCap = parseInt(e.target.value, 10); save(); applyQuality(); 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-chat-font') { S.chatFont = parseInt(e.target.value, 10) || 0; save(); applyChatCSS(); return; } if (e.target.id === 'cbx-chat-kw') { S.chatKeywords = e.target.value; save(); rescanChat(); return; } if (e.target.id === 'cbx-chat-mute') { S.chatMuted = e.target.value.split(/[,\n]/).map(function (u) { return u.trim().toLowerCase(); }).filter(Boolean); save(); rescanChat(); return; } if (e.target.id === 'cbx-grid') { S.gridSize = parseInt(e.target.value, 10); save(); applySiteCSS(); return; } if (e.target.id === 'cbx-grid-more') { S.moreGridSize = 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-site-keep') { S.siteKeep = parseInt(e.target.value, 10) || 300; siteDvr.halvings = 0; if (P.eng) { var ct2 = P.eng.active(); if (ct2) fitBufferToMemory(ct2); } save(); refreshPanel(); 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-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-siteplayer', panelEl).addEventListener('click', backToSitePlayer); $('#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-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(multiUrl(), '_blank'); }); $('#cbx-export', panelEl).addEventListener('click', exportAll); $('#cbx-import', panelEl).addEventListener('click', function () { $('#cbx-import-file', panelEl).click(); }); $('#cbx-import-file', panelEl).addEventListener('change', importAll); // per-tab reset: every switch on the tab back to its default, plus the selects it carries var SEL_KEYS = { 'cbx-site-keep': ['siteKeep'], 'cbx-quality-cap': ['qualityCap'], 'cbx-datasaver': ['dataSaver', 'dataSaverAuto'], 'cbx-bgtabs': ['inactivePause', 'inactiveQuality'], 'cbx-previews': ['hoverPreview', 'previewInline'], 'cbx-tr-lang': ['translateTo'], 'cbx-chat-font': ['chatFont'], 'cbx-grid': ['gridSize'], 'cbx-grid-more': ['moreGridSize'], 'cbx-multi-quality': ['multiMaxHeight'], 'cbx-random-count': ['randomCount'], 'cbx-alert-every': ['alertEvery'], 'cbx-uilang': ['uiLang'] }; panelEl.addEventListener('click', function (e) { var b = e.target.closest('[data-reset-tab]'); if (!b) return; var tab = TABS.filter(function (t) { return t.id === b.getAttribute('data-reset-tab'); })[0], pane = b.closest('.cbx-pane'); if (!tab || !pane) return; var keys = []; tab.groups.forEach(function (g) { g.items.forEach(function (it) { if (typeof it !== 'string') keys.push(it[0]); }); }); $$('select.cbx-sel', pane).forEach(function (s) { (SEL_KEYS[s.id] || []).forEach(function (k) { keys.push(k); }); }); var changed = []; keys.forEach(function (k) { if (k in DEFAULTS && JSON.stringify(S[k]) !== JSON.stringify(DEFAULTS[k])) { S[k] = JSON.parse(JSON.stringify(DEFAULTS[k])); changed.push(k); } }); save(); $$('select.cbx-sel', pane).forEach(function (s) { var ks = SEL_KEYS[s.id]; if (ks && ks.length === 1) s.value = String(S[ks[0]]); }); refreshPanel(); applySiteCSS(); applyDark(); applyChatCSS(); decorateCards(); changed.forEach(onSettingChanged); toast(changed.length ? 'Reset ' + changed.length + ' setting' + (changed.length === 1 ? '' : 's') : 'Already at defaults'); }); $('#cbx-recommended', panelEl).addEventListener('click', function () { if (!confirm('Set every option back to the recommended defaults? Notes, hidden cams and alerts are kept.')) return; var keepKeys = ['uiLang', 'showLauncher', 'randomGenders', 'randomCount']; for (var k in DEFAULTS) if (keepKeys.indexOf(k) === -1) S[k] = DEFAULTS[k]; save(); applySiteCSS(); applyDark(); decorateCards(); refreshPanel(); toast('Recommended settings applied'); }); $('#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, CC_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 === 'siteDvr') { siteDvr.halvings = 0; removeDvr(); if (!S.siteDvr) clearSiteClock(); playerTick(); toast(S.siteDvr ? 'Rewind on' : 'Rewind off'); } if (k === 'dataSaver' || k === 'dataSaverAuto') { if (P.eng && P.armed) pinTrack(bestTrackUnder(dvrCap())); updatePlayerToggle(); } if (k === 'statsOverlay') { var so = $('#cbx-stats'); if (so && !S.statsOverlay) so.remove(); updateBar(); } if (k === 'fsAutoHide') armIdleHide(); if (k === 'chatTipsOnly') applyChatCSS(); if (k === 'tipMarks') updateBar(); 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' || k === 'multiCam') placeLauncher(); if (k === 'randomLink') ensureRandomLink(); if (k === 'bgMute' && S.bgMute && document.hidden) applyBgMute(true); if (k === 'forceDark') { if (S.forceDark) applyDark(); else { document.body.classList.remove('darkmode'); document.documentElement.classList.remove('darkmode'); } } if (k === 'showDuration') installDuration(); 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() { removeTick('duration'); var old = $('#cbx-duration'); if (!S.showDuration || !roomName()) { if (old) old.remove(); return; } addTick('duration', 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 (!host._cbxPosChecked) { host._cbxPosChecked = true; if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; } var badge = $('#cbx-duration'); if (!badge) { badge = el('div', { id: 'cbx-duration' }); host.appendChild(badge); } var txt = fmtTime(v.currentTime); if (badge._txt !== txt) { badge._txt = txt; badge.textContent = txt; } }, 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 setSel = function (id, v) { var s = $('#' + id, panelEl); if (s && s.value !== v) s.value = v; }; setSel('cbx-datasaver', S.dataSaver ? 'on' : S.dataSaverAuto ? 'auto' : 'off'); setSel('cbx-bgtabs', S.inactivePause ? 'pause' : S.inactiveQuality ? 'quality' : 'play'); setSel('cbx-previews', !S.hoverPreview ? 'off' : S.previewInline ? 'inline' : 'box'); 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 sn = $('#cbx-sitedvr-note', panelEl); if (sn) sn.textContent = siteDvrNote(); 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') : ''; var ccs = hiddenCountries(), cbox = $('#cbx-cc-list', panelEl); if (cbox) cbox.innerHTML = ccs.length ? '<p class="cbx-note">Hidden countries</p>' + listHTML(ccs.map(countryName), 'cc') : ''; $$('[data-un]', panelEl).forEach(function (b) { b.addEventListener('click', function () { var p = b.getAttribute('data-un').split(':'); var key = { sel: HIDE_KEY, alert: WATCH_KEY, cc: CC_KEY }[p[0]] || 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, []), countries: hiddenCountries(), 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.countries) jsonSet(CC_KEY, d.countries); 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); // clamp to the visual viewport so a saved spot never ends up under the browser's toolbar var vv = window.visualViewport, vw = vv ? vv.width : innerWidth, vh = vv ? vv.height : innerHeight; var pad = isTouch() ? 72 : 12; if (p && typeof p.x === 'number') { launcherEl.style.left = Math.min(Math.max(p.x, 4), vw - 44) + 'px'; launcherEl.style.top = Math.min(Math.max(p.y, 4), vh - 44 - (isTouch() ? 28 : 0)) + 'px'; launcherEl.style.right = 'auto'; launcherEl.style.bottom = 'auto'; } else { launcherEl.style.left = 'auto'; launcherEl.style.top = 'auto'; launcherEl.style.right = '12px'; launcherEl.style.bottom = 'calc(' + pad + 'px + env(safe-area-inset-bottom, 0px))'; } placeDock(); } // the on/off pill sits beside the gear and follows it around function placeDock() { if (!dockEl || !launcherEl) return; var inRoom = !!roomName(), show = inRoom && S.showLauncher; dockEl.style.display = show ? 'inline-flex' : 'none'; 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; if (show) { updatePlayerToggle(); dockEl.style.left = Math.round(left) + 'px'; dockEl.style.top = Math.round(r.top + (r.height - h) / 2) + 'px'; } if (!multiDockEl) return; // the viewer button shows on every page; the add/remove button only where there is a room to add var mshow = S.showLauncher && !!S.multiCam && !IS_MULTI && !WATCH_POP; multiDockEl.style.display = mshow ? 'inline-flex' : 'none'; if (!mshow) return; var list = jsonGet(MULTI_KEY, []), tg = $('#cbx-multi-toggle', multiDockEl), op = $('#cbx-multi-open', multiDockEl); tg.style.display = inRoom ? '' : 'none'; if (inRoom) { var inList = list.indexOf(roomName()) !== -1; tg.classList.toggle('cbx-in', inList); tg.innerHTML = inList ? GRID_IN_SVG : GRID_ADD_SVG; tg.title = inList ? 'In multi cam — tap to remove' : 'Add to multi cam'; } op.lastChild.textContent = list.length ? String(list.length) : ''; op.classList.toggle('cbx-has', !!list.length); op.title = list.length ? 'Open the multi cam viewer (' + list.length + ' room' + (list.length === 1 ? '' : 's') + ')' : 'Open the multi cam viewer'; var mw = multiDockEl.offsetWidth || (inRoom ? 88 : 40), anchorLeft = show ? left : r.left, anchorRight = show ? left + w : r.right; var mleft = anchorLeft - mw - 8; if (mleft < 4) mleft = anchorRight + 8; multiDockEl.style.left = Math.round(mleft) + 'px'; multiDockEl.style.top = Math.round(r.top + (r.height - 40) / 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, ddEl, $('#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); armIdleHide(); }); }); // fullscreen: controls fade after a few seconds without input var idleTimer = null; function armIdleHide(delay) { clearTimeout(idleTimer); var b = P.block; if (!b) return; var overlay = b.classList.contains('cbx-overlay'), fsEl = document.fullscreenElement || document.webkitFullscreenElement; var fs = fsEl === b || (!!fsEl && fsEl === P.box); kickSmooth(); b.classList.remove('cbx-idle'); if (!(fs && S.fsAutoHide) && !overlay) return; idleTimer = setTimeout(function () { if (P.block !== b) return; var scrub = $('#cbx-scrub', b), bar = $('#cbx-bar', b), v = activeVideo(); if (scrub && scrub._held) return; if (overlay && !fs) { if (v && v.paused) return; try { if (bar && bar.matches(':hover')) return; } catch (e) {} } b.classList.add('cbx-idle'); }, delay || 3000); } ['pointermove', 'pointerdown', 'touchstart', 'keydown'].forEach(function (ev) { document.addEventListener(ev, function () { if (!P.block) return; if (document.fullscreenElement === P.block || document.webkitFullscreenElement === P.block || ev === 'keydown') armIdleHide(); }, { capture: true, passive: true }); }); 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; // periodic UI work shares one timer; everything pauses while the tab is hidden var TICKS = []; function addTick(id, fn, every) { removeTick(id); TICKS.push({ id: id, fn: fn, every: every, last: 0 }); } function removeTick(id) { TICKS = TICKS.filter(function (t) { return t.id !== id; }); } var CHAT_MSG = 'div[data-testid="chat-message"]'; var CHAT_SEL = '[data-testid="chat-messages"],[data-testid="chat-list"],.chat-list,.msg-list-fvm,#ChatTabContents,.message-list'; var chatTick = null; function scheduleChat() { clearTimeout(chatTick); chatTick = setTimeout(processChat, 250); } function whenIdle(fn, timeout) { if (window.requestIdleCallback) requestIdleCallback(function () { fn(); }, { timeout: timeout || 500 }); else setTimeout(fn, 0); } function scheduleWork() { clearTimeout(tick); tick = setTimeout(function () { whenIdle(decorateCards, 500); ensureRandomLink(); autoAcceptRules(); processChat(); if (S.inlinePreview) $$('video:not([playsinline])').forEach(tagInline); playerTick(); renderBioInfo(); applyTransform(); applyAudioChain(); }, 180); } function onNavigate() { if (ddOpen) closeDD(); if (recording()) { toast('Room changed — recording saved'); stopRecording(); } 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 ' + VERSION + ' loaded on ' + location.pathname); } catch (e) {} installInlinePreview(); installSoundBlock(); installSiteDvr(); installBgMute(); applyDark(); applySiteCSS(); onReady(function () { if (IS_MULTI) { buildMulti(); return; } if (WATCH_POP) { document.head.appendChild(el('style', null, UI_CSS)); document.title = WATCH_POP; openWatchOnly(WATCH_POP, true); return; } applyTouchClass(); applySiteCSS(); buildUI(); installHoverPreview(); hookHistory(); installDuration(); decorateCards(); renderBioInfo(); installExclusiveAudio(); installErrorQuality(); installInactiveLoad(); startAlerts(); startQualityWatchdog(); setTimeout(applyQuality, 2500); playerTick(); applyTipMute(0); addTick('bar', function () { updateBar(); captureThumb(); watchSiteRebuild(); refreshSiteDvrNote(); }, 1000); addTick('heartbeat', siteHeartbeat, 2000); watchRotation(); (function pump() { var scrub = $('#cbx-scrub'), held = scrub && scrub._held, now = Date.now(); if (!document.hidden) { for (var i = 0; i < TICKS.length; i++) { var t = TICKS[i]; if (held && t.id === 'bar' ? true : now - t.last >= t.every - 20) { t.last = now; try { t.fn(); } catch (e) { log('tick', t.id, e && e.message); } } } } setTimeout(pump, held ? 250 : 1000); })(); installKeys(); watchEnvSaver(); var rsz = null; window.addEventListener('resize', function () { clearTimeout(rsz); rsz = setTimeout(function () { applyTouchClass(); applyTransform(); }, 300); }); // warm hls.js once the page settles so the first hover preview does not wait for it if (S.hoverPreview && canUseMse()) whenIdle(function () { loadHlsJs().catch(function () {}); }, 4000); new MutationObserver(function (muts) { // chat rooms mutate several times a second; only new messages come from // there, so keep the grid/player work for changes outside the chat var chatOnly = true; for (var i = 0; i < muts.length && chatOnly; i++) { var m = muts[i]; if (m.target.nodeType === 1 && m.target.closest(CHAT_SEL)) continue; for (var j = 0; j < m.addedNodes.length; j++) { var n = m.addedNodes[j]; if (n.nodeType !== 1) continue; if (n.matches(CHAT_MSG) || n.closest(CHAT_SEL)) continue; chatOnly = false; break; } if (m.addedNodes.length === 0 && m.target.nodeType === 1 && !m.target.closest(CHAT_SEL)) { // removals outside chat (e.g. cards, player) still matter chatOnly = false; } } if (chatOnly) scheduleChat(); else scheduleWork(); }).observe(document.body, { childList: true, subtree: true }); }); })();