ライブ配信用オールインワンツール:複数ルーム表示、分割録画、自動再接続、通知、スクリーンショット、再生操作、共有、一時URL、設定管理。
// ==UserScript== // @name Chaturbate MultiCam Pro // @name:zh-CN Chaturbate 多开直播窗口 // @namespace https://github.com/ryujo/roomgrid-multicam-pro // @version 15.9-enhancer // @description All-in-one livestream workstation tool: multi-room grid/focus layouts, resilient segmented recording, smart reconnect, alerts, screenshots, playback controls, sharing, temporary URLs, and config tools. // @description:de All-in-one-Livestream-Workstation: Multi-Room-Layouts, segmentierte Aufnahme, Reconnect, Warnungen, Screenshots, Wiedergabe, Teilen, temporäre URLs und Konfigurations-Tools. // @description:es Herramienta todo en uno para directos: cuadrícula/enfoque multisalón, grabación segmentada, reconexión, alertas, capturas, reproducción, compartir, URL temporales y configuración. // @description:es-CO Herramienta todo en uno para directos: cuadrícula/enfoque multisalón, grabación segmentada, reconexión, alertas, capturas, reproducción, compartir, URL temporales y configuración. // @description:it Strumento tutto in uno per livestream: layout multi-room, registrazione segmentata, riconnessione, avvisi, screenshot, riproduzione, condivisione, URL temporanei e configurazione. // @description:fr Outil tout-en-un pour livestreams : vues multi-salons, enregistrement segmenté, reconnexion, alertes, captures, lecture, partage, URL temporaires et configuration. // @description:fr-CA Outil tout-en-un pour livestreams : vues multi-salons, enregistrement segmenté, reconnexion, alertes, captures, lecture, partage, URL temporaires et configuration. // @description:ru Универсальный инструмент для livestream: сетка/фокус нескольких комнат, сегментная запись, переподключение, оповещения, снимки, воспроизведение, ссылки и настройки. // @description:tr Can yayınlar için hepsi bir arada araç: çoklu oda düzenleri, parçalı kayıt, yeniden bağlanma, uyarılar, ekran görüntüleri, oynatma, paylaşım ve ayarlar. // @description:ro Instrument all-in-one pentru livestream: layout multi-room, înregistrare segmentată, reconectare, alerte, capturi, redare, partajare, URL temporare și configurare. // @description:no Alt-i-ett-verktøy for direktestrømmer: multirom-oppsett, segmentert opptak, tilkobling på nytt, varsler, skjermbilder, avspilling, deling og innstillinger. // @description:nl Alles-in-één livestreamtool: multi-room raster/focus, gesegmenteerde opname, opnieuw verbinden, meldingen, screenshots, afspelen, delen, tijdelijke URL's en configuratie. // @description:pl Wszechstronne narzędzie livestream: układy wielu pokoi, nagrywanie segmentowe, ponowne łączenie, alerty, zrzuty, odtwarzanie, udostępnianie i konfiguracja. // @description:ja ライブ配信用オールインワンツール:複数ルーム表示、分割録画、自動再接続、通知、スクリーンショット、再生操作、共有、一時URL、設定管理。 // @description:el Εργαλείο livestream όλα σε ένα: πολλαπλές αίθουσες, τμηματική εγγραφή, επανασύνδεση, ειδοποιήσεις, στιγμιότυπα, αναπαραγωγή, κοινή χρήση και ρυθμίσεις. // @description:hu Minden egyben livestream eszköz: több szoba elrendezése, szegmenses felvétel, újracsatlakozás, riasztások, képernyőképek, lejátszás, megosztás és beállítások. // @description:fi All-in-one-livestream-työkalu: monihuoneasettelut, segmentoitu tallennus, uudelleenyhdistys, hälytykset, kuvakaappaukset, toisto, jakaminen ja asetukset. // @description:ar أداة بث مباشر شاملة: تخطيطات غرف متعددة، تسجيل مقسم، إعادة اتصال ذكية، تنبيهات، لقطات شاشة، تشغيل، مشاركة، روابط مؤقتة وأدوات إعداد. // @description:hi लाइवस्ट्रीम के लिए ऑल-इन-वन टूल: मल्टी-रूम लेआउट, सेगमेंट रिकॉर्डिंग, रीकनेक्ट, अलर्ट, स्क्रीनशॉट, प्लेबैक, शेयरिंग, अस्थायी URL और कॉन्फिग। // @description:id Alat livestream serba bisa: tata letak multi-room, rekaman tersegmentasi, sambung ulang, peringatan, screenshot, pemutaran, berbagi, URL sementara, dan konfigurasi. // @description:ko 라이브스트림 올인원 도구: 다중 방 레이아웃, 분할 녹화, 재연결, 알림, 스크린샷, 재생 제어, 공유, 임시 URL 및 설정 도구. // @description:pt-PT Ferramenta tudo-em-um para livestream: layouts multi-sala, gravação segmentada, reconexão, alertas, capturas, reprodução, partilha, URLs temporários e configuração. // @description:pt-BR Ferramenta tudo em um para livestream: layouts multi-sala, gravação segmentada, reconexão, alertas, capturas, reprodução, compartilhamento, URLs temporários e configuração. // @description:zh 一体化直播工作台工具:多房间网格/主屏布局、分段录制、智能重连、提醒、截图、播放控制、分享、临时 URL 和配置管理。 // @description:zh-CN 一体化直播工作台工具:多房间网格/主屏布局、分段录制、智能重连、提醒、截图、播放控制、分享、临时 URL 和配置管理。 // @description:zh-TW 一體化直播工作台工具:多房間網格/主屏佈局、分段錄製、智慧重連、提醒、截圖、播放控制、分享、臨時 URL 和設定管理。 // @description:cs Univerzální nástroj pro livestream: rozvržení více místností, segmentované nahrávání, opětovné připojení, upozornění, snímky, přehrávání, sdílení a nastavení. // @description:sk Univerzálny nástroj pre livestream: rozloženia viacerých miestností, segmentované nahrávanie, opätovné pripojenie, upozornenia, snímky, prehrávanie, zdieľanie a nastavenia. // @description:sl Vsestransko orodje za livestream: večsobne postavitve, segmentno snemanje, ponovna povezava, opozorila, posnetki zaslona, predvajanje, deljenje in nastavitve. // @description:sv Allt-i-ett-verktyg för livestream: flerrumslayouter, segmenterad inspelning, återanslutning, varningar, skärmbilder, uppspelning, delning och inställningar. // @description:sr Sve-u-jednom alat za livestream: rasporedi više soba, segmentirano snimanje, ponovno povezivanje, upozorenja, snimci ekrana, reprodukcija, deljenje i podešavanja. // @description:af Alles-in-een livestream-nutsding: veelkamer-uitlegte, gesegmenteerde opname, herkoppeling, waarskuwings, skermskote, afspeel, deel, tydelike URL's en instellings. // @description:sq Mjet gjithëpërfshirës për livestream: pamje me shumë dhoma, regjistrim me segmente, rilidhje, njoftime, pamje ekrani, riprodhim, ndarje dhe konfigurim. // @description:hy Լայվսթրիմի համապարփակ գործիք՝ բազմասենյակ դասավորություններ, հատվածային ձայնագրում, վերամիացում, ծանուցումներ, սքրինշոթեր, նվագարկում, կիսում և կարգավորումներ։ // @description:be Універсальны інструмент для livestream: некалькі пакояў, сегментаваны запіс, паўторнае падключэнне, абвесткі, здымкі экрана, прайграванне, абмен і налады. // @description:bg Универсален инструмент за livestream: многостаен изглед, сегментиран запис, повторно свързване, известия, снимки, възпроизвеждане, споделяне и настройки. // @description:da Alt-i-et livestream-værktøj: multirums-layouts, segmenteret optagelse, genforbindelse, advarsler, skærmbilleder, afspilning, deling, midlertidige URL'er og opsætning. // @description:et Kõik-ühes livestreami tööriist: mitme ruumi paigutused, segmenditud salvestus, taasühendus, teavitused, kuvatõmmised, taasesitus, jagamine ja seaded. // @description:he כלי סטרימינג הכל-באחד: פריסות מרובות חדרים, הקלטה מחולקת, חיבור מחדש, התראות, צילומי מסך, הפעלה, שיתוף, כתובות זמניות והגדרות. // @description:hr Sve-u-jednom alat za livestream: rasporedi više soba, segmentirano snimanje, ponovno povezivanje, upozorenja, snimke zaslona, reprodukcija, dijeljenje i postavke. // @description:fa ابزار جامع پخش زنده: چیدمان چند اتاق، ضبط بخشبندیشده، اتصال مجدد، هشدارها، اسکرینشات، پخش، اشتراکگذاری، URL موقت و تنظیمات. // @description:ur لائیو اسٹریم کے لیے آل اِن ون ٹول: ملٹی روم لے آؤٹ، سیگمنٹڈ ریکارڈنگ، دوبارہ کنکشن، الرٹس، اسکرین شاٹس، پلے بیک، شیئرنگ اور سیٹنگز۔ // @description:bn লাইভস্ট্রিমের অল-ইন-ওয়ান টুল: মাল্টি-রুম লেআউট, সেগমেন্টেড রেকর্ডিং, রিকানেক্ট, অ্যালার্ট, স্ক্রিনশট, প্লেব্যাক, শেয়ারিং ও সেটিংস। // @description:th เครื่องมือไลฟ์สตรีมแบบครบวงจร: เลย์เอาต์หลายห้อง การบันทึกแบบแบ่งช่วง การเชื่อมต่อใหม่ การแจ้งเตือน ภาพหน้าจอ การเล่น การแชร์ และการตั้งค่า // @description:eo Ĉio-en-unu livestream-ilo: plurĉambraj aranĝoj, segmenta registrado, rekonekto, atentigoj, ekrankopioj, reprodukto, kunhavigo, portempaj URL-oj kaj agordoj. // @description:ug بىردە ھەممىسى بار livestream قورالى: كۆپ ھۇجرىلىق كۆرۈنۈش، بۆلەكلىك خاتىرىلەش، قايتا ئۇلاش، ئاگاھلاندۇرۇش، ئېكران سۈرىتى، قويۇش، ھەمبەھىرلەش ۋە تەڭشەكلەر. // @description:vi Công cụ livestream tất cả trong một: bố cục nhiều phòng, ghi theo phân đoạn, kết nối lại, cảnh báo, ảnh chụp màn hình, phát lại, chia sẻ, URL tạm thời và cấu hình. // @author RYUJO // @license MIT // @match https://chaturbate.com/* // @match https://*.chaturbate.com/* // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js // @grant none // @run-at document-end // ==/UserScript== /* * MIT License * * Copyright (c) 2026 RYUJO * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ (function () { 'use strict'; // v9.0: 防止脚本被重复粘贴 / 重复安装时同页运行两次。 // 你上传的 enhancer.txt 是两个相同脚本拼在一起;没有这个保护会导致 UI、轮询、HLS 实例重复。 const INSTANCE_KEY = '__roomGridMultiCamWorkstationRunning'; if (window[INSTANCE_KEY]) { try { console.warn('[RoomGrid] duplicate userscript instance blocked'); } catch (_) {} return; } window[INSTANCE_KEY] = true; /* ============================================================= * 0. 工具层 / Utils * ============================================================= */ const $ = (tag, props = {}, children = []) => { const el = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (k === 'style' && typeof v === 'object') Object.assign(el.style, v); else if (k === 'class') el.className = v; else if (k === 'dataset') Object.assign(el.dataset, v); else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v); else if (k === 'html') setTrustedHtml(el, v); else if (k in el) el[k] = v; else el.setAttribute(k, v); } for (const c of [].concat(children)) { if (c == null || c === false) continue; el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } const title = typeof props.title === 'string' ? props.title.trim() : ''; if (title) setElementHint(el, title); return el; }; function setElementHint(el, text) { if (!el || !text) return el; const v = String(text).trim(); if (!v) return el; try { el.title = v; } catch (_) {} try { el.dataset.hint = v; } catch (_) {} try { if (/^(BUTTON|INPUT|SELECT|TEXTAREA)$/i.test(el.tagName || '')) { el.setAttribute('aria-label', v); } } catch (_) {} return el; } function htmlEscape(s) { return String(s ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[ch])); } const TRUSTED_HTML_MARK = '__roomgridTrustedHtml'; function trustedHtml(html) { return { [TRUSTED_HTML_MARK]: true, html: String(html ?? '') }; } function setTrustedHtml(el, value) { if (!el) return; if (value && typeof value === 'object' && value[TRUSTED_HTML_MARK] === true) el.innerHTML = value.html; else el.textContent = String(value ?? ''); } const ICONS = { menu: '<path d="M5 7h14M5 12h14M5 17h14"/>', refresh: '<path d="M20 11a8 8 0 0 0-14.2-5M4 5v5h5"/><path d="M4 13a8 8 0 0 0 14.2 5M20 19v-5h-5"/>', pause: '<path d="M8 5v14M16 5v14"/>', play: '<path d="M8 5v14l11-7z"/>', volume: '<path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M16 8a5 5 0 0 1 0 8"/>', volumeOff: '<path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M18 9l-4 4M14 9l4 4"/>', camera: '<path d="M4 8h4l2-3h4l2 3h4v13H4z"/><circle cx="12" cy="14" r="4"/>', record: '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>', stop: '<rect x="8" y="8" width="8" height="8" rx="1" fill="currentColor" stroke="none"/>', pip: '<rect x="4" y="5" width="16" height="14" rx="2"/><rect x="12" y="12" width="6" height="4" rx="1"/>', expand: '<path d="M8 4H4v4M16 4h4v4M8 20H4v-4M20 16v4h-4"/>', more: '<circle cx="6" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="18" cy="12" r="1.5" fill="currentColor" stroke="none"/>', close: '<path d="M18 6 6 18M6 6l12 12"/>', drag: '<path d="M8 7h8M8 12h8M8 17h8"/>', resize: '<path d="M9 15h6V9M7 21h12V9"/>', grid: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>', focus: '<rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8v6H8z"/>', clean: '<path d="M4 12s3-6 8-6 8 6 8 6-3 6-8 6-8-6-8-6z"/><circle cx="12" cy="12" r="2"/>', search: '<circle cx="10" cy="10" r="6"/><path d="M15 15l5 5"/>', bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 7h18s-3 0-3-7"/><path d="M10 20a2 2 0 0 0 4 0"/>', external: '<path d="M14 4h6v6"/><path d="M10 14 20 4"/><path d="M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5"/>', copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V6a2 2 0 0 1 2-2h10"/>', folder: '<path d="M4 7h7l2 2h7v11H4z"/>', star: '<path d="m12 4 2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4-3.9-3.8 5.4-.8z"/>', trash: '<path d="M5 7h14M10 11v6M14 11v6M8 7l1-3h6l1 3M7 7l1 14h8l1-14"/>', }; function iconSvg(name, size = 16) { const body = ICONS[name] || ICONS.more; return `<svg class="svg-icon" width="${size}" height="${size}" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`; } function iconLabel(name, text, size = 15) { return `${iconSvg(name, size)}<span class="btn-label">${htmlEscape(text)}</span>`; } const debounce = (fn, ms = 200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; const fmtTime = (ts) => { if (!ts) return '—'; const diff = (Date.now() - ts) / 1000; const ago = (n, u) => LANG === 'zh' ? `${n}${u} 前` : `${n}${u} ago`; if (diff < 60) return ago(Math.floor(diff), 's'); if (diff < 3600) return ago(Math.floor(diff / 60), 'm'); if (diff < 86400) return ago(Math.floor(diff / 3600), 'h'); return ago(Math.floor(diff / 86400), 'd'); }; const uuid = () => { try { if (crypto?.randomUUID) return 'g_' + crypto.randomUUID().replace(/-/g, '').slice(0, 10); if (crypto?.getRandomValues) { const buf = new Uint32Array(2); crypto.getRandomValues(buf); return 'g_' + [...buf].map(n => n.toString(36)).join('').slice(0, 10); } } catch (_) {} return 'g_' + Math.random().toString(36).slice(2, 12); }; const LIBRARY_GROUP_ID = 'library'; const DEFAULT_GROUP_ID = 'all'; const ONLINE_GROUP_ID = 'online'; const FAVORITE_GROUP_ID = 'fav'; // v15.5: 稳定状态。多工作台/多窗口同时轮询时,不再用 loading / transient error 覆盖 // 已确认的 online/offline/private,避免筛选、分页和 HLS 反复重建造成闪屏。 const STABLE_ROOM_STATUSES = new Set(['online', 'offline', 'private']); function isStableRoomStatus(status) { return STABLE_ROOM_STATUSES.has(String(status || '')); } function isTransientRoomStatus(status) { return status === 'loading' || status === 'error'; } function defaultShortcuts() { return { focusAdd: '/', refreshAll: 'r', gridView: 'g', focusView: 'f', pureMode: 'alt+p', viewerMode: 'alt+v', focusThumbs: 'alt+t', recordingCenter: 'alt+shift+c', recordPage: 'alt+shift+r', }; } function normalizeShortcutSpec(value) { const raw = String(value || '').trim().toLowerCase().replace(/\s+/g, ''); if (!raw) return ''; const parts = raw.split('+').filter(Boolean); const mods = new Set(); let key = ''; for (const part of parts) { if (part === 'control') mods.add('ctrl'); else if (part === 'cmd' || part === 'command') mods.add('meta'); else if (['ctrl', 'meta', 'alt', 'shift'].includes(part)) mods.add(part); else key = part === ' ' ? 'space' : part; } if (!key) return ''; const out = []; ['ctrl', 'meta', 'alt', 'shift'].forEach(m => { if (mods.has(m)) out.push(m); }); out.push(key); return out.join('+'); } function sanitizeShortcuts(input, fallback = defaultShortcuts()) { const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {}; const out = {}; for (const [action, spec] of Object.entries(fallback)) { out[action] = normalizeShortcutSpec(source[action]) || normalizeShortcutSpec(spec); } return out; } function shortcutFromEvent(e) { const keyRaw = e.code === 'Space' ? 'space' : String(e.key || '').toLowerCase(); const key = keyRaw === ' ' ? 'space' : keyRaw; if (!key || ['control', 'shift', 'alt', 'meta'].includes(key)) return ''; const out = []; if (e.ctrlKey) out.push('ctrl'); if (e.metaKey) out.push('meta'); if (e.altKey) out.push('alt'); if (e.shiftKey) out.push('shift'); out.push(key); return normalizeShortcutSpec(out.join('+')); } function shortcutLabel(spec) { const normalized = normalizeShortcutSpec(spec); if (!normalized) return ''; const names = { ctrl: 'Ctrl', meta: 'Meta', alt: 'Alt', shift: 'Shift', space: 'Space', arrowleft: 'Left', arrowright: 'Right', arrowup: 'Up', arrowdown: 'Down' }; return normalized.split('+').map(part => names[part] || (part.length === 1 ? part.toUpperCase() : part)).join('+'); } function defaultVideoTransform() { return { mirror: false, flip: false, rotation: 0, zoom: 1, x: 0, y: 0 }; } function sanitizeVideoTransform(input) { const src = input && typeof input === 'object' && !Array.isArray(input) ? input : {}; const rotationRaw = Number(src.rotation) || 0; const rotation = ((Math.round(rotationRaw / 90) * 90) % 360 + 360) % 360; return { mirror: !!src.mirror, flip: !!src.flip, rotation, zoom: Math.max(1, Math.min(20, Number(src.zoom) || 1)), x: Math.max(-5000, Math.min(5000, Number(src.x) || 0)), y: Math.max(-5000, Math.min(5000, Number(src.y) || 0)), }; } function isDefaultVideoTransform(transform) { const t = sanitizeVideoTransform(transform); return !t.mirror && !t.flip && t.rotation === 0 && t.zoom === 1 && t.x === 0 && t.y === 0; } function sanitizeVideoTransformMap(input) { const out = {}; if (input && typeof input === 'object' && !Array.isArray(input)) { for (const [id, value] of Object.entries(input)) { const roomId = normalizeUsername(id); if (!roomId) continue; const transform = sanitizeVideoTransform(value); if (!isDefaultVideoTransform(transform)) out[roomId] = transform; } } return out; } function encodeSharePayload(data) { const json = JSON.stringify(data); const bytes = new TextEncoder().encode(json); let bin = ''; for (let i = 0; i < bytes.length; i += 0x8000) { bin += String.fromCharCode(...bytes.slice(i, i + 0x8000)); } return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function decodeSharePayload(data) { const normalized = String(data || '').replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); const bin = atob(padded); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return JSON.parse(new TextDecoder().decode(bytes)); } function normalizeUsername(raw) { let v = String(raw || '').trim(); if (!v) return ''; try { if (/^https?:\/\//i.test(v)) { const url = new URL(v); v = url.pathname.split('/').filter(Boolean)[0] || ''; } } catch (_) {} return v.replace(/^@+/, '').replace(/^\/+|\/+$/g, '').trim().toLowerCase(); } function usernameSyntaxOk(v) { v = normalizeUsername(v); return !!v && v.length >= 2 && v.length <= 32 && /^[a-z0-9_-]+$/i.test(v) && !/^\d+$/.test(v); } function safeGroupName(raw, fallback = '') { const v = String(raw ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim(); return (v || fallback || '').slice(0, 80); } function isSafeHttpUrl(raw, base = location.href) { try { const u = new URL(String(raw || ''), base); return u.protocol === 'https:' || u.protocol === 'http:'; } catch (_) { return false; } } function isSafeStreamUrl(raw) { const s = String(raw || '').trim(); if (!s || s.length > 4096 || /[\u0000-\u001f\u007f]/.test(s)) return false; // Accept only http(s). Do not require a .m3u8 suffix because some CDNs serve playlists through signed routes. return isSafeHttpUrl(s); } function safeChaturbateHost(host) { const h = String(host || '').toLowerCase(); return h === 'chaturbate.com' || h.endsWith('.chaturbate.com'); } async function copyText(text) { text = String(text || ''); try { await navigator.clipboard.writeText(text); return true; } catch (_) { try { const ta = $('textarea', { value: text, style: { position: 'fixed', left: '-9999px', top: '-9999px', opacity: '0' } }); document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok; } catch (_) { return false; } } } function downloadBlob(blob, filename) { const a = $('a', { href: URL.createObjectURL(blob), download: filename }); document.body.appendChild(a); a.click(); setTimeout(() => { try { URL.revokeObjectURL(a.href); a.remove(); } catch (_) {} }, 1200); } function openNoopener(url, target = '_blank') { const safeTarget = target || '_blank'; const w = window.open(String(url || 'about:blank'), safeTarget, 'noopener,noreferrer'); try { if (w) w.opener = null; } catch (_) {} return w; } function safeFilePart(v) { return String(v || 'room').replace(/[^a-z0-9_-]+/gi, '_').slice(0, 48) || 'room'; } function stampForFile() { return new Date().toISOString().replace(/[:.]/g, '-'); } function stopMediaElement(media, remove = false) { if (!media) return; try { media.muted = true; media.volume = 0; media.pause(); } catch (_) {} try { const srcObject = media.srcObject; if (srcObject && typeof srcObject.getTracks === 'function') { srcObject.getTracks().forEach(track => { try { track.stop(); } catch (_) {} }); } } catch (_) {} try { media.srcObject = null; } catch (_) {} try { media.querySelectorAll?.('source').forEach(source => { try { source.removeAttribute('src'); source.src = ''; source.remove(); } catch (_) {} }); } catch (_) {} try { media.removeAttribute('src'); media.src = ''; media.load(); } catch (_) {} if (remove) { try { media.remove(); } catch (_) {} } } function stopAllPageMedia(root = document) { // 当前页打开工作台、新开工作台或整页卸载前,先切断页面上原有的音视频。 try { root.querySelectorAll('video,audio').forEach(media => stopMediaElement(media, false)); } catch (_) {} } /* ============================================================= * 0.5. 国际化 / i18n —— 默认跟随浏览器语言,可手动切换 * ============================================================= */ const I18N = { en: { // ---- workstation chrome ---- title: 'RoomGrid MultiCam Pro', appTagline: 'All-in-one multiview workstation for rooms, recording, alerts, screenshots, reconnects, and data tools.', addPlaceholder: 'Username ↵', searchPlaceholder: 'Search rooms', invalidUsername: 'Invalid username', hideOffline: 'Hide offline', hidePrivate: 'Hide private', onlyOnline: 'Online only', sortLabel: 'Sort', sortManual: 'Manual', sortStatus: 'By status', sortName: 'By name', sortAdded: 'By added time', refreshAll: 'Refresh all', notifyOnline: 'Online alerts', notifyTitle: 'Desktop notification + card flash when a model goes online', collapseSidebar: '', viewGrid: 'Grid', viewFocus: 'Focus', viewModeLabel: 'Layout', moreOps: 'More', pureMode: 'Clean', pureModeOn: 'Clean mode on', pureModeOff: 'Exit clean mode', pureModeHint: 'Temporarily hide all controls and overlays. Shortcut: Alt+P / Alt+C. Press Esc to exit.', viewerMode: 'Window first', viewerModeOn: 'Window-first mode on', viewerModeOff: 'Exit window-first', viewerModeHint: 'Hide the app shell and keep room windows visible. Shortcut: Alt+V.', focusThumbsShow: 'Show thumbnails', focusThumbsHide: 'Hide thumbnails', focusThumbsHint: 'Show or hide the thumbnail rail in Focus mode. Shortcut: Alt+T.', videoFitContain: 'Fit: full image', videoFitCover: 'Fit: fill window', videoFitHint: 'Switch between full image and cropped fill.', playbackSettingsTitle: 'Playback settings', maxStreamHeight: 'Max stream quality', maxQualityAuto: 'Auto / no cap', freeZoomLabel: 'Ctrl/Command + wheel zoom', freeZoomHint: 'Zoom a card with Ctrl/Command + mouse wheel, drag while zoomed, double-click to reset.', pureExitHint: 'Exit clean mode. Shortcut: Alt+P / Alt+C or Esc.', pureExitChip: 'Clean · Alt+P/C / Esc', syncedFromOtherTab: '(synced from another tab)', hintAddInput: 'Type a username and press Enter. Shortcut: /', hintSearchInput: 'Filter visible rooms by username', hintSidebarToggle: 'Show / hide group sidebar', hintVolume: 'Global volume. Per-room mute is still respected.', hintGridSize: 'Base tile size in grid view', hintSort: 'Sort rooms in the current group', hintMainRatio: 'Main-screen height in Focus mode. You can also drag the separator.', hintMainAspect: 'Main-screen aspect ratio in Focus mode', hintThumbSize: 'Thumbnail width in Focus mode', hintGroupTab: (n) => `Switch to ${n}. Drag rooms here to add them to this group.`, hintLibraryTab: 'Show every saved room. The remove button deletes globally in this view.', hintNewGroup: 'Create an isolated group', hintMoreMenu: 'More tools and maintenance', // ---- sidebar ---- groupsHeading: 'GROUPS', groupLibrary: 'All saved', groupAll: 'Default', groupOnline: 'Online now', groupFav: 'Favorites', newGroup: 'New group', newGroupPrompt: 'New group name:', renameGroup: 'Rename', renameGroupPrompt: 'Rename to:', deleteGroup: 'Delete group', deleteGroupConfirm: (n) => `Delete group "${n}"? Rooms stay saved and are only removed from this group.`, statTotal: 'Total', statOnline: 'Online', statMuted: 'Muted', // ---- card ---- opRefresh: 'Refresh', opMuteToggle: 'Toggle mute', opPause: 'Pause', opResume: 'Resume', opScreenshot: 'Screenshot current frame', opRecordStart: 'Record current card', opRecordStop: 'Stop recording', opPiP: 'Picture-in-Picture', opFullscreen: 'Fullscreen', opMoveGroup: 'Add to group', opRemove: 'Remove from current group', opDeleteRoom: 'Delete saved room', opMirror: 'Mirror image', opFlip: 'Flip image', opRotateLeft: 'Rotate left', opRotateRight: 'Rotate right', opResetView: 'Reset view', opOpenRoom: 'Open room', opCopyUsername: 'Copy username', opFavoriteAdd: 'Add favorite', opFavoriteRemove: 'Remove favorite', opMoveToFavorites: 'Move to favorites only', copied: 'Copied', screenshotSaved: 'Screenshot saved', captureFailed: 'Screenshot failed. Browser/CORS may block drawing this stream.', recordingConsent: 'Recording is local and saves this video with its matching audio when the browser exposes it. Use it only when you have permission to save this content. Continue?', recordingStarted: 'Recording started', recordingSaved: 'Recording saved', recordingUnsupported: 'Recording is not supported for this video/browser', recordingSegmentSaved: 'Recording segment saved', recordingFinalSaved: 'Final recording segment saved', recordingPausedSource: 'Recording paused; waiting for the stream to return', recordingResumed: 'Recording resumed', recordingWaiting: 'Recording paused; click to stop', recordingNoData: 'No recording data to save', recordingSettingsSaved: 'Recording settings saved', recordingSegmentPrompt: 'Segment length in minutes (1-180):', recordingBitratePrompt: 'Video bitrate in Mbps (0.5-20):', recordingExitWarnToggle: 'Warn before leaving while recording', recordingExitWarnMessage: 'Recording is still active. Leave anyway?', recordingCenter: 'Recording center', recordingCenterEmpty: 'No active recordings', recordingActive: 'Recording', recordingWaitingShort: 'Waiting for source', recordingSavedSegments: 'Saved segments', recordingDuration: 'Duration', recordingBitrate: 'Bitrate', recordingFormatHint: 'Format: MP4 when supported by this browser; otherwise the best supported fallback.', recordingRecoverPrompt: (n) => `Resume recording intent for ${n} room(s) from the previous session?`, recordCurrentPage: 'Record current page', recordCurrentGroup: 'Record current group', recordOnlineRooms: 'Record online rooms', stopAllRecordings: 'Stop all recordings', showRecordingOnly: 'Show recording only', hideRecordingOnly: 'Show all rooms', batchOpenCurrentPage: 'Open current page rooms', batchMoveCurrentPage: 'Move current page to group', layoutSettings: 'Layout settings', recordingSettingsTitle: 'Recording settings', saveSettings: 'Save settings', backupPanel: 'Config backups', noBackups: 'No config backups found', restoreBackup: 'Restore', deleteBackup: 'Delete', statusHistory: 'Online/offline history', noStatusHistory: 'No history yet', groupRules: 'Group rules', favoriteFirst: 'Keep favorites first', tempUrlManager: 'Temporary URL manager', saveTempUrls: 'Save current temporary URLs', noTempUrls: 'No temporary URLs', sharePanel: 'Share workspace', shareCurrentWorkspace: 'Share current rooms and layout', shareCopyLink: 'Copy share link', shareLinkHint: 'This link restores rooms, groups, and layout settings in another workstation tab.', shareImportPrompt: (n) => `Import shared workspace with ${n} room(s)? Your current config will be backed up first.`, shareImported: 'Shared workspace imported', shareInvalid: 'Shared workspace link is invalid', shortcutPanel: 'Shortcuts', shortcutCaptureHint: 'Click a field and press the new shortcut. Backspace clears that field.', resetShortcuts: 'Reset shortcuts', shortcutFocusAdd: 'Focus add box', shortcutRefreshAll: 'Refresh all', shortcutGridView: 'Grid view', shortcutFocusView: 'Focus view', shortcutPureMode: 'Clean mode', shortcutViewerMode: 'Window-first mode', shortcutFocusThumbs: 'Toggle thumbnails', shortcutRecordingCenter: 'Recording center', shortcutRecordPage: 'Record current page', // ---- statuses ---- stOnline: 'Online', stOffline: 'Offline', stPrivate: 'Private', stLoading: 'Loading', stError: 'Error', stUnknown: 'Unknown', lastSeen: (t) => `Last online ${t}`, autoDetect: 'Auto-detecting…', // ---- empty state ---- emptyTitle: 'No rooms in this group', emptyHint: 'Add a username at the top, switch group, or clear filters', // ---- toasts / floating button ---- addRoom: (n) => `Add ${n}`, alreadyAdded: (n) => ` ${n} already in workstation`, openWorkstation: 'Open workstation', openWorkstationHere: 'Open here (replace page)', openWorkstationHereConfirm: 'Replace this page with the workstation? You can go back via the browser.', memoryStat: (n) => `${n} rooms saved`, memoryView: 'View saved list', collapseFAB: 'Collapse dock', dockSubtitle: 'MultiCam tools', dockCurrentRoom: (n) => `Current: ${n}`, dockNoRoom: 'No room detected', dockOpen: 'Open workstation', dockOpenHere: 'Open here', dockAdd: 'Add room', dockRemove: 'Remove room', dockRecord: 'Record in workstation', dockScreenshot: 'Screenshot video', dockPip: 'Picture-in-Picture', dockPause: 'Play / pause', dockMute: 'Mute / unmute', dockVideoMissing: 'No playable video found on this page', dockRecordQueued: 'Recording intent queued in workstation', added: 'Added', exists: 'Already exists', addFailed: 'Failed', addedNamed: (n) => `${n} added`, removedNamed: (n) => `${n} removed`, quickAddTitle: 'Add to workstation', quickRemoveTitle: 'Already in workstation — click to remove', // ---- notifications ---- notifyTitleText: 'Model online', notifyBody: (n) => `${n} just went live`, permDenied: 'Notification permission denied. Card flash still works.\n\nYou can enable it in browser settings.', // ---- batch add ---- manualImport: 'Batch add', manualImportPrompt: 'Paste usernames, one per line or separated by spaces/commas:', importReviewCancel: 'Cancel', manualImportDone: (a, e) => `Batch add complete: ${a} new, ${e} already existed`, // ---- more menu ---- moreMenu: 'More', moreMenuTitle: 'More', menuLanguage: 'Language', menuAbout: 'About', menuExport: 'Export config', menuExportUsernames: 'Export usernames.txt', menuCopyUsernames: 'Copy usernames', menuMuteAll: 'Mute all', menuUnmuteAll: 'Unmute all', menuPauseVisible: '⏸ Pause visible', menuResumeVisible: 'Resume visible', menuStopRecordings: 'Stop all recordings', menuRecordingSettings: 'Recording settings', menuRecordingCenter: 'Recording center', menuLayoutSettings: 'Layout settings', menuPlaybackSettings: 'Playback settings', menuBackupPanel: 'Config backups', menuStatusHistory: 'Status history', menuGroupRules: 'Group rules', menuTempUrlManager: 'Temporary URLs', menuShareWorkspace: 'Share workspace', menuShortcutPanel: 'Shortcut panel', menuPureMode: 'Clean mode', menuViewerMode: 'Window-first mode', menuToggleThumbs: 'Toggle thumbnails', menuToggleFit: 'Toggle video fit', menuShortcutHelp: 'Shortcuts / hints', shortcutsHelp: 'Hover any button/control to see its hint. Common shortcuts can be edited in the shortcut panel.\n\nDefaults:\n/ Focus username input\nr Refresh all\ng Grid view\nf Focus view\nAlt+P or Alt+C Clean mode on/off\nAlt+Shift+C Recording center\nAlt+Shift+R Record current page\nEsc Exit clean mode / fullscreen\nSpace Pause/resume the main screen in Focus view\n←/→ or [/ ] Switch the main screen in Focus view\nDouble-click card Fullscreen', pausedVisible: 'Visible windows paused', resumedVisible: 'Visible windows resumed', menuImport: 'Import config', menuRepairData: 'Repair saved data', repairDone: 'Saved data has been normalized.', menuClearAll: 'Clear all data', clearAllConfirm: 'This will erase ALL rooms, groups and settings. Continue?', langZh: '中文', langEn: 'English', // ---- about panel ---- aboutTitle: 'About', aboutAuthor: 'Author', aboutVersion: 'Version', aboutLicense: 'License', aboutSource: 'Source', aboutDonate: 'If this script saves you time…', aboutDonateBtn: 'Tip in ETH', aboutDonateAddrLabel: 'ETH address', aboutCopyAddr: 'Copy', aboutCopied: 'Copied', aboutClose: 'Close', }, zh: { title: 'RoomGrid MultiCam Pro', appTagline: '多房间、多画面、录制、提醒、截图、重连和数据维护的一体化工作台。', addPlaceholder: '输入用户名 ↵', searchPlaceholder: '搜索房间', invalidUsername: '用户名格式不对', hideOffline: '隐藏离线', hidePrivate: '隐藏私密', onlyOnline: '仅看在线', sortLabel: '排序', sortManual: '手动排序', sortStatus: '按状态', sortName: '按名称', sortAdded: '按添加时间', refreshAll: '全部刷新', notifyOnline: '上线提醒', notifyTitle: '主播上线时桌面通知 + 卡片闪烁', collapseSidebar: '', viewGrid: '平铺', viewFocus: '主屏', viewModeLabel: '视图', moreOps: '更多', pureMode: '纯净', pureModeOn: '已进入纯净模式', pureModeOff: '退出纯净模式', pureModeHint: '暂时隐藏所有工具栏、按钮和覆盖层。快捷键:Alt+P / Alt+C;按 Esc 退出。', viewerMode: '窗口优先', viewerModeOn: '已进入窗口优先模式', viewerModeOff: '退出窗口优先', viewerModeHint: '隐藏应用外壳,尽量把空间留给房间窗口。快捷键:Alt+V。', focusThumbsShow: '显示缩略图', focusThumbsHide: '隐藏缩略图', focusThumbsHint: '显示或隐藏主屏模式的缩略图栏。快捷键:Alt+T。', videoFitContain: '画面:完整显示', videoFitCover: '画面:填满窗口', videoFitHint: '在完整显示和裁切填满之间切换。', playbackSettingsTitle: '播放设置', maxStreamHeight: '最高画质', maxQualityAuto: '自动 / 不限制', freeZoomLabel: 'Ctrl/Command + 滚轮缩放', freeZoomHint: '按 Ctrl/Command 加滚轮缩放窗口;放大后拖动画面;双击恢复。', pureExitHint: '退出纯净模式。快捷键:Alt+P / Alt+C 或 Esc。', pureExitChip: '纯净 · Alt+P/C / Esc', syncedFromOtherTab: '(来自其它标签页同步)', hintAddInput: '输入用户名后按 Enter 添加。快捷键:/', hintSearchInput: '按用户名过滤当前可见房间', hintSidebarToggle: '显示 / 隐藏分组侧边栏', hintVolume: '全局音量。单房间静音仍然优先。', hintGridSize: '平铺模式下的基础窗口尺寸', hintSort: '当前分组内的房间排序方式', hintMainRatio: '主屏模式下主屏高度,也可以拖动分隔条调整。', hintMainAspect: '主屏模式下主屏宽高比', hintThumbSize: '主屏模式下缩略图宽度', hintGroupTab: (n) => `切换到「${n}」。也可以把房间拖到这里加入该分组。`, hintLibraryTab: '显示所有已保存房间。在这个视图点移除会全局删除。', hintNewGroup: '创建一个互相独立的新分组', hintMoreMenu: '更多工具和维护功能', groupsHeading: '分组', groupLibrary: '全部保存', groupAll: '默认', groupOnline: '在线', groupFav: '收藏', newGroup: '新建分组', newGroupPrompt: '新分组名称:', renameGroup: '重命名', renameGroupPrompt: '重命名为:', deleteGroup: '删除分组', deleteGroupConfirm: (n) => `删除分组「${n}」?房间仍会保留,只会从该分组移除。`, statTotal: '总计', statOnline: '在线', statMuted: '静音', opRefresh: '刷新', opMuteToggle: '静音切换', opPause: '暂停', opResume: '继续播放', opScreenshot: '截图当前画面', opRecordStart: '录制当前窗口', opRecordStop: '停止录制', opPiP: '画中画', opFullscreen: '全屏', opMoveGroup: '加入分组', opRemove: '移出当前分组', opDeleteRoom: '彻底删除房间', opMirror: '左右镜像', opFlip: '上下翻转', opRotateLeft: '向左旋转', opRotateRight: '向右旋转', opResetView: '重置画面', opOpenRoom: '打开房间', opCopyUsername: '复制用户名', opFavoriteAdd: '加入收藏', opFavoriteRemove: '取消收藏', opMoveToFavorites: '仅移到收藏', copied: '已复制', screenshotSaved: '截图已保存', captureFailed: '截图失败。浏览器跨域/CORS 可能阻止绘制该视频流。', recordingConsent: '录制只保存在本地,会在浏览器允许时保存该视频及对应音频。请只在你有权保存该内容时使用。继续?', recordingStarted: '已开始录制', recordingSaved: '录制已保存', recordingUnsupported: '当前视频或浏览器不支持录制', recordingSegmentSaved: '录制分段已保存', recordingFinalSaved: '最后录制片段已保存', recordingPausedSource: '录制已暂停,等待视频恢复', recordingResumed: '录制已恢复', recordingWaiting: '录制暂停中,点击停止', recordingNoData: '没有可保存的录制数据', recordingSettingsSaved: '录制设置已保存', recordingSegmentPrompt: '分段时长,单位分钟(1-180):', recordingBitratePrompt: '视频码率,单位 Mbps(0.5-20):', recordingExitWarnToggle: '录制中离开页面前提醒', recordingExitWarnMessage: '仍有录制正在进行,确定离开吗?', recordingCenter: '录制管理中心', recordingCenterEmpty: '暂无正在录制的窗口', recordingActive: '录制中', recordingWaitingShort: '等待视频源', recordingSavedSegments: '已保存分段', recordingDuration: '时长', recordingBitrate: '码率', recordingFormatHint: '格式:浏览器支持时优先 MP4,否则使用当前浏览器可用的最佳格式。', recordingRecoverPrompt: (n) => `检测到上次有 ${n} 个房间的录制意图,是否继续等待视频并恢复录制?`, recordCurrentPage: '录制当前页', recordCurrentGroup: '录制当前分组', recordOnlineRooms: '录制在线房间', stopAllRecordings: '停止所有录制', showRecordingOnly: '只看录制中', hideRecordingOnly: '显示全部房间', batchOpenCurrentPage: '打开当前页房间', batchMoveCurrentPage: '当前页移到分组', layoutSettings: '布局设置', recordingSettingsTitle: '录制设置', saveSettings: '保存设置', backupPanel: '配置备份', noBackups: '没有配置备份', restoreBackup: '恢复', deleteBackup: '删除', statusHistory: '上线/离线历史', noStatusHistory: '暂无历史', groupRules: '分组规则', favoriteFirst: '收藏优先置顶', tempUrlManager: '临时 URL 管理', saveTempUrls: '保存当前临时 URL', noTempUrls: '暂无临时 URL', sharePanel: '分享工作台', shareCurrentWorkspace: '分享当前房间和布局', shareCopyLink: '复制分享链接', shareLinkHint: '这个链接会在另一个工作台标签页里恢复房间、分组和布局设置。', shareImportPrompt: (n) => `导入这个分享工作台中的 ${n} 个房间?当前配置会先自动备份。`, shareImported: '已导入分享工作台', shareInvalid: '分享链接无效', shortcutPanel: '快捷键', shortcutCaptureHint: '点击输入框后按新的快捷键。Backspace 可清空当前项。', resetShortcuts: '恢复默认快捷键', shortcutFocusAdd: '聚焦添加框', shortcutRefreshAll: '刷新全部', shortcutGridView: '平铺视图', shortcutFocusView: '主屏视图', shortcutPureMode: '纯净模式', shortcutViewerMode: '窗口优先模式', shortcutFocusThumbs: '显示 / 隐藏缩略图', shortcutRecordingCenter: '录制管理中心', shortcutRecordPage: '录制当前页', stOnline: '在线', stOffline: '离线', stPrivate: '私密', stLoading: '加载中', stError: '错误', stUnknown: '未知', lastSeen: (t) => `上次在线 ${t}`, autoDetect: '将自动检测上线', emptyTitle: '当前分组没有房间', emptyHint: '在顶部输入用户名添加,或切换分组 / 取消过滤', addRoom: (n) => `加入 ${n}`, alreadyAdded: (n) => ` ${n} 已在工作台`, openWorkstation: '打开工作台', openWorkstationHere: '在当前页打开(覆盖)', openWorkstationHereConfirm: '工作台将取代当前页面,需要时可用浏览器后退返回。继续?', memoryStat: (n) => `已记录 ${n} 个房间`, memoryView: '查看记忆列表', collapseFAB: '收起工具坞', dockSubtitle: 'MultiCam 工具', dockCurrentRoom: (n) => `当前:${n}`, dockNoRoom: '未识别到房间', dockOpen: '打开工作台', dockOpenHere: '当前页打开', dockAdd: '加入房间', dockRemove: '移除房间', dockRecord: '在工作台录制', dockScreenshot: '截图当前视频', dockPip: '画中画', dockPause: '播放 / 暂停', dockMute: '静音 / 取消', dockVideoMissing: '当前页没有找到可操作的视频', dockRecordQueued: '已把录制意图发送到工作台', added: '已加入', exists: '已存在', addFailed: '失败', addedNamed: (n) => `${n} 已加入`, removedNamed: (n) => `${n} 已移除`, quickAddTitle: '加入工作台', quickRemoveTitle: '已在工作台 — 点击移除', notifyTitleText: '主播上线', notifyBody: (n) => `${n} 已开播`, permDenied: '未获得桌面通知权限,仅卡片闪烁会生效。\n\n你可以去浏览器设置里手动开启。', manualImport: '批量添加', manualImportPrompt: '粘贴用户名,支持一行一个,或用空格/逗号分隔:', importReviewCancel: '取消', manualImportDone: (a, e) => `批量添加完成:新增 ${a},已存在 ${e}`, moreMenu: '更多', moreMenuTitle: '更多', menuLanguage: '语言', menuAbout: '关于', menuExport: '导出配置', menuExportUsernames: '导出用户名 txt', menuCopyUsernames: '复制用户名列表', menuMuteAll: '全部静音', menuUnmuteAll: '取消全部静音', menuPauseVisible: '⏸ 暂停可见窗口', menuResumeVisible: '继续可见窗口', menuStopRecordings: '停止所有录制', menuRecordingSettings: '录制设置', menuRecordingCenter: '录制管理中心', menuLayoutSettings: '布局设置', menuPlaybackSettings: '播放设置', menuBackupPanel: '配置备份', menuStatusHistory: '状态历史', menuGroupRules: '分组规则', menuTempUrlManager: '临时 URL', menuShareWorkspace: '分享工作台', menuShortcutPanel: '快捷键面板', menuPureMode: '纯净模式', menuViewerMode: '窗口优先模式', menuToggleThumbs: '显示 / 隐藏缩略图', menuToggleFit: '切换画面适应', menuShortcutHelp: '快捷键 / 提示说明', shortcutsHelp: '鼠标停在任何按钮或控件上,会显示即时说明。常用快捷键可以在快捷键面板里修改。\n\n默认:\n/ 聚焦用户名输入框\nr 全部刷新\ng 平铺视图\nf 主屏视图\nAlt+P 或 Alt+C 开关纯净模式\nAlt+Shift+C 录制管理中心\nAlt+Shift+R 录制当前页\nEsc 退出纯净模式 / 全屏\n空格 主屏模式下暂停/继续主屏\n←/→ 或 [/ ] 主屏模式下切换主屏\n双击窗口 全屏', pausedVisible: '已暂停可见窗口', resumedVisible: '已继续可见窗口', menuImport: '导入配置', menuRepairData: '修复保存数据', repairDone: '已完成保存数据规范化。', menuClearAll: '清空所有数据', clearAllConfirm: '将清空所有房间、分组和设置,确定继续?', langZh: '中文', langEn: 'English', aboutTitle: '关于', aboutAuthor: '作者', aboutVersion: '版本', aboutLicense: '协议', aboutSource: '源码', aboutDonate: '如果这个脚本帮你节省了时间…', aboutDonateBtn: '请作者一杯咖啡 (ETH)', aboutDonateAddrLabel: 'ETH 地址', aboutCopyAddr: '复制', aboutCopied: '已复制', aboutClose: '关闭', }, }; const LANG_KEY = 'multicam_lang'; function detectLang() { const stored = localStorage.getItem(LANG_KEY); if (stored && I18N[stored]) return stored; const browserLangs = [ ...(Array.isArray(navigator.languages) ? navigator.languages : []), navigator.language, navigator.userLanguage, ].filter(Boolean).map(v => String(v).toLowerCase()); const primary = browserLangs.find(v => v.startsWith('zh') || v.startsWith('en')) || ''; if (primary.startsWith('zh')) return 'zh'; return 'en'; } let LANG = detectLang(); const t = (key, ...args) => { const v = (I18N[LANG] && I18N[LANG][key]) ?? I18N.en[key] ?? key; return typeof v === 'function' ? v(...args) : v; }; function setLang(lang) { if (!I18N[lang] || lang === LANG) return; localStorage.setItem(LANG_KEY, lang); location.reload(); } /* ============================================================= * 0.6. 元数据 / Meta —— 关于 + 捐赠 * ============================================================= */ const META = { version: '15.9-enhancer', author: 'RYUJO', license: 'MIT', source: 'https://greasyfork.org/', eth: '0x6ad5b8Baf993C1C377B81Fa277c5d8350e339D07', }; /* ============================================================= * 1. 持久化层 / Storage —— schema 版本号兜底 * ============================================================= */ const STORE_KEY = 'ryujo_multicam_v8'; const CONFIG_BACKUP_PREFIX = STORE_KEY + '_backup_'; const RECORDING_INTENT_KEY = STORE_KEY + '_recording_intents_v1'; const ROOM_STATUS_HISTORY_KEY = STORE_KEY + '_status_history_v1'; const SAVED_TEMP_URLS_KEY = STORE_KEY + '_saved_temp_urls_v1'; const MAX_CONFIG_BYTES = 2 * 1024 * 1024; const MAX_CONFIG_BACKUPS = 3; const INJECTOR_ROUTE_POLL_VISIBLE_MS = 1000; const INJECTOR_ROUTE_POLL_HIDDEN_MS = 5000; const defaultState = () => ({ v: 8, rooms: [], // {id, addedAt, group, order, lastStatus, lastSeenOnline, muted, notes} groups: [ { id: LIBRARY_GROUP_ID, name: '__library__', order: 0, system: true }, { id: DEFAULT_GROUP_ID, name: '__all__', order: 1, system: true }, { id: ONLINE_GROUP_ID, name: '__online__', order: 2, system: true }, { id: FAVORITE_GROUP_ID, name: '__fav__', order: 3, system: true }, ], settings: { volume: 0, gridSize: 400, gridCellSize: 80, layoutSize: 4, // one screen capacity: 2 | 4 | 6 | 9 pageIndex: 0, toolbarCollapsed: false, viewMode: 'grid', // 'grid' | 'focus' focusedRoomId: null, focusMainPct: 62, // main screen width ratio in focus mode focusMainHPct: 64, // main screen height ratio in focus mode focusAspect: 'auto', focusThumbSize: 150, filter: { hideOffline: false, hidePrivate: false, onlyOnline: false }, sortBy: 'manual', notifyOnline: true, activeGroup: 'all', searchQuery: '', pureMode: false, viewerMode: false, focusThumbsCollapsed: false, videoFit: 'contain', freeZoom: true, maxStreamHeight: 1080, videoTransforms: {}, showRecordingOnly: false, favoriteFirst: true, shortcuts: defaultShortcuts(), recordingSegmentMinutes: 10, recordingVideoBitrate: 6000000, recordingExitWarn: true, pollMs: { offline: 60000, private: 30000, error: 10000, online: 120000 }, sidebarCollapsed: false, }, }); function ensureSystemGroups(state) { if (!state || typeof state !== 'object') return state; state.groups = Array.isArray(state.groups) ? state.groups : []; const wanted = [ { id: LIBRARY_GROUP_ID, name: '__library__', order: 0, system: true }, { id: DEFAULT_GROUP_ID, name: '__all__', order: 1, system: true }, { id: ONLINE_GROUP_ID, name: '__online__', order: 2, system: true }, { id: FAVORITE_GROUP_ID, name: '__fav__', order: 3, system: true }, ]; for (const g of wanted) { const found = state.groups.find(x => x.id === g.id); if (!found) state.groups.push({ ...g }); else { found.name = g.name; found.system = true; found.order = g.order; } } state.groups.sort((a, b) => numeric(a.order, 999) - numeric(b.order, 999)); return state; } /* ============================================================= * 1.1. 分组成员关系 / Group membership * v12 模型:所有显示分组都是独立成员关系;library 是唯一聚合管理视图。 * 在普通分组点 X 只移出当前分组;在 library 点 X 才彻底删除。 * ============================================================= */ function uniq(arr) { return [...new Set((arr || []).filter(Boolean))]; } function numeric(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; } function clampInt(v, min, max, fallback = min) { const n = Math.round(Number(v)); if (!Number.isFinite(n)) return fallback; return Math.max(min, Math.min(max, n)); } function normalizeCardSize(size) { if (!size || typeof size !== 'object') return null; const cols = clampInt(size.cols ?? size.columns ?? size.w, 3, 18, 0); const rows = clampInt(size.rows ?? size.h, 3, 18, 0); if (!cols || !rows) return null; return { cols, rows }; } function normalizeCardSizeMap(map) { const out = {}; if (map && typeof map === 'object' && !Array.isArray(map)) { for (const [k, v] of Object.entries(map)) { const key = String(k || '').trim() || 'all'; const size = normalizeCardSize(v); if (size) out[key] = size; } } return out; } function setCardSizeForGroup(room, groupId, size) { if (!room) return; const key = String(groupId || 'all'); const next = normalizeCardSize(size); const map = normalizeCardSizeMap(room.cardSizeByGroup); if (next) map[key] = next; else delete map[key]; room.cardSizeByGroup = map; delete room.cardSize; if (!Object.keys(room.cardSizeByGroup).length) delete room.cardSizeByGroup; } function getRoomGroups(room) { const groups = Array.isArray(room?.groups) ? room.groups.slice() : []; if (room?.group) groups.push(room.group); // v12: all/default is a real group; only library is an aggregate view and is never stored as membership. return uniq(groups.filter(g => g && g !== LIBRARY_GROUP_ID && g !== ONLINE_GROUP_ID)); } function normalizeRoom(room, fallbackOrder = 0) { const r = room && typeof room === 'object' ? room : { id: String(room || '') }; r.id = normalizeUsername(r.id); r.groups = getRoomGroups(r); r.group = r.groups[0] || null; r.addedAt = numeric(r.addedAt, Date.now()); r.order = numeric(r.order, fallbackOrder); if (!r.groupOrder || typeof r.groupOrder !== 'object' || Array.isArray(r.groupOrder)) r.groupOrder = {}; for (const g of r.groups) r.groupOrder[g] = numeric(r.groupOrder[g], r.order); if (!r.lastStatus) r.lastStatus = 'unknown'; r.lastSeenOnline = numeric(r.lastSeenOnline, 0); r.muted = !!r.muted; const sizeMap = normalizeCardSizeMap(r.cardSizeByGroup); const legacyCardSize = normalizeCardSize(r.cardSize); if (legacyCardSize && !sizeMap.all) sizeMap.all = legacyCardSize; if (Object.keys(sizeMap).length) r.cardSizeByGroup = sizeMap; else delete r.cardSizeByGroup; delete r.cardSize; return r; } function normalizeStateMemberships(state) { if (!state || typeof state !== 'object') return state; state.rooms = Array.isArray(state.rooms) ? state.rooms : []; const merged = new Map(); state.rooms.forEach((room, idx) => { const r = normalizeRoom(room, idx); if (!r.id) return; const prev = merged.get(r.id); if (!prev) { merged.set(r.id, r); return; } const groups = uniq([...getRoomGroups(prev), ...getRoomGroups(r)]); prev.groups = groups; prev.group = groups[0] || null; prev.groupOrder = { ...(r.groupOrder || {}), ...(prev.groupOrder || {}) }; for (const g of groups) prev.groupOrder[g] = numeric(prev.groupOrder[g], prev.order); prev.order = Math.min(numeric(prev.order, idx), numeric(r.order, idx)); const mergedSizeMap = { ...(r.cardSizeByGroup || {}), ...(prev.cardSizeByGroup || {}) }; if (Object.keys(mergedSizeMap).length) prev.cardSizeByGroup = normalizeCardSizeMap(mergedSizeMap); else delete prev.cardSizeByGroup; delete prev.cardSize; prev.addedAt = Math.min(numeric(prev.addedAt, Date.now()), numeric(r.addedAt, Date.now())); if (r.lastStatus && r.lastStatus !== 'unknown') prev.lastStatus = r.lastStatus; if (r.lastSeenOnline) prev.lastSeenOnline = Math.max(numeric(prev.lastSeenOnline, 0), numeric(r.lastSeenOnline, 0)); prev.muted = !!(prev.muted || r.muted); }); state.rooms = [...merged.values()]; return state; } function sanitizeState(input) { const def = defaultState(); const src = input && typeof input === 'object' && !Array.isArray(input) ? input : {}; const out = { ...def, v: numeric(src.v, def.v) }; const rawGroups = Array.isArray(src.groups) ? src.groups.slice(0, 80) : def.groups; const seenGroups = new Set(); out.groups = []; rawGroups.forEach((g, idx) => { if (!g || typeof g !== 'object') return; let id = String(g.id || '').trim(); if (!id || !/^[a-z0-9_-]{1,48}$/i.test(id)) id = uuid(); if (seenGroups.has(id)) return; seenGroups.add(id); let name = safeGroupName(g.name, id); if (!name) name = id; out.groups.push({ id, name, order: clampInt(g.order, 0, 10000, idx), system: !!g.system && [LIBRARY_GROUP_ID, DEFAULT_GROUP_ID, ONLINE_GROUP_ID, FAVORITE_GROUP_ID].includes(id), }); }); ensureSystemGroups(out); const validGroupIds = new Set(out.groups.map(g => g.id).filter(id => id !== LIBRARY_GROUP_ID && id !== ONLINE_GROUP_ID)); const allowedStatus = new Set(['online', 'offline', 'private', 'loading', 'error', 'unknown']); const rawRooms = Array.isArray(src.rooms) ? src.rooms.slice(0, 1200) : []; out.rooms = rawRooms.map((room, idx) => { const r = normalizeRoom(room, idx); if (!isLikelyUsername(r.id)) return null; const groups = getRoomGroups(r).filter(g => validGroupIds.has(g)); const safe = { id: r.id, addedAt: Math.max(0, numeric(r.addedAt, Date.now())), group: groups[0] || null, groups, groupOrder: {}, order: clampInt(r.order, 0, 1000000, idx), lastStatus: allowedStatus.has(r.lastStatus) ? r.lastStatus : 'unknown', lastSeenOnline: Math.max(0, numeric(r.lastSeenOnline, 0)), muted: !!r.muted, }; if (r.privateLabel) safe.privateLabel = String(r.privateLabel).slice(0, 40); if (r.errorMsg) safe.errorMsg = String(r.errorMsg).slice(0, 120); for (const g of groups) safe.groupOrder[g] = clampInt(r.groupOrder?.[g], 0, 1000000, safe.order); const sizeMap = normalizeCardSizeMap(r.cardSizeByGroup); if (Object.keys(sizeMap).length) safe.cardSizeByGroup = sizeMap; return safe; }).filter(Boolean); normalizeStateMemberships(out); const st = src.settings && typeof src.settings === 'object' && !Array.isArray(src.settings) ? src.settings : {}; out.settings = { ...def.settings, ...st }; const allowedSettingKeys = new Set([...Object.keys(def.settings), '__focusAutoMigratedV12']); for (const key of Object.keys(out.settings)) { if (!allowedSettingKeys.has(key)) delete out.settings[key]; } out.settings.filter = { ...def.settings.filter, ...(st.filter && typeof st.filter === 'object' ? st.filter : {}) }; out.settings.filter.hideOffline = !!out.settings.filter.hideOffline; out.settings.filter.hidePrivate = !!out.settings.filter.hidePrivate; out.settings.filter.onlyOnline = !!out.settings.filter.onlyOnline; out.settings.volume = Math.max(0, Math.min(1, Number(out.settings.volume) || 0)); out.settings.gridSize = clampInt(out.settings.gridSize, 220, 900, def.settings.gridSize); out.settings.gridCellSize = clampInt(out.settings.gridCellSize, 56, 120, def.settings.gridCellSize); out.settings.layoutSize = [2, 4, 6, 9].includes(Number(out.settings.layoutSize)) ? Number(out.settings.layoutSize) : def.settings.layoutSize; out.settings.pageIndex = clampInt(out.settings.pageIndex, 0, 100000, 0); out.settings.viewMode = out.settings.viewMode === 'focus' ? 'focus' : 'grid'; out.settings.focusedRoomId = isLikelyUsername(out.settings.focusedRoomId) ? normalizeUsername(out.settings.focusedRoomId) : null; out.settings.focusMainPct = clampInt(out.settings.focusMainPct, 45, 76, def.settings.focusMainPct); out.settings.focusMainHPct = clampInt(out.settings.focusMainHPct, 44, 78, def.settings.focusMainHPct); out.settings.focusAspect = ['auto', '16:9', '4:3', '1:1', '9:16'].includes(out.settings.focusAspect) ? out.settings.focusAspect : 'auto'; out.settings.focusThumbSize = clampInt(out.settings.focusThumbSize, 96, 260, def.settings.focusThumbSize); out.settings.sortBy = ['manual', 'status', 'name', 'addedAt'].includes(out.settings.sortBy) ? out.settings.sortBy : 'manual'; out.settings.activeGroup = out.groups.some(g => g.id === out.settings.activeGroup) ? out.settings.activeGroup : DEFAULT_GROUP_ID; out.settings.searchQuery = normalizeUsername(out.settings.searchQuery || ''); out.settings.pureMode = false; out.settings.viewerMode = !!out.settings.viewerMode; out.settings.focusThumbsCollapsed = !!out.settings.focusThumbsCollapsed; out.settings.videoFit = out.settings.videoFit === 'cover' ? 'cover' : 'contain'; out.settings.freeZoom = out.settings.freeZoom !== false; out.settings.maxStreamHeight = [0, 240, 360, 480, 720, 1080, 1440, 2160].includes(Number(out.settings.maxStreamHeight)) ? Number(out.settings.maxStreamHeight) : def.settings.maxStreamHeight; out.settings.videoTransforms = sanitizeVideoTransformMap(st.videoTransforms); out.settings.showRecordingOnly = !!out.settings.showRecordingOnly; out.settings.favoriteFirst = out.settings.favoriteFirst !== false; out.settings.shortcuts = sanitizeShortcuts(st.shortcuts, def.settings.shortcuts); out.settings.recordingSegmentMinutes = clampInt(out.settings.recordingSegmentMinutes, 1, 180, def.settings.recordingSegmentMinutes); out.settings.recordingVideoBitrate = clampInt(out.settings.recordingVideoBitrate, 500000, 20000000, def.settings.recordingVideoBitrate); out.settings.recordingExitWarn = out.settings.recordingExitWarn !== false; out.settings.toolbarCollapsed = !!out.settings.toolbarCollapsed; out.settings.sidebarCollapsed = !!out.settings.sidebarCollapsed; out.settings.notifyOnline = out.settings.notifyOnline !== false; out.settings.pollMs = { ...def.settings.pollMs, ...(st.pollMs && typeof st.pollMs === 'object' ? st.pollMs : {}) }; for (const [k, fallback] of Object.entries(def.settings.pollMs)) { out.settings.pollMs[k] = clampInt(out.settings.pollMs[k], 5000, 300000, fallback); } return out; } function roomInGroup(room, groupId) { if (groupId === LIBRARY_GROUP_ID) return true; if (groupId === ONLINE_GROUP_ID) return room?.lastStatus === 'online'; return getRoomGroups(room).includes(groupId || DEFAULT_GROUP_ID); } function roomOrderInGroup(room, groupId) { if (groupId === LIBRARY_GROUP_ID || groupId === DEFAULT_GROUP_ID || groupId === ONLINE_GROUP_ID) return numeric(room?.order, 0); return numeric(room?.groupOrder?.[groupId], numeric(room?.order, 0)); } function nextOrderForGroup(state, groupId) { const list = groupId === LIBRARY_GROUP_ID ? state.rooms : state.rooms.filter(r => roomInGroup(r, groupId)); return Math.max(-1, ...list.map(r => roomOrderInGroup(r, groupId))) + 1; } function ensureRoomInGroup(room, groupId, order) { normalizeRoom(room); if (!groupId || groupId === LIBRARY_GROUP_ID || groupId === ONLINE_GROUP_ID) return false; const groups = getRoomGroups(room); const existed = groups.includes(groupId); if (!existed) groups.push(groupId); room.groups = uniq(groups); room.group = room.groups[0] || null; if (!room.groupOrder || typeof room.groupOrder !== 'object' || Array.isArray(room.groupOrder)) room.groupOrder = {}; if (!existed || !Number.isFinite(Number(room.groupOrder[groupId]))) room.groupOrder[groupId] = numeric(order, room.order); return !existed; } function removeRoomFromGroup(room, groupId) { if (!room || !groupId || groupId === LIBRARY_GROUP_ID || groupId === ONLINE_GROUP_ID) return false; const before = getRoomGroups(room); const after = before.filter(g => g !== groupId); if (after.length === before.length) return false; room.groups = after; room.group = after[0] || null; if (room.groupOrder && typeof room.groupOrder === 'object') delete room.groupOrder[groupId]; return true; } function pruneConfigBackups(max = MAX_CONFIG_BACKUPS) { try { const keys = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(CONFIG_BACKUP_PREFIX)) keys.push(key); } keys.sort((a, b) => Number(b.slice(CONFIG_BACKUP_PREFIX.length)) - Number(a.slice(CONFIG_BACKUP_PREFIX.length))); keys.slice(Math.max(0, max)).forEach(key => { try { localStorage.removeItem(key); } catch (_) {} }); } catch (_) {} } function backupCurrentConfig() { try { const raw = localStorage.getItem(STORE_KEY); if (!raw) return false; pruneConfigBackups(MAX_CONFIG_BACKUPS - 1); localStorage.setItem(CONFIG_BACKUP_PREFIX + Date.now(), raw); pruneConfigBackups(MAX_CONFIG_BACKUPS); return true; } catch (_) { try { pruneConfigBackups(1); } catch (_) {} return false; } } function readJsonStorage(key, fallback) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (_) { return fallback; } } function writeJsonStorage(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (_) { return false; } } function loadRecordingIntents() { const ids = readJsonStorage(RECORDING_INTENT_KEY, []); return Array.isArray(ids) ? [...new Set(ids.map(normalizeUsername).filter(isLikelyUsername))] : []; } function saveRecordingIntents(ids) { writeJsonStorage(RECORDING_INTENT_KEY, [...new Set((ids || []).map(normalizeUsername).filter(isLikelyUsername))]); } function setRecordingIntent(id, on) { id = normalizeUsername(id); if (!isLikelyUsername(id)) return; const ids = new Set(loadRecordingIntents()); if (on) ids.add(id); else ids.delete(id); saveRecordingIntents([...ids]); } function addRoomStatusHistory(id, status, extra = {}) { id = normalizeUsername(id); if (!isLikelyUsername(id) || !isStableRoomStatus(status)) return; const all = readJsonStorage(ROOM_STATUS_HISTORY_KEY, {}); const list = Array.isArray(all[id]) ? all[id] : []; const last = list[0]; if (last && last.status === status && Date.now() - Number(last.ts || 0) < 30000) return; list.unshift({ ts: Date.now(), status, privateLabel: extra.privateLabel ? String(extra.privateLabel).slice(0, 40) : '', }); all[id] = list.slice(0, 80); writeJsonStorage(ROOM_STATUS_HISTORY_KEY, all); } function loadRoomStatusHistory() { const all = readJsonStorage(ROOM_STATUS_HISTORY_KEY, {}); return all && typeof all === 'object' && !Array.isArray(all) ? all : {}; } function writeStoreRaw(json) { try { localStorage.setItem(STORE_KEY, json); pruneConfigBackups(MAX_CONFIG_BACKUPS); return true; } catch (e) { pruneConfigBackups(0); localStorage.setItem(STORE_KEY, json); return true; } } const Storage = { load() { try { const raw = localStorage.getItem(STORE_KEY); if (raw) { const s = sanitizeState(JSON.parse(raw)); if (!s.settings.__focusAutoMigratedV12) { s.settings.focusAspect = 'auto'; s.settings.__focusAutoMigratedV12 = true; } return s; } return defaultState(); } catch (e) { console.warn('[RoomGrid] Storage load failed, fallback to default', e); return defaultState(); } }, save(state) { try { const clean = sanitizeState(state); writeStoreRaw(JSON.stringify(clean)); // localStorage 的 storage 事件不会在当前页面触发;补一个同页事件, // 让同标签页 SPA 切换主播 / QuickAdd 状态也能立即刷新。 try { window.dispatchEvent(new CustomEvent('ryujo_multicam_storage', { detail: { state: clean } })); } catch (_) {} } catch (e) { console.warn('[RoomGrid] Storage save failed', e); } }, clearAll() { try { localStorage.removeItem(STORE_KEY); try { window.dispatchEvent(new CustomEvent('ryujo_multicam_storage', { detail: { state: null } })); } catch (_) {} } catch (_) {} }, has(id) { id = normalizeUsername(id); try { return !!this.load().rooms.find(r => r.id === id); } catch (_) { return false; } }, add(id) { id = normalizeUsername(id); if (!isLikelyUsername(id)) return 'failed'; const s = this.load(); if (s.rooms.find(r => r.id === id)) return 'exists'; const order = nextOrderForGroup(s, DEFAULT_GROUP_ID); s.rooms.push(normalizeRoom({ id, addedAt: Date.now(), group: DEFAULT_GROUP_ID, groups: [DEFAULT_GROUP_ID], groupOrder: {}, order, lastStatus: 'unknown', lastSeenOnline: 0, muted: false, }, order)); this.save(s); return 'added'; }, remove(id) { id = normalizeUsername(id); const s = this.load(); const before = s.rooms.length; s.rooms = s.rooms.filter(r => r.id !== id); if (s.rooms.length !== before) { this.save(s); return true; } return false; }, }; /* ============================================================= * 2. 状态层 / Store —— 简单响应式 + 持久化 * ============================================================= */ function createStore() { let state = Storage.load(); const subs = new Set(); const persistDebounced = debounce(() => Storage.save(state), 800); const notify = (path) => { for (const fn of subs) fn(state, path); }; const update = (mutator, path = 'all') => { try { const result = mutator(state); // v15.5: mutator 返回 false 表示没有实际变化,避免同状态反复写 localStorage, // 减少多工作台之间 storage 事件 ping-pong 引发的重排/闪屏。 if (result === false) return; } catch (err) { console.warn('[RoomGrid] store update failed', err); return; } persistDebounced(); notify(path); }; return { get state() { return state; }, subscribe(fn) { subs.add(fn); return () => subs.delete(fn); }, update, replaceState(nextState, path = 'all') { state = sanitizeState(nextState || defaultState()); notify(path); }, // ---- 房间 ---- addRoom(id) { id = normalizeUsername(id); if (!isLikelyUsername(id)) return false; let changed = false; update(s => { normalizeStateMemberships(s); const ag = s.settings.activeGroup || DEFAULT_GROUP_ID; const targetGroup = (ag === LIBRARY_GROUP_ID || ag === ONLINE_GROUP_ID) ? DEFAULT_GROUP_ID : ag; const existing = s.rooms.find(r => r.id === id); if (existing) { changed = ensureRoomInGroup(existing, targetGroup, nextOrderForGroup(s, targetGroup)) || changed; return; } const allOrder = nextOrderForGroup(s, DEFAULT_GROUP_ID); const groupOrder = nextOrderForGroup(s, targetGroup); s.rooms.push(normalizeRoom({ id, addedAt: Date.now(), group: targetGroup, groups: [targetGroup], groupOrder: targetGroup === DEFAULT_GROUP_ID ? {} : { [targetGroup]: groupOrder }, order: allOrder, lastStatus: 'unknown', lastSeenOnline: 0, muted: false, }, allOrder)); changed = true; }, 'rooms'); return changed; }, removeRoom(id) { id = normalizeUsername(id); update(s => { s.rooms = s.rooms.filter(r => r.id !== id); }, 'rooms'); }, removeRoomFromActiveGroup(id) { id = normalizeUsername(id); let globalRemoved = false; update(s => { normalizeStateMemberships(s); const ag = s.settings.activeGroup || DEFAULT_GROUP_ID; if (ag === LIBRARY_GROUP_ID || ag === ONLINE_GROUP_ID) { const before = s.rooms.length; s.rooms = s.rooms.filter(r => r.id !== id); globalRemoved = s.rooms.length !== before; return; } const r = s.rooms.find(r => r.id === id); if (r) removeRoomFromGroup(r, ag); }, 'rooms'); return globalRemoved; }, patchRoom(id, patch) { id = normalizeUsername(id); const allowed = new Set(['lastStatus', 'lastSeenOnline', 'muted', 'privateLabel', 'errorMsg']); const clean = {}; for (const [k, v] of Object.entries(patch || {})) if (allowed.has(k)) clean[k] = v; update(s => { const r = s.rooms.find(r => r.id === id); if (!r) return false; let changed = false; for (const [k, v] of Object.entries(clean)) { if (!Object.is(r[k], v)) { r[k] = v; changed = true; } } return changed; }, 'room:' + id); }, setRoomCardSize(id, groupId, size) { id = normalizeUsername(id); update(s => { normalizeStateMemberships(s); const r = s.rooms.find(r => r.id === id); if (r) setCardSizeForGroup(r, groupId, size); }, 'rooms'); }, reorderRooms(orderedIds, targetGroup) { update(s => { normalizeStateMemberships(s); orderedIds.forEach((id, idx) => { const r = s.rooms.find(r => r.id === id); if (!r) return; if (!targetGroup || targetGroup === LIBRARY_GROUP_ID || targetGroup === ONLINE_GROUP_ID || targetGroup === DEFAULT_GROUP_ID) { r.order = idx; } else { ensureRoomInGroup(r, targetGroup, idx); r.groupOrder[targetGroup] = idx; } }); }, 'rooms'); }, moveToGroup(id, groupId) { id = normalizeUsername(id); update(s => { normalizeStateMemberships(s); const r = s.rooms.find(r => r.id === id); if (!r || !groupId || groupId === LIBRARY_GROUP_ID || groupId === ONLINE_GROUP_ID) return; ensureRoomInGroup(r, groupId, nextOrderForGroup(s, groupId)); }, 'rooms'); }, toggleRoomInGroup(id, groupId) { id = normalizeUsername(id); let nowInGroup = false; update(s => { normalizeStateMemberships(s); const r = s.rooms.find(r => r.id === id); if (!r || !groupId || groupId === LIBRARY_GROUP_ID || groupId === ONLINE_GROUP_ID) return; if (roomInGroup(r, groupId)) { removeRoomFromGroup(r, groupId); nowInGroup = false; } else { ensureRoomInGroup(r, groupId, nextOrderForGroup(s, groupId)); nowInGroup = true; } }, 'rooms'); return nowInGroup; }, moveOnlyToGroup(id, groupId) { id = normalizeUsername(id); update(s => { normalizeStateMemberships(s); const r = s.rooms.find(r => r.id === id); if (!r || !groupId || groupId === LIBRARY_GROUP_ID || groupId === ONLINE_GROUP_ID) return; r.groups = [groupId]; r.group = groupId; r.groupOrder = r.groupOrder && typeof r.groupOrder === 'object' && !Array.isArray(r.groupOrder) ? r.groupOrder : {}; r.groupOrder[groupId] = nextOrderForGroup(s, groupId); }, 'rooms'); }, setAllMuted(muted) { update(s => { normalizeStateMemberships(s); s.rooms.forEach(r => { r.muted = !!muted; }); }, 'rooms'); }, resetTileSizes() { update(s => { normalizeStateMemberships(s); s.rooms.forEach(r => { delete r.cardSize; delete r.cardSizeByGroup; }); }, 'rooms'); }, repairData() { update(s => { normalizeStateMemberships(s); ensureSystemGroups(s); const validGroupIds = new Set((s.groups || []).map(g => g.id).filter(id => id !== LIBRARY_GROUP_ID && id !== ONLINE_GROUP_ID)); s.rooms.forEach((r, idx) => { r.groups = getRoomGroups(r).filter(g => validGroupIds.has(g)); r.group = r.groups[0] || null; r.order = numeric(r.order, idx); if (!r.groupOrder || typeof r.groupOrder !== 'object' || Array.isArray(r.groupOrder)) r.groupOrder = {}; for (const g of Object.keys(r.groupOrder)) if (!validGroupIds.has(g)) delete r.groupOrder[g]; const sizeMap = normalizeCardSizeMap(r.cardSizeByGroup); const legacyCardSize = normalizeCardSize(r.cardSize); if (legacyCardSize && !sizeMap.all) sizeMap.all = legacyCardSize; if (Object.keys(sizeMap).length) r.cardSizeByGroup = sizeMap; else delete r.cardSizeByGroup; delete r.cardSize; }); }, 'all'); }, // ---- 分组 ---- addGroup(name) { const safeName = safeGroupName(name, LANG === 'zh' ? '新分组' : 'New group'); const id = uuid(); update(s => { s.groups.push({ id, name: safeName, order: s.groups.length }); }, 'groups'); return id; }, renameGroup(id, name) { update(s => { const g = s.groups.find(g => g.id === id); if (g && !g.system) g.name = safeGroupName(name, g.name); }, 'groups'); }, removeGroup(id) { update(s => { if (s.groups.find(g => g.id === id)?.system) return; s.groups = s.groups.filter(g => g.id !== id); s.rooms.forEach(r => removeRoomFromGroup(r, id)); if (s.settings.activeGroup === id) s.settings.activeGroup = DEFAULT_GROUP_ID; }, 'groups'); }, setActiveGroup(id) { update(s => { if (s.groups.some(g => g.id === id)) { s.settings.activeGroup = id; s.settings.pageIndex = 0; } }, 'settings:activeGroup,pageIndex'); }, // ---- 设置 ---- patchSettings(patch) { const allowed = new Set([...Object.keys(defaultState().settings), '__focusAutoMigratedV12']); const clean = {}; for (const [k, v] of Object.entries(patch || {})) if (allowed.has(k)) clean[k] = v; const keys = Object.keys(clean); update(s => { Object.assign(s.settings, clean); }, keys.length ? 'settings:' + keys.join(',') : 'settings'); }, patchFilter(patch) { const allowed = new Set(['hideOffline', 'hidePrivate', 'onlyOnline']); const clean = {}; for (const [k, v] of Object.entries(patch || {})) if (allowed.has(k)) clean[k] = !!v; const keys = Object.keys(clean).map(k => 'filter.' + k); update(s => { Object.assign(s.settings.filter, clean); }, keys.length ? 'settings:' + keys.join(',') : 'settings'); }, }; } /* ============================================================= * 3. 通知 / Notify * ============================================================= */ const Notify = { granted: false, init() { if ('Notification' in window && Notification.permission === 'granted') this.granted = true; }, async request() { if (!('Notification' in window)) return false; if (Notification.permission === 'granted') { this.granted = true; return true; } if (Notification.permission === 'denied') return false; const r = await Notification.requestPermission(); this.granted = r === 'granted'; return this.granted; }, fire(title, body) { if (!this.granted) return; try { const n = new Notification(title, { body, silent: false, tag: 'multicam-' + title }); n.onclick = () => { window.focus(); n.close(); }; setTimeout(() => n.close(), 8000); } catch (_) {} }, }; /* ============================================================= * 4. 房间服务 / RoomService —— API + HLS + 重连 + 智能轮询 * ============================================================= */ function createRoomService(store) { const sessions = new Map(); // id -> { hls, video, status, retryCount, pollTimer, userPaused } const domain = safeChaturbateHost(window.location.hostname) ? window.location.hostname : 'chaturbate.com'; function clearPoll(s) { if (s?.pollTimer) { clearTimeout(s.pollTimer); s.pollTimer = null; } } function onlinePollMs() { return Math.max(45000, Number(store.state.settings.pollMs?.online) || 120000); } async function fetchContext(id, signal) { const req = new AbortController(); const timeout = setTimeout(() => { try { req.abort(); } catch (_) {} }, 15000); const abortFromParent = () => { try { req.abort(); } catch (_) {} }; try { if (signal) { if (signal.aborted) abortFromParent(); else signal.addEventListener('abort', abortFromParent, { once: true }); } const res = await fetch(`https://${domain}/api/chatvideocontext/${encodeURIComponent(id)}/`, { credentials: 'include', signal: req.signal, referrer: `https://${domain}/${encodeURIComponent(id)}/`, referrerPolicy: 'strict-origin-when-cross-origin', }); if (!res.ok) throw new Error('http ' + res.status); return res.json(); } finally { clearTimeout(timeout); try { signal?.removeEventListener?.('abort', abortFromParent); } catch (_) {} } } function setStatus(id, status, extra = {}) { id = normalizeUsername(id); const s = sessions.get(id); if (!s) return; const room = store.state.rooms.find(r => r.id === id); const prev = s.status || room?.lastStatus || 'unknown'; const opts = extra && typeof extra === 'object' ? extra : {}; const transient = !!opts.transient; const safeExtra = { ...opts }; delete safeExtra.transient; // v15.5: 后台探测中的 loading / 临时请求错误不覆盖稳定状态。 // 否则 hideOffline / onlyOnline / 分页会短时间重算,表现为离线房间闪出、在线房间消失后又回来。 if ((status === 'loading' || (status === 'error' && transient)) && isStableRoomStatus(prev)) { s.status = prev; s.pendingStatus = status; if (safeExtra.errorMsg) s.lastTransientError = safeExtra.errorMsg; return; } s.status = status; delete s.pendingStatus; const patch = { lastStatus: status, ...safeExtra }; if (status === 'online') { const lastSeen = numeric(room?.lastSeenOnline, 0); if (prev !== 'online' || !lastSeen || Date.now() - lastSeen > 60000) patch.lastSeenOnline = Date.now(); } store.patchRoom(id, patch); if (prev !== status && isStableRoomStatus(status)) addRoomStatusHistory(id, status, safeExtra); // 上线提醒 if (prev && prev !== 'online' && status === 'online' && store.state.settings.notifyOnline) { Notify.fire(t('notifyTitleText'), t('notifyBody', id)); EventBus.emit('room:flash', id); } } function hardStopVideo(video) { stopMediaElement(video, true); } function hardStopRoomVideos(id) { id = normalizeUsername(id); // DOM 兜底:即使 cardMap/session 已经丢失,也按 room-id 把残留 video 杀掉。 try { const safe = String(id).replace(/"/g, ''); document.querySelectorAll(`video[data-multicam-room-id="${safe}"],video[data-multicam-room="${safe}"],audio[data-multicam-room-id="${safe}"],audio[data-multicam-room="${safe}"]`).forEach(hardStopVideo); } catch (_) {} } function destroyHls(s) { if (!s || !s.hls) return; try { s.hls.stopLoad(); } catch (_) {} try { s.hls.detachMedia(); } catch (_) {} try { s.hls.destroy(); } catch (_) {} s.hls = null; } function destroyPlayer(id, opts = {}) { id = normalizeUsername(id); const s = sessions.get(id); if (!s) { hardStopRoomVideos(id); return; } if (opts.abort !== false && s.abortController) { try { s.abortController.abort(); } catch (_) {} s.abortController = null; } clearPoll(s) if (s.hls) { try { s.hls.stopLoad(); } catch (_) {} } hardStopVideo(s.video); hardStopRoomVideos(id); if (s.hls) { try { s.hls.detachMedia(); } catch (_) {} try { s.hls.destroy(); } catch (_) {} s.hls = null; } s.video = null; s.hlsSource = null; } function detachVideo(id) { id = normalizeUsername(id); const s = sessions.get(id); if (s) { if (s.hls) { try { s.hls.stopLoad(); } catch (_) {} try { s.hls.detachMedia(); } catch (_) {} try { s.hls.destroy(); } catch (_) {} s.hls = null; } hardStopVideo(s.video); s.video = null; s.hlsSource = null; if (s.status === 'online' && store.state.rooms.some(r => r.id === id)) schedulePoll(id, onlinePollMs()); } hardStopRoomVideos(id); } function attachVideo(id, video) { id = normalizeUsername(id); const s = sessions.get(id); if (!s) { hardStopVideo(video); return; } clearPoll(s); s.video = video; } function schedulePoll(id, ms) { id = normalizeUsername(id); const s = sessions.get(id); if (!s) return; clearPoll(s); // 错峰:±20% const jitter = ms * (0.8 + Math.random() * 0.4); s.pollTimer = setTimeout(() => { if (sessions.has(id)) connect(id); }, jitter); } function pause(id) { id = normalizeUsername(id); const s = sessions.get(id); if (!s) return false; s.userPaused = true; try { s.hls?.stopLoad?.(); } catch (_) {} try { s.video?.pause?.(); } catch (_) {} return true; } function resume(id) { id = normalizeUsername(id); const s = sessions.get(id); if (!s) return false; s.userPaused = false; try { s.hls?.startLoad?.(); } catch (_) {} try { s.video?.play?.().catch?.(() => {}); } catch (_) {} if (!s.video && store.state.rooms.some(r => r.id === id)) refresh(id); return true; } function isPaused(id) { id = normalizeUsername(id); const s = sessions.get(id); return !!(s?.userPaused || s?.video?.paused); } function togglePause(id) { return isPaused(id) ? (resume(id), false) : (pause(id), true); } function pauseAll(ids = null) { const list = ids && ids.length ? ids.map(normalizeUsername) : [...sessions.keys()]; list.forEach(id => pause(id)); } function resumeAll(ids = null) { const list = ids && ids.length ? ids.map(normalizeUsername) : [...sessions.keys()]; list.forEach(id => resume(id)); } function streamMaxHeight() { return Number(store.state.settings.maxStreamHeight) || 0; } function hlsLevelForMaxHeight(hls, maxHeight = streamMaxHeight()) { const levels = Array.isArray(hls?.levels) ? hls.levels : []; if (!levels.length || !maxHeight) return -1; let best = -1; let bestHeight = 0; let smallest = 0; let smallestHeight = Number(levels[0]?.height) || Infinity; levels.forEach((level, idx) => { const height = Number(level?.height) || 0; if (height && height < smallestHeight) { smallest = idx; smallestHeight = height; } if (height && height <= maxHeight && height >= bestHeight) { best = idx; bestHeight = height; } }); return best >= 0 ? best : smallest; } function applyHlsQuality(hls) { if (!hls) return; const level = hlsLevelForMaxHeight(hls); try { hls.autoLevelCapping = level; } catch (_) {} try { hls.loadLevel = level; } catch (_) {} try { hls.currentLevel = level; } catch (_) {} } function refreshQuality() { sessions.forEach(s => { if (s?.hls) applyHlsQuality(s.hls); }); } async function connect(id) { id = normalizeUsername(id); let s = sessions.get(id); if (!s) { s = { retryCount: 0 }; sessions.set(id, s); } // 新请求开始前中止旧请求。删除房间或快速刷新时,旧请求返回也不会再创建 video。 if (s.abortController) { try { s.abortController.abort(); } catch (_) {} } const ac = new AbortController(); s.abortController = ac; EventBus.emit('room:loading', id); setStatus(id, 'loading'); let data; try { data = await fetchContext(id, ac.signal); } catch (e) { if (ac.signal.aborted || !sessions.has(id) || sessions.get(id) !== s) return; setStatus(id, 'error', { errorMsg: 'request failed', transient: true }); s.retryCount = (s.retryCount || 0) + 1; const wait = Math.min(60000, store.state.settings.pollMs.error * Math.pow(1.6, s.retryCount)); schedulePoll(id, wait); return; } if (ac.signal.aborted || !sessions.has(id) || sessions.get(id) !== s) return; if (s.abortController === ac) s.abortController = null; s.retryCount = 0; const cfg = store.state.settings.pollMs; if (data.room_status === 'offline') { setStatus(id, 'offline'); destroyPlayer(id); s = sessions.get(id) || s; s.video = null; sessions.set(id, s); schedulePoll(id, cfg.offline); return; } if (data.room_status === 'private' || data.room_status === 'hidden' || data.room_status === 'away') { setStatus(id, 'private', { privateLabel: data.room_status }); destroyPlayer(id); sessions.set(id, sessions.get(id) || s); schedulePoll(id, cfg.private); return; } if (!data.hls_source) { setStatus(id, 'error', { errorMsg: 'no stream' }); schedulePoll(id, cfg.error); return; } if (!isSafeStreamUrl(data.hls_source)) { setStatus(id, 'error', { errorMsg: 'invalid stream url' }); schedulePoll(id, cfg.error); return; } // 在线 —— 触发 UI 创建 video,再回调 attach。 // v15.5: 如果只是例行探测且流地址没变、video 仍在播放,不重建 video/HLS。 // 这能消除多窗口/多房间场景中周期性 attach/detach 造成的闪屏。 if (!sessions.has(id) || sessions.get(id) !== s) return; const prevSource = s.hlsSource; const hasLiveVideo = !!s.video && !s.video.ended && (s.hls || s.video.src || s.video.srcObject); const sameActiveStream = s.status === 'online' && hasLiveVideo && prevSource === data.hls_source; s.hlsSource = data.hls_source; setStatus(id, 'online'); if (!sessions.has(id) || sessions.get(id) !== s) return; if (!sameActiveStream) EventBus.emit('room:online', { id, hlsSource: data.hls_source }); if (sessions.has(id) && sessions.get(id) === s) schedulePoll(id, cfg.online || onlinePollMs()); } function startHls(id, hlsSource) { id = normalizeUsername(id); const s = sessions.get(id); if (!s || !s.video || !isSafeStreamUrl(hlsSource)) return; const video = s.video; s.hlsSource = hlsSource; destroyHls(s); if (window.Hls && Hls.isSupported()) { const hls = new Hls({ liveDurationInfinity: true, lowLatencyMode: true, maxBufferLength: 10 }); hls.loadSource(hlsSource); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => { if (sessions.get(id)?.hls === hls) { applyHlsQuality(hls); if (sessions.get(id)?.userPaused) video.pause(); else video.play().catch(() => {}); } }); if (Hls.Events.LEVELS_UPDATED) { hls.on(Hls.Events.LEVELS_UPDATED, () => { if (sessions.get(id)?.hls === hls) applyHlsQuality(hls); }); } hls.on(Hls.Events.ERROR, (_, data) => { if (!data.fatal || sessions.get(id)?.hls !== hls) return; // 致命错误:尝试 recover,多次失败后回到状态轮询 s.retryCount = (s.retryCount || 0) + 1; if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { try { hls.startLoad(); } catch (_) {} } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { try { hls.recoverMediaError(); } catch (_) {} } if (s.retryCount > 3) { destroyPlayer(id); // 退避后重新走 connect 流程(重新拉接口) const wait = Math.min(30000, 2000 * Math.pow(1.5, s.retryCount)); if (sessions.has(id)) schedulePoll(id, wait); setStatus(id, 'error', { errorMsg: 'stream broken' }); } }); s.hls = hls; } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = hlsSource; video.addEventListener('loadedmetadata', () => { if (sessions.get(id)?.userPaused) video.pause(); else video.play().catch(() => {}); }, { once: true }); } } function refresh(id) { id = normalizeUsername(id); // 强制重连:清理旧 hls + video + timer,再走 connect,避免刷新时双音轨。 const s = sessions.get(id); if (s) { clearPoll(s) destroyHls(s); hardStopVideo(s.video); s.video = null; s.hlsSource = null; } if (!sessions.has(id)) sessions.set(id, { retryCount: 0 }); connect(id); } function refreshAll() { for (const id of [...sessions.keys()]) refresh(id); } function start(id) { id = normalizeUsername(id); // 幂等:已有 session 就跳过(避免切分组时重复启动轮询) if (sessions.has(id)) return; sessions.set(id, { retryCount: 0 }); connect(id); } function stop(id) { id = normalizeUsername(id); destroyPlayer(id); sessions.delete(id); } function stopAll() { for (const id of [...sessions.keys()]) stop(id); stopAllPageMedia(); } function has(id) { id = normalizeUsername(id); return sessions.has(id); } return { start, stop, stopAll, refresh, refreshAll, attachVideo, detachVideo, startHls, has, pause, resume, togglePause, isPaused, pauseAll, resumeAll, refreshQuality }; } /* ============================================================= * 4.5. 用户名过滤 / Username validation * 已移除 followed-cams 自动抓取导入;这里只保留全站通用的用户名白名单与保留路径过滤。 * ============================================================= */ const USERNAME_EXCLUDE = new Set([ 'tags', 'tag', 'auth', 'followed-cams', 'multicam', 'events', 'jobs', 'terms', 'privacy', 'support', 'billing', 'accounts', 'b', 'p', 'apps', 'affiliates', 'static', 'feedback', 'sitemap', 'home', 'about', 'rules', 'login', 'logout', 'signup', 'female-cams', 'male-cams', 'couple-cams', 'trans-cams', 's', 'shortcuts', 'roomlist', 'photo_videos', 'in-private-show', 'external_link', 'dmca', 'contact', 'mobile', 'tipping', 'tokens', 'token-purchase', 'social', 'wiki', 'directory', 'spy-on-cams', 'private-shows', 'app', 'photos-videos', 'pm', 'inbox', 'find-friends', 'broadcasters', 'broadcast', 'discover', 'top', 'new', 'gold-shows', 'language', 'settings', 'en', 'es', 'de', 'fr', 'it', 'ja', 'ko', 'pt', 'ru', 'zh', ]); function isLikelyUsername(name) { name = normalizeUsername(name); if (!name) return false; if (USERNAME_EXCLUDE.has(name.toLowerCase())) return false; return usernameSyntaxOk(name); } /* ============================================================= * 5. 事件总线 / EventBus * ============================================================= */ const EventBus = (() => { const m = new Map(); return { on(ev, fn) { (m.get(ev) || m.set(ev, new Set()).get(ev)).add(fn); return () => m.get(ev).delete(fn); }, emit(ev, payload) { (m.get(ev) || []).forEach(fn => { try { fn(payload); } catch (_) {} }); }, }; })(); /* ============================================================= * 6. 模式分发 * ============================================================= */ const isWorkstation = new URLSearchParams(location.search).get('multicam_mode') === '1'; if (isWorkstation) initWorkstation(); else initInjector(); /* ============================================================= * 7. 普通页面注入:浮动按钮 + 快捷键 * ============================================================= */ function initInjector() { const ROOM_PATH = /^\/([a-zA-Z0-9_-]+)\/?$/; // ---- 当前房间(响应式:URL / canonical / DOM 变化时自动重算)---- let currentRoom = null; const currentRoomSubs = new Set(); function extractRoomFromPath(pathname) { const m = (pathname || '').match(ROOM_PATH); const name = m ? normalizeUsername(m[1]) : null; return isLikelyUsername(name) ? name : null; } function extractRoomFromUrl(url) { if (!url) return null; try { return extractRoomFromPath(new URL(url, location.origin).pathname); } catch (_) { return null; } } function detectCurrentRoom() { // 1. URL 路径:/<username>/ let next = extractRoomFromPath(location.pathname); if (next) return next; // 2. SPA 场景:URL/DOM 被异步替换时,canonical/og:url 往往先更新。 const candidates = [ document.querySelector('link[rel="canonical"]')?.href, document.querySelector('meta[property="og:url"]')?.content, document.querySelector('meta[name="twitter:url"]')?.content, ]; for (const href of candidates) { next = extractRoomFromUrl(href); if (next) return next; } return null; } function recalcCurrentRoom() { const next = detectCurrentRoom(); if (next !== currentRoom) { currentRoom = next; currentRoomSubs.forEach(fn => { try { fn(currentRoom); } catch (_) {} }); } } const recalcCurrentRoomSoon = debounce(recalcCurrentRoom, 180); recalcCurrentRoom(); // hook history pushState/replaceState(chaturbate 是 SPA,URL 变化时不刷新) ['pushState', 'replaceState'].forEach(method => { const orig = history[method]; history[method] = function () { const ret = orig.apply(this, arguments); setTimeout(recalcCurrentRoom, 0); setTimeout(recalcCurrentRoom, 250); setTimeout(recalcCurrentRoom, 900); return ret; }; }); window.addEventListener('popstate', () => setTimeout(recalcCurrentRoom, 50)); window.addEventListener('hashchange', () => setTimeout(recalcCurrentRoom, 50)); // 监听同标签内的异步切换,解决「换人后加入状态不变」。 try { const routeMo = new MutationObserver(recalcCurrentRoomSoon); if (document.head) routeMo.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ['href', 'content'] }); if (document.body) routeMo.observe(document.body, { childList: true, subtree: true }); } catch (_) {} // poll 兜底(万一还有别的 navigation 路径漏了);隐藏标签页降低频率。 let routePollTimer = 0; function scheduleRoutePoll() { clearTimeout(routePollTimer); const ms = document.hidden ? INJECTOR_ROUTE_POLL_HIDDEN_MS : INJECTOR_ROUTE_POLL_VISIBLE_MS; routePollTimer = setTimeout(() => { recalcCurrentRoom(); scheduleRoutePoll(); }, ms); } scheduleRoutePoll(); document.addEventListener('visibilitychange', scheduleRoutePoll); // 跨标签页同步 const storageSubs = new Set(); const fireStorageSubs = () => storageSubs.forEach(fn => { try { fn(); } catch (_) {} }); window.addEventListener('storage', (e) => { if (e.key === STORE_KEY) fireStorageSubs(); }); window.addEventListener('ryujo_multicam_storage', fireStorageSubs); function refreshInjectorState() { recalcCurrentRoom(); fireStorageSubs(); } // SPA 路由兜底:CB 有些入口不会稳定走 pushState/popstate。 // 用点击、hash、pageshow、DOM 变化做低成本监听,保证同标签切换主播后“已加入/加入”状态实时变。 const routeCheckSoon = debounce(() => recalcCurrentRoom(), 80); document.addEventListener('click', (e) => { const a = e.target && e.target.closest ? e.target.closest('a[href]') : null; if (!a) return; setTimeout(routeCheckSoon, 0); setTimeout(routeCheckSoon, 160); setTimeout(routeCheckSoon, 650); }, true); window.addEventListener('hashchange', routeCheckSoon); window.addEventListener('pageshow', routeCheckSoon); document.addEventListener('visibilitychange', routeCheckSoon); try { const routeMo = new MutationObserver(routeCheckSoon); routeMo.observe(document.body, { childList: true, subtree: true }); } catch (_) {} const buildWorkstationUrl = () => { const u = new URL(location.href); u.searchParams.set('multicam_mode', '1'); u.hash = ''; return u.toString(); }; const openWorkstationNew = () => { // 新开工作台前静音并停止当前页面媒体,避免“新工作台已开但旧页面还在出声”。 stopAllPageMedia(); openNoopener(buildWorkstationUrl()); }; const openWorkstationHere = () => { // 把工作台直接挂到当前页面(破坏性,但有用户要求) // 实现:跳转到 ?multicam_mode=1 同 tab;跳转前先停掉当前页面媒体。 stopAllPageMedia(); location.href = buildWorkstationUrl(); }; function getPrimaryPageVideo() { const videos = [...document.querySelectorAll('video')] .filter(v => v && v.offsetWidth > 120 && v.offsetHeight > 80 && !v.ended); return videos.sort((a, b) => (b.offsetWidth * b.offsetHeight) - (a.offsetWidth * a.offsetHeight))[0] || null; } function captureCurrentPageVideo() { const video = getPrimaryPageVideo(); if (!video || !video.videoWidth || !video.videoHeight) { toast(t('dockVideoMissing')); return; } try { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height); canvas.toBlob(blob => { if (!blob) { toast(t('captureFailed')); return; } downloadBlob(blob, `roomgrid-page-${safeFilePart(currentRoom || 'video')}-${stampForFile()}.png`); toast(t('screenshotSaved')); }, 'image/png'); } catch (_) { toast(t('captureFailed')); } } async function toggleCurrentPagePiP() { const video = getPrimaryPageVideo(); if (!video) { toast(t('dockVideoMissing')); return; } try { if (document.pictureInPictureElement) await document.exitPictureInPicture(); else await video.requestPictureInPicture(); } catch (_) { toast(t('dockVideoMissing')); } } function toggleCurrentPagePlayback() { const video = getPrimaryPageVideo(); if (!video) { toast(t('dockVideoMissing')); return; } if (video.paused) video.play?.().catch?.(() => {}); else video.pause?.(); } function toggleCurrentPageMute() { const video = getPrimaryPageVideo(); if (!video) { toast(t('dockVideoMissing')); return; } video.muted = !video.muted; if (!video.muted && video.volume === 0) video.volume = 0.5; } function queueCurrentRoomRecording() { if (!currentRoom) { toast(t('dockNoRoom')); return; } if (!Storage.has(currentRoom)) Storage.add(currentRoom); setRecordingIntent(currentRoom, true); toast(t('dockRecordQueued')); openWorkstationNew(); } // ---- RoomGrid 工具坞 ---- const dockStyle = $('style', { html: trustedHtml(` .roomgrid-dock { position:fixed; right:18px; bottom:18px; z-index:99999; width:292px; font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; color:#f8fafc; user-select:none; } .roomgrid-dock-card { border:1px solid rgba(255,255,255,.16); border-radius:16px; background:rgba(15,23,42,.86); box-shadow:0 18px 48px rgba(15,23,42,.36); backdrop-filter:blur(18px); -webkit-backdrop-filter:blur(18px); overflow:hidden; } .roomgrid-dock-head { width:100%; border:0; display:flex; align-items:center; gap:10px; padding:10px 12px; cursor:pointer; color:inherit; background:linear-gradient(135deg,rgba(37,99,235,.88),rgba(20,184,166,.82)); text-align:left; } .roomgrid-dock-mark { width:34px; height:34px; border-radius:10px; display:grid; place-items:center; background:rgba(255,255,255,.16); font-weight:900; letter-spacing:.02em; } .roomgrid-dock-title { font-size:14px; font-weight:850; line-height:1.1; } .roomgrid-dock-sub { margin-top:2px; font-size:11px; color:rgba(255,255,255,.78); } .roomgrid-dock-chevron { margin-left:auto; font-size:16px; opacity:.82; } .roomgrid-dock-body { display:grid; gap:10px; padding:10px; } .roomgrid-dock-room { font-size:12px; color:#cbd5e1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .roomgrid-dock-actions { display:grid; grid-template-columns:1fr 1fr; gap:7px; } .roomgrid-dock-action { min-height:34px; border:1px solid rgba(255,255,255,.12); border-radius:9px; background:rgba(255,255,255,.07); color:#f8fafc; cursor:pointer; font-size:12px; font-weight:700; text-align:left; padding:7px 9px; } .roomgrid-dock-action:hover { background:rgba(255,255,255,.13); border-color:rgba(255,255,255,.22); } .roomgrid-dock-action.primary { background:rgba(37,99,235,.78); border-color:rgba(147,197,253,.44); } .roomgrid-dock-action.success { background:rgba(22,163,74,.70); border-color:rgba(134,239,172,.40); } .roomgrid-dock-action.warn { background:rgba(217,119,6,.68); border-color:rgba(253,186,116,.36); } .roomgrid-dock-foot { display:flex; justify-content:space-between; gap:8px; border-top:1px solid rgba(255,255,255,.10); padding-top:9px; } .roomgrid-dock-link { border:0; background:transparent; color:#cbd5e1; cursor:pointer; font-size:11px; padding:2px 0; } .roomgrid-dock-link:hover { color:#fff; text-decoration:underline; } .roomgrid-dock.is-collapsed { width:auto; } .roomgrid-dock.is-collapsed .roomgrid-dock-card { border-radius:999px; } .roomgrid-dock.is-collapsed .roomgrid-dock-body { display:none; } .roomgrid-dock.is-collapsed .roomgrid-dock-head { border-radius:999px; padding:8px 10px; } .roomgrid-dock.is-collapsed .roomgrid-dock-sub, .roomgrid-dock.is-collapsed .roomgrid-dock-chevron { display:none; } `)}); document.head.appendChild(dockStyle); const root = $('div', { class: 'roomgrid-dock' }); let collapsed = localStorage.getItem('ryujo_fab_collapsed') === '1'; let dragged = false, sx, sy, ox, oy; const roomLine = $('div', { class: 'roomgrid-dock-room' }, t('dockNoRoom')); const addBtn = $('button', { class: 'roomgrid-dock-action success', onclick: () => toggleCurrentRoomSaved() }, t('dockAdd')); const head = $('button', { class: 'roomgrid-dock-head', title: 'Alt+M / Alt+A', onclick: () => { if (!dragged) toggleDock(); } }, [ $('span', { class: 'roomgrid-dock-mark' }, '▦'), $('span', {}, [ $('div', { class: 'roomgrid-dock-title' }, 'RoomGrid'), $('div', { class: 'roomgrid-dock-sub' }, t('dockSubtitle')), ]), $('span', { class: 'roomgrid-dock-chevron' }, '▾'), ]); const body = $('div', { class: 'roomgrid-dock-body' }, [ roomLine, $('div', { class: 'roomgrid-dock-actions' }, [ $('button', { class: 'roomgrid-dock-action primary', onclick: openWorkstationNew }, t('dockOpen')), addBtn, $('button', { class: 'roomgrid-dock-action warn', onclick: queueCurrentRoomRecording }, t('dockRecord')), $('button', { class: 'roomgrid-dock-action', onclick: captureCurrentPageVideo }, t('dockScreenshot')), $('button', { class: 'roomgrid-dock-action', onclick: toggleCurrentPagePiP }, t('dockPip')), $('button', { class: 'roomgrid-dock-action', onclick: toggleCurrentPagePlayback }, t('dockPause')), $('button', { class: 'roomgrid-dock-action', onclick: toggleCurrentPageMute }, t('dockMute')), $('button', { class: 'roomgrid-dock-action', onclick: () => { if (confirm(t('openWorkstationHereConfirm'))) openWorkstationHere(); } }, t('dockOpenHere')), ]), $('div', { class: 'roomgrid-dock-foot' }, [ $('button', { class: 'roomgrid-dock-link', onclick: () => { const s = Storage.load(); toast(t('memoryStat', s.rooms.length), 2500); } }, t('memoryView')), $('button', { class: 'roomgrid-dock-link', onclick: () => { collapsed = true; syncDock(); localStorage.setItem('ryujo_fab_collapsed', '1'); } }, t('collapseFAB')), ]), ]); function toggleCurrentRoomSaved() { if (!currentRoom) return; if (Storage.has(currentRoom)) { if (Storage.remove(currentRoom)) toast(t('removedNamed', currentRoom)); } else { const r = Storage.add(currentRoom); toast(r === 'added' ? t('addedNamed', currentRoom) : r === 'exists' ? t('exists') : t('addFailed')); } refreshInjectorState(); updateDockRoom(); } function updateDockRoom() { roomLine.textContent = currentRoom ? t('dockCurrentRoom', currentRoom) : t('dockNoRoom'); addBtn.textContent = currentRoom && Storage.has(currentRoom) ? t('dockRemove') : t('dockAdd'); addBtn.classList.toggle('success', !(currentRoom && Storage.has(currentRoom))); addBtn.classList.toggle('warn', !!(currentRoom && Storage.has(currentRoom))); addBtn.disabled = !currentRoom; addBtn.style.opacity = currentRoom ? '1' : '.55'; } currentRoomSubs.add(updateDockRoom); storageSubs.add(updateDockRoom); function syncDock() { root.classList.toggle('is-collapsed', !!collapsed); root.querySelector('.roomgrid-dock-chevron').textContent = collapsed ? '▴' : '▾'; } const toggleDock = () => { collapsed = !collapsed; localStorage.setItem('ryujo_fab_collapsed', collapsed ? '1' : '0'); syncDock(); }; root.appendChild($('div', { class: 'roomgrid-dock-card' }, [head, body])); document.body.appendChild(root); updateDockRoom(); syncDock(); head.addEventListener('mousedown', (e) => { sx = e.clientX; sy = e.clientY; const rect = root.getBoundingClientRect(); ox = rect.left; oy = rect.top; dragged = false; const move = (ev) => { if (Math.abs(ev.clientX - sx) + Math.abs(ev.clientY - sy) > 6) dragged = true; if (dragged) { root.style.left = (ox + ev.clientX - sx) + 'px'; root.style.top = (oy + ev.clientY - sy) + 'px'; root.style.right = 'auto'; root.style.bottom = 'auto'; } }; const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }); // —— 快捷键 —— document.addEventListener('keydown', (e) => { if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (e.key.toLowerCase() === 'm') { e.preventDefault(); openWorkstationNew(); } if (e.key.toLowerCase() === 'a' && currentRoom) { e.preventDefault(); if (Storage.has(currentRoom)) toast(t('alreadyAdded', currentRoom)); else { const r = Storage.add(currentRoom); toast(r === 'added' ? t('addedNamed', currentRoom) : t('addFailed')); } refreshInjectorState(); updateDockRoom(); } }); // —— Toast —— function toast(text, ms = 1800) { const toastEl = $('div', { style: { position: 'fixed', left: '50%', top: '20%', transform: 'translateX(-50%)', background: 'rgba(20,20,24,.95)', color: '#fff', padding: '12px 20px', borderRadius: '10px', zIndex: 999999, fontSize: '14px', fontFamily: 'system-ui', boxShadow: '0 8px 24px rgba(0,0,0,.5)', backdropFilter: 'blur(10px)', }, }, String(text || '')); document.body.appendChild(toastEl); setTimeout(() => toastEl.remove(), ms); } // =========================================================== // QuickAdd —— 在 chaturbate 主页/分类页的房间卡片上注入「+」按钮 // 用 MutationObserver 监听动态加载,[data-username] 是稳定锚点 // =========================================================== initQuickAdd(); function initQuickAdd() { // 注入 QuickAdd 按钮的样式 const style = $('style', { html: trustedHtml(` .multicam-quick-add { position: absolute; top: 6px; right: 6px; z-index: 99; min-width: 58px; height: 27px; border-radius: 999px; border: 1px solid rgba(255,255,255,.18); cursor: pointer; background: rgba(15,23,42,.62); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 11px; font-weight: 800; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .15s, background .15s, transform .15s; padding: 0 9px; line-height: 1; box-shadow: 0 8px 22px rgba(0,0,0,.28); } .multicam-quick-add:hover { background: #2563eb; transform: translateY(-1px); } .multicam-qa-host:hover .multicam-quick-add, .multicam-quick-add:focus, .multicam-quick-add.added { opacity: 1; } .multicam-quick-add.added { background: #16a34a; opacity: 1; } .multicam-quick-add.added:hover { background: #c62828; } `)}); document.head.appendChild(style); const observed = new WeakSet(); // 已注入按钮的元素 const elToUsername = new WeakMap(); // 元素 → 用户名 function updateBtnState(btn, username) { const inList = Storage.has(username); btn.classList.toggle('added', inList); btn.textContent = inList ? (LANG === 'zh' ? '已保存' : 'Saved') : 'Grid +'; btn.title = inList ? t('quickRemoveTitle') : t('quickAddTitle'); } function injectButton(host, username) { if (observed.has(host)) return; observed.add(host); elToUsername.set(host, username); host.classList.add('multicam-qa-host'); // 给 host 提供定位上下文 const cs = getComputedStyle(host); if (cs.position === 'static') host.style.position = 'relative'; const btn = $('button', { class: 'multicam-quick-add', dataset: { multicamUsername: username, roomgridQuickAdd: '1' }, onclick: (e) => { e.preventDefault(); e.stopPropagation(); if (Storage.has(username)) { if (Storage.remove(username)) toast(t('removedNamed', username)); } else { const r = Storage.add(username); if (r === 'added') toast(t('addedNamed', username)); } refreshInjectorState(); updateBtnState(btn, username); }, }, 'Grid +'); host.appendChild(btn); updateBtnState(btn, username); } // ---- 检测候选 host 元素 ---- function scan() { if (document.hidden) return; // 策略 1: 元素本身有 data-username(最强信号) let checked = 0; for (const el of document.querySelectorAll('[data-username]')) { if (++checked > 900) break; const u = normalizeUsername(el.getAttribute('data-username')); if (!u || !isLikelyUsername(u)) continue; // 跳过过小的元素(如聊天里的 avatar) if (el.offsetWidth < 100 || el.offsetHeight < 80) continue; injectButton(el, u); } // 策略 2: 房间卡片 li,包含一个指向 /<username>/ 的 <a> // 兼容 chaturbate 列表页结构 checked = 0; for (const li of document.querySelectorAll('li')) { if (++checked > 900) break; if (observed.has(li)) continue; if (li.offsetWidth < 100 || li.offsetHeight < 80) continue; // 找第一个匹配主播路径的 a const a = li.querySelector('a[href]'); if (!a) continue; const href = a.getAttribute('href') || ''; let path = href; if (/^https?:\/\//i.test(href)) { try { path = new URL(href).pathname; } catch (_) { continue; } } const m = path.match(ROOM_PATH); if (!m) continue; const u = normalizeUsername(m[1]); if (!isLikelyUsername(u)) continue; injectButton(li, u); } } // 节流 scan let scanScheduled = false; function scheduleScan() { if (document.hidden || scanScheduled) return; scanScheduled = true; const run = () => { scanScheduled = false; scan(); }; try { if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 900 }); else setTimeout(run, 260); } catch (_) { setTimeout(run, 260); } } document.addEventListener('visibilitychange', () => { if (!document.hidden) scheduleScan(); }); scan(); const mo = new MutationObserver(scheduleScan); mo.observe(document.body, { childList: true, subtree: true }); // 跨标签页 storage 同步:刷新所有按钮状态 storageSubs.add(() => { document.querySelectorAll('.multicam-quick-add').forEach(btn => { const u = btn.dataset.multicamUsername || btn.dataset.username; if (u) updateBtnState(btn, u); }); }); } } /* ============================================================= * 8. 工作台 / Workstation * ============================================================= */ function initWorkstation() { document.title = t('title'); // 在当前页打开工作台时,先停止原页面自带的 video/audio,避免页面清空后仍有声音。 stopAllPageMedia(); document.body.replaceChildren(); const store = createStore(); const service = createRoomService(store); Notify.init(); // 离开工作台页时,统一停流,避免浏览器残留音轨。 window.addEventListener('pagehide', () => { try { stopAllRecordings({ final: true, silent: true }); } catch (_) {} try { service.stopAll(); } catch (_) { stopAllPageMedia(); } }); window.addEventListener('beforeunload', (e) => { if (!store.state.settings.recordingExitWarn || !recordings.size) return; const msg = t('recordingExitWarnMessage'); e.preventDefault(); e.returnValue = msg; return msg; }); // 全局样式 document.head.appendChild($('style', { html: trustedHtml(` :root { color-scheme: light; /* —— 更像标准 SaaS 产品的浅色设计系统:更清晰层级、更统一圆角、更轻的高光与阴影 —— */ --bg: #f4f7fb; --bg-elevated: rgba(255,255,255,.88); --bg-card: #ffffff; --bg-input: #ffffff; --bg-hover: rgba(15,23,42,.05); --bg-overlay: rgba(15,23,42,.18); --border: #e6ebf2; --border-strong: #d3dce8; --text: #0f172a; --text-secondary: #334155; --text-muted: #64748b; --accent: #2563eb; --accent-hover: #1d4ed8; --accent-soft: rgba(37,99,235,.10); --info: #2563eb; --info-soft: rgba(37,99,235,.10); --success: #16a34a; --danger: #dc2626; --warning: #d97706; --radius-sm: 10px; --radius-md: 14px; --radius-lg: 18px; --shadow-sm: 0 1px 2px rgba(15,23,42,.04), 0 4px 14px rgba(15,23,42,.04); --shadow-md: 0 8px 24px rgba(15,23,42,.08); --shadow-lg: 0 18px 48px rgba(15,23,42,.12); } * { box-sizing: border-box; } body { margin:0; background:radial-gradient(circle at top left, rgba(37,99,235,.06), transparent 24%), linear-gradient(180deg, #f8fbff 0%, var(--bg) 100%); color:var(--text); font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow:hidden; } button { font-family:inherit; } ::-webkit-scrollbar { width:10px; height:10px; } ::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:999px; border:2px solid transparent; background-clip:padding-box; } ::-webkit-scrollbar-thumb:hover { background:#c1cada; background-clip:padding-box; } ::-webkit-scrollbar-track { background:transparent; } video:focus { outline:none; } .cam-card { position:relative; background:#f8fafc; border-radius:var(--radius-md); overflow:hidden; border:1px solid rgba(15,23,42,.08); box-shadow:var(--shadow-sm); transition:border-color .2s, box-shadow .2s, transform .15s; display:block; min-height:0; min-width:0; width:100%; height:100%; isolation:isolate; contain:layout paint; } .grid.view-grid { align-content:start; align-items:stretch; } .grid.view-grid .cam-card { aspect-ratio:auto; } .cam-card.dragging { opacity:.4; transform:scale(.96); } .cam-card.drop-before { box-shadow: inset 3px 0 0 0 var(--accent), inset 0 3px 0 0 var(--accent); } .cam-card.drop-after { box-shadow: inset -3px 0 0 0 var(--accent), inset 0 -3px 0 0 var(--accent); } /* 避免使用 .overlay 通用类名:CB 站点样式会把它拉伸成整卡黑色遮罩。 */ .cam-card .mc-hover-ui { opacity:0; transition:opacity .14s ease, transform .14s ease; pointer-events:none; transform:translateY(-2px); background:transparent !important; width:auto !important; height:auto !important; filter:none !important; mix-blend-mode:normal !important; } .cam-card:hover .mc-hover-ui, .cam-card:not(.compact) .mc-hover-ui { opacity:1; transform:translateY(0); } .cam-card .mc-hover-ui.drag-handle { position:absolute !important; top:6px !important; left:50% !important; right:auto !important; bottom:auto !important; transform:translateX(-50%) !important; } .cam-card .mc-hover-ui.ops-row { position:absolute !important; top:8px !important; right:8px !important; left:auto !important; bottom:auto !important; display:flex !important; } .cam-card .mc-hover-ui button, .cam-card .mc-hover-ui[draggable="true"] { pointer-events:auto; } .cam-card:hover .cam-video, .cam-card .cam-video:hover { filter:none !important; opacity:1 !important; } .cam-card::before, .cam-card::after { pointer-events:none; background:transparent !important; } .cam-video { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; background:transparent; z-index:1; pointer-events:none; } .cam-card.video-zoomed .cam-video { cursor:grab; } .cam-card.video-panning .cam-video { cursor:grabbing !important; } .cam-video::-webkit-media-controls, .cam-video::-webkit-media-controls-enclosure { display:none !important; opacity:0 !important; } .cam-card.not-online { background:linear-gradient(135deg, rgba(255,255,255,.92), rgba(241,245,249,.96)); } .status-layer { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:6px; color:var(--text-muted); font-size:12px; text-align:center; padding:16px; pointer-events:none; background:transparent; z-index:4; } .status-layer .status-icon { font-size:24px; line-height:1; opacity:.9; } .status-layer .status-chip { font-weight:650; padding:3px 9px; border-radius:999px; background:rgba(17,24,39,.045); border:1px solid rgba(17,24,39,.06); } .cam-card.flash { animation: flash 1.5s ease 3; } @keyframes flash { 0%,100% { box-shadow:0 0 0 0 rgba(35,165,89,0); border-color:var(--border); } 50% { box-shadow:0 0 0 4px rgba(35,165,89,.55); border-color:var(--success); } } /* —— 卡片浮层控件:透明毛玻璃,避免遮挡画面 —— */ .cam-card video { -webkit-user-drag:none; user-drag:none; } .pill { display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:999px; font-size:11px; font-weight:700; backdrop-filter:blur(8px) !important; -webkit-backdrop-filter:blur(8px) !important; background:rgba(255,255,255,.86); color:var(--text); border:1px solid rgba(255,255,255,.55); box-shadow:0 6px 16px rgba(15,23,42,.08); } .dot { width:7px; height:7px; border-radius:50%; display:inline-block; } .svg-icon { width:1em; height:1em; display:inline-block; vertical-align:-.16em; flex:0 0 auto; } .btn-label { white-space:nowrap; } .ctrl-btn, .seg button { display:inline-flex; align-items:center; justify-content:center; gap:7px; } .icon-btn .svg-icon { width:15px; height:15px; } .status-dot-large { width:12px; height:12px; border-radius:999px; display:inline-block; background:currentColor; box-shadow:0 0 0 6px currentColor; opacity:.18; } .icon-btn { background:rgba(255,255,255,.88); backdrop-filter:blur(10px) !important; -webkit-backdrop-filter:blur(10px) !important; border:1px solid rgba(255,255,255,.7); color:var(--text-secondary); width:29px; height:29px; border-radius:9px; cursor:pointer; box-shadow:0 4px 12px rgba(15,23,42,.08); display:flex; align-items:center; justify-content:center; font-size:12px; transition:background .15s, transform .15s, color .15s, border-color .15s, box-shadow .15s; } .icon-btn:hover { background:#fff; color:var(--accent); border-color:rgba(37,99,235,.2); transform:translateY(-1px); box-shadow:0 8px 18px rgba(15,23,42,.12); } .icon-btn.danger:hover { background:var(--danger); } .icon-btn.recording { background:var(--danger); color:#fff; animation: recordPulse 1s ease infinite; } .icon-btn.recording.waiting { background:var(--warning); color:#fff; animation:none; } .cam-card.recording { outline:2px solid var(--danger); outline-offset:2px; } .cam-card.recording-waiting { outline-color:var(--warning); } @keyframes recordPulse { 0%,100% { opacity:1; } 50% { opacity:.58; } } /* 拖拽手柄 */ .drag-handle { backdrop-filter:blur(8px) !important; -webkit-backdrop-filter:blur(8px) !important; border:1px solid rgba(255,255,255,.55); box-shadow:0 6px 16px rgba(15,23,42,.10); transition:background .15s, transform .15s, border-color .15s; } .drag-handle:hover { background:var(--accent) !important; color:#fff; border-color:transparent; transform:translateY(-1px); } /* —— 卡片响应式:根据宽度收起部分按钮 —— */ .cam-card.compact .ops-extra { display:none; } .cam-card.compact .pill .pill-text { display:none; } .cam-card.compact .name-label { font-size:11px; padding:3px 7px; max-width:55%; } .cam-card.tiny .ops-row { gap:3px; } .cam-card.tiny .icon-btn { width:22px; height:22px; font-size:10px; } .cam-card.tiny .drag-handle { padding:2px 10px; font-size:11px; } .cam-card.tiny .name-label { display:none; } .cam-card.tiny .pill { padding:2px 6px; font-size:10px; } /* —— 主屏(focus 模式)—— */ .grid.view-focus { display:grid; grid-template-areas:'main' 'bar' 'thumbs'; grid-template-rows:minmax(0,var(--focus-main-pct,68fr)) 10px minmax(108px,var(--focus-thumb-pct,32fr)); grid-template-columns:minmax(0,1fr); gap:12px; padding:18px; height:100%; overflow:hidden; } .grid.view-focus .focused-row { grid-area:main; min-height:0; min-width:0; display:grid; place-items:center; overflow:hidden; position:relative; z-index:1; } .grid.view-focus .focused-row .cam-card { max-width:100%; max-height:100%; flex:0 0 auto; min-height:0; } .grid.view-focus .focused-row .cam-card.is-focus-main { border-color:var(--accent); box-shadow:0 0 0 1px rgba(16,163,127,.25); } .grid.view-focus .resizer { grid-area:bar; height:8px; min-height:8px; cursor:ns-resize; background:transparent; position:relative; z-index:2; } .grid.view-focus .resizer::before { content:''; position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:50px; height:3px; border-radius:2px; background:var(--border-strong); transition:background .15s, width .15s; } .grid.view-focus .resizer:hover::before, .grid.view-focus .resizer.dragging::before { background:var(--accent); width:80px; } .grid.view-focus .thumbs-row { grid-area:thumbs; min-height:0; min-width:0; display:grid; grid-template-columns:repeat(auto-fill,minmax(var(--focus-thumb-min,180px),1fr)); grid-auto-flow:row; gap:12px; overflow:auto; padding:2px 2px 8px; align-content:start; align-items:start; position:relative; z-index:1; } .grid.view-focus .thumbs-row .cam-card { cursor:pointer; width:100%; height:auto; min-height:0; aspect-ratio:var(--focus-thumb-aspect,16/9); } .grid.view-focus .thumbs-row .cam-card:hover { border-color:var(--accent); } /* —— 控件 —— */ .group-tab { padding:10px 12px; border-radius:12px; cursor:pointer; font-size:13px; background:transparent; border:none; color:var(--text-secondary); text-align:left; transition:background .12s, color .12s, border-color .12s, box-shadow .12s; border:1px solid transparent; display:flex; align-items:center; justify-content:space-between; gap:8px; width:100%; } .group-tab:hover { background:#fff; color:var(--text); border-color:var(--border); box-shadow:var(--shadow-sm); } .group-tab.active { background:linear-gradient(180deg, rgba(37,99,235,.10), rgba(37,99,235,.06)); color:var(--accent); border-color:rgba(37,99,235,.18); box-shadow:var(--shadow-sm); } .group-tab.drop-target { background:var(--accent-soft); outline:1.5px dashed var(--accent); } .ctrl-input { background:var(--bg-input); border:1px solid var(--border); color:var(--text); padding:8px 12px; min-height:38px; border-radius:12px; font-size:13px; outline:none; box-shadow:0 1px 0 rgba(255,255,255,.7) inset; transition:border-color .15s, background .15s, box-shadow .15s; } .ctrl-input:focus { border-color:rgba(37,99,235,.38); box-shadow:0 0 0 4px rgba(37,99,235,.10); } .ctrl-input:hover:not(:focus) { border-color:var(--border-strong); } .ctrl-btn { background:var(--bg-input); border:1px solid var(--border); color:var(--text); padding:8px 12px; min-height:38px; border-radius:12px; font-size:13px; cursor:pointer; box-shadow:0 1px 0 rgba(255,255,255,.7) inset; transition:background .15s, border-color .15s, transform .15s, box-shadow .15s; } .ctrl-btn:hover { background:#fff; border-color:var(--border-strong); transform:translateY(-1px); box-shadow:var(--shadow-sm); } .ctrl-btn.primary { background:linear-gradient(180deg, rgba(37,99,235,.12), rgba(37,99,235,.08)); border-color:rgba(37,99,235,.28); color:var(--accent); } .ctrl-btn.primary:hover { background:var(--accent); color:#fff; border-color:var(--accent); } .ctrl-btn.disabled, .ctrl-btn:disabled { opacity:.5; cursor:not-allowed; } .toggle { display:inline-flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary); padding:7px 10px; border-radius:12px; user-select:none; border:1px solid transparent; transition:background .12s, border-color .12s; } .toggle:hover { background:#fff; color:var(--text); border-color:var(--border); } .toggle input { accent-color:var(--accent); } .empty-state { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text-muted); gap:16px; padding:40px; text-align:center; } .menu-pop { position:absolute; background:var(--bg-elevated); backdrop-filter:none; border:1px solid var(--border); border-radius:8px; padding:5px; min-width:180px; box-shadow:var(--shadow-lg); z-index:1000; display:flex; flex-direction:column; gap:1px; } .menu-pop button { background:transparent; border:none; color:var(--text); padding:8px 12px; text-align:left; cursor:pointer; border-radius:5px; font-size:13px; } .menu-pop button:hover { background:var(--bg-hover); } .menu-pop button.danger { color:var(--danger); } .menu-pop button.danger:hover { background:rgba(242,63,67,.15); } /* —— 视图切换段 segmented control —— */ .seg { display:inline-flex; background:var(--bg-input); border:1px solid var(--border); border-radius:12px; padding:3px; box-shadow:0 1px 0 rgba(255,255,255,.7) inset; } .seg button { background:transparent; border:none; color:var(--text-secondary); padding:6px 12px; border-radius:9px; cursor:pointer; font-size:12px; font-weight:600; transition:background .15s, color .15s; } .seg button.active { background:var(--accent); color:#fff; box-shadow:0 6px 16px rgba(37,99,235,.20); } .seg button:hover:not(.active) { color:var(--text); } .toolbar-group { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:8px 10px; background:rgba(255,255,255,.76); border:1px solid var(--border); border-radius:14px; box-shadow:var(--shadow-sm); } .toolbar-group.compact { padding:6px 8px; } .toolbar-spacer { margin-left:auto; display:flex; gap:10px; align-items:center; flex-wrap:wrap; } .sidebar-brand { padding:14px 14px 12px; border-radius:16px; background:linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,255,255,.82)); border:1px solid var(--border); box-shadow:var(--shadow-sm); margin-bottom:10px; } .sidebar-brand .title { font-size:15px; font-weight:800; letter-spacing:.01em; color:var(--text); } .sidebar-brand .sub { font-size:12px; color:var(--text-muted); margin-top:4px; line-height:1.45; } .sidebar-section-title { font-size:11px; color:var(--text-muted); padding:6px 8px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; } .sidebar-stats { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:auto; padding:10px 4px 2px; } .sidebar-stat { background:rgba(255,255,255,.84); border:1px solid var(--border); border-radius:14px; padding:10px 8px; box-shadow:var(--shadow-sm); } .sidebar-stat .k { font-size:10px; color:var(--text-muted); margin-bottom:4px; } .sidebar-stat .v { font-size:14px; font-weight:800; color:var(--text); } .mc-tooltip { position:fixed; z-index:1000002; max-width:min(360px, calc(100vw - 24px)); padding:6px 8px; border-radius:6px; background:rgba(17,24,39,.92); color:#fff; font-size:11px; line-height:1.35; pointer-events:none; box-shadow:var(--shadow-md); white-space:normal; transform:translateY(2px); opacity:0; transition:opacity .08s, transform .08s; } .mc-tooltip.show { opacity:1; transform:translateY(0); } /* —— Window-first:把空间留给窗口,所有操作只在需要时出现 —— */ .app-shell { display:flex; height:100vh; gap:10px; padding:10px; } .grid { background:linear-gradient(180deg, rgba(255,255,255,.34), rgba(255,255,255,.14)); } body.rg-video-cover .cam-video { object-fit:cover !important; } body.rg-video-contain .cam-video { object-fit:contain !important; } body.rg-viewer-mode { background:#050607; } body.rg-viewer-mode .sidebar, body.rg-viewer-mode header, body.rg-viewer-mode .top-accent { display:none !important; } body.rg-viewer-mode .app-shell { padding:0 !important; gap:0 !important; } body.rg-viewer-mode main { width:100vw !important; height:100vh !important; border:0 !important; border-radius:0 !important; box-shadow:none !important; background:#050607 !important; } body.rg-viewer-mode .grid { padding:8px !important; background:#050607 !important; } body.rg-viewer-mode .grid.view-grid { gap:8px !important; } .cam-card:not(.compact) .mc-hover-ui { opacity:0 !important; pointer-events:none !important; } .cam-card:hover .mc-hover-ui, .cam-card:focus-within .mc-hover-ui { opacity:1 !important; pointer-events:auto !important; } .cam-card .pill, .cam-card .name-label { opacity:0; pointer-events:none; transition:opacity .16s ease, transform .16s ease; } .cam-card:hover .pill, .cam-card:focus-within .pill, .cam-card:hover .name-label, .cam-card:focus-within .name-label { opacity:1; pointer-events:auto; } .cam-card.not-online .pill, .cam-card.recording .pill { opacity:1; } .cam-card.recording .name-label { opacity:1; } .cam-card .ops-row { top:auto !important; right:auto !important; bottom:10px !important; left:50% !important; transform:translateX(-50%) translateY(8px) !important; display:flex !important; align-items:center !important; gap:3px !important; padding:4px !important; border-radius:999px !important; background:rgba(15,23,42,.42) !important; border:1px solid rgba(255,255,255,.18) !important; backdrop-filter:blur(14px) !important; -webkit-backdrop-filter:blur(14px) !important; box-shadow:0 10px 30px rgba(0,0,0,.18) !important; } .cam-card:hover .ops-row, .cam-card:focus-within .ops-row { transform:translateX(-50%) translateY(0) !important; } .cam-card .ops-row .icon-btn { width:28px; height:28px; border-radius:999px; color:rgba(255,255,255,.92); background:transparent; border:0; box-shadow:none; } .cam-card .ops-row .icon-btn:hover { background:rgba(255,255,255,.16); color:#fff; transform:none; box-shadow:none; } .cam-card .ops-row .icon-btn.danger:hover { background:rgba(220,38,38,.86); color:#fff; } .cam-card.compact .ops-extra { display:flex; } .cam-card.tiny .ops-extra { display:none; } .cam-card.tiny .ops-row .icon-btn { width:24px; height:24px; } .cam-card .drag-handle { top:8px !important; left:8px !important; right:auto !important; bottom:auto !important; transform:translateY(-4px) !important; padding:5px 7px !important; border-radius:999px !important; background:rgba(15,23,42,.40) !important; color:rgba(255,255,255,.88) !important; border:1px solid rgba(255,255,255,.16) !important; box-shadow:none !important; } .cam-card:hover .drag-handle, .cam-card:focus-within .drag-handle { transform:translateY(0) !important; } .cam-card .name-label { top:8px !important; bottom:auto !important; left:42px !important; transform:translateY(-4px); } .cam-card:hover .name-label, .cam-card:focus-within .name-label { transform:translateY(0); } .cam-card .pill { top:8px !important; right:8px !important; left:auto !important; } .grid.view-focus { grid-template-areas:'main thumbs' !important; grid-template-columns:minmax(0,1fr) minmax(190px,var(--focus-rail-width,260px)) !important; grid-template-rows:minmax(0,1fr) !important; gap:10px !important; padding:10px !important; background:#050607 !important; } .grid.view-focus .focused-row { background:#050607; border-radius:12px; } .grid.view-focus .focused-row .cam-card { border-radius:12px; border-color:rgba(255,255,255,.10); box-shadow:none; background:#000; } .grid.view-focus .focused-row .cam-video { background:#000; } .grid.view-focus .resizer { display:none !important; } .grid.view-focus .thumbs-row { grid-template-columns:1fr !important; gap:8px !important; padding:0 2px 0 0 !important; background:transparent; } .grid.view-focus .thumbs-row .cam-card { border-radius:10px; border-color:rgba(255,255,255,.12); opacity:.82; transition:opacity .14s,border-color .14s,transform .14s; } .grid.view-focus .thumbs-row .cam-card:hover { opacity:1; transform:translateY(-1px); } body.rg-focus-thumbs-collapsed .grid.view-focus { grid-template-areas:'main' !important; grid-template-columns:minmax(0,1fr) !important; } body.rg-focus-thumbs-collapsed .grid.view-focus .thumbs-row { display:none !important; } @media (max-width: 980px) { .grid.view-focus { grid-template-areas:'main' 'thumbs' !important; grid-template-columns:minmax(0,1fr) !important; grid-template-rows:minmax(0,1fr) minmax(92px,24vh) !important; } .grid.view-focus .thumbs-row { grid-template-columns:repeat(auto-fill,minmax(var(--focus-thumb-min,150px),1fr)) !important; padding:0 0 2px !important; } } /* —— v13.2:窗口优先最终覆盖。卡片默认只看画面,主屏默认只看主画面。 —— */ .cam-card .ops-row .ops-extra, .cam-card .ops-row .icon-btn.danger { display:none !important; } .cam-card .ops-row .icon-btn.recording { display:flex !important; } .cam-card .ops-row { opacity:0; pointer-events:none; } .cam-card:hover .ops-row, .cam-card:focus-within .ops-row { opacity:1; pointer-events:auto; } .cam-card.is-online .pill, .cam-card.is-online .name-label { opacity:0 !important; pointer-events:none !important; } .cam-card.is-online:hover .pill, .cam-card.is-online:focus-within .pill, .cam-card.is-online:hover .name-label, .cam-card.is-online:focus-within .name-label { opacity:1 !important; } .grid.view-focus { display:block !important; position:relative !important; grid-template-areas:none !important; grid-template-columns:none !important; grid-template-rows:none !important; padding:0 !important; gap:0 !important; background:#000 !important; overflow:hidden !important; } .grid.view-focus .focused-row { position:absolute !important; inset:0 !important; grid-area:auto !important; min-width:0 !important; min-height:0 !important; display:grid !important; place-items:center !important; background:#000 !important; border-radius:0 !important; overflow:hidden !important; } .grid.view-focus .focused-row .cam-card { border:0 !important; border-radius:0 !important; box-shadow:none !important; background:#000 !important; } .grid.view-focus .focused-row .drag-handle { display:none !important; } .grid.view-focus .focused-row .ops-row { top:14px !important; right:14px !important; bottom:auto !important; left:auto !important; transform:translateY(-6px) !important; } .grid.view-focus .focused-row .cam-card:hover .ops-row, .grid.view-focus .focused-row .cam-card:focus-within .ops-row { transform:translateY(0) !important; } .grid.view-focus .focused-row .name-label { top:auto !important; bottom:14px !important; left:14px !important; } .grid.view-focus .resizer { display:none !important; } .grid.view-focus .thumbs-row { position:absolute !important; grid-area:auto !important; left:14px !important; right:14px !important; bottom:10px !important; height:var(--focus-filmstrip-height,118px) !important; display:flex !important; gap:10px !important; overflow-x:auto !important; overflow-y:hidden !important; padding:10px !important; z-index:12 !important; background:rgba(15,23,42,.56) !important; border:1px solid rgba(255,255,255,.16) !important; border-radius:16px !important; box-shadow:0 14px 40px rgba(0,0,0,.32) !important; backdrop-filter:blur(14px) !important; -webkit-backdrop-filter:blur(14px) !important; transform:translateY(calc(100% - 20px)) !important; opacity:.20 !important; transition:transform .18s ease, opacity .18s ease, background .18s ease !important; } .grid.view-focus .thumbs-row:hover, .grid.view-focus .thumbs-row:focus-within { transform:translateY(0) !important; opacity:1 !important; background:rgba(15,23,42,.70) !important; } .grid.view-focus .thumbs-row .cam-card { flex:0 0 var(--focus-thumb-min,150px) !important; width:auto !important; height:100% !important; border-radius:10px !important; border-color:rgba(255,255,255,.18) !important; box-shadow:none !important; opacity:.86; } .grid.view-focus .thumbs-row .cam-card:hover { opacity:1; transform:translateY(-1px); } .grid.view-focus .thumbs-row .pill, .grid.view-focus .thumbs-row .name-label, .grid.view-focus .thumbs-row .ops-row, .grid.view-focus .thumbs-row .drag-handle { display:none !important; } body.rg-focus-thumbs-collapsed .grid.view-focus .thumbs-row { display:none !important; } body.rg-focus-mode { background:#000; } body.rg-focus-mode .app-shell { padding:0 !important; gap:0 !important; } body.rg-focus-mode .sidebar { display:none !important; } body.rg-focus-mode main { width:100vw !important; height:100vh !important; border:0 !important; border-radius:0 !important; box-shadow:none !important; background:#000 !important; position:relative !important; } body.rg-focus-mode .top-accent { display:none !important; } body.rg-focus-mode header { position:absolute !important; left:12px !important; right:12px !important; top:10px !important; z-index:70 !important; transform:translateY(calc(-100% + 8px)) !important; opacity:.14 !important; transition:transform .18s ease, opacity .18s ease !important; } body.rg-focus-mode header:hover, body.rg-focus-mode header:focus-within { transform:translateY(0) !important; opacity:1 !important; } /* —— v14.0:重新按“多窗口视频优先”收敛。窗口是产品主体,控件只做临时 HUD。 —— */ body { background:#0b0e13 !important; } .app-shell { gap:8px !important; padding:8px !important; background:#0b0e13 !important; } main { background:#07090d !important; border-color:rgba(255,255,255,.08) !important; border-radius:12px !important; box-shadow:none !important; } .top-accent { display:none !important; } header { padding:8px 10px !important; gap:8px !important; background:rgba(255,255,255,.92) !important; border-bottom:1px solid rgba(15,23,42,.08) !important; } .toolbar-group { padding:5px 7px !important; gap:6px !important; border-radius:10px !important; box-shadow:none !important; background:#fff !important; } .ctrl-btn, .ctrl-input { min-height:32px !important; border-radius:9px !important; padding:5px 9px !important; font-size:12px !important; } .seg { border-radius:10px !important; padding:2px !important; } .seg button { padding:5px 9px !important; border-radius:8px !important; } .toggle { padding:5px 7px !important; border-radius:9px !important; } .toolbar-spacer { gap:6px !important; } .sidebar { background:#111827 !important; color:#e5e7eb !important; border-color:rgba(255,255,255,.08) !important; border-radius:12px !important; box-shadow:none !important; padding:10px 8px !important; width:220px !important; } .sidebar-brand { background:transparent !important; border:0 !important; box-shadow:none !important; margin:0 0 4px !important; padding:8px 10px !important; } .sidebar-brand .title { color:#f8fafc !important; font-size:14px !important; } .sidebar-section-title { color:#94a3b8 !important; } .group-tab { color:#cbd5e1 !important; padding:8px 10px !important; border-radius:10px !important; } .group-tab:hover { background:rgba(255,255,255,.06) !important; border-color:rgba(255,255,255,.08) !important; box-shadow:none !important; color:#fff !important; } .group-tab.active { background:rgba(37,99,235,.18) !important; border-color:rgba(96,165,250,.30) !important; color:#bfdbfe !important; box-shadow:none !important; } .sidebar-stat { background:rgba(255,255,255,.04) !important; border-color:rgba(255,255,255,.08) !important; box-shadow:none !important; } .sidebar-stat .k { color:#94a3b8 !important; } .sidebar-stat .v { color:#f8fafc !important; } .grid.view-grid { background:#07090d !important; padding:8px !important; gap:6px !important; } .grid.view-grid .cam-card { border-radius:6px !important; } .cam-card { background:#000 !important; border:1px solid rgba(255,255,255,.075) !important; box-shadow:none !important; } .cam-card:hover, .cam-card:focus-within { border-color:rgba(96,165,250,.55) !important; box-shadow:0 0 0 1px rgba(96,165,250,.10) !important; } .cam-card.is-online .status-layer { display:none !important; } .cam-card .cam-video { background:#000 !important; } .cam-card .pill, .cam-card .name-label, .cam-card .drag-handle { opacity:0 !important; pointer-events:none !important; } .cam-card.not-online .pill, .cam-card.not-online .name-label, .cam-card.recording .name-label { opacity:1 !important; } .cam-card:hover .name-label, .cam-card:focus-within .name-label { opacity:1 !important; } .cam-card:hover .drag-handle, .cam-card:focus-within .drag-handle { opacity:.65 !important; pointer-events:auto !important; } .cam-card .name-label { left:8px !important; top:8px !important; bottom:auto !important; max-width:calc(100% - 52px) !important; background:rgba(0,0,0,.42) !important; color:#f8fafc !important; border-color:rgba(255,255,255,.10) !important; border-radius:6px !important; font-size:11px !important; padding:4px 7px !important; } .cam-card .pill { display:none !important; } .cam-card.not-online .pill { display:inline-flex !important; } .cam-card .drag-handle { display:none !important; } .cam-card:hover .drag-handle, .cam-card:focus-within .drag-handle { display:flex !important; } .cam-card .ops-row { top:8px !important; right:8px !important; left:auto !important; bottom:auto !important; transform:none !important; padding:0 !important; background:transparent !important; border:0 !important; box-shadow:none !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important; opacity:0 !important; } .cam-card:hover .ops-row, .cam-card:focus-within .ops-row { opacity:1 !important; transform:none !important; } .cam-card .ops-row .icon-btn { width:30px !important; height:30px !important; border-radius:8px !important; background:rgba(0,0,0,.48) !important; color:#f8fafc !important; border:1px solid rgba(255,255,255,.14) !important; box-shadow:none !important; } .cam-card .ops-row .icon-btn:hover { background:rgba(37,99,235,.86) !important; border-color:rgba(96,165,250,.50) !important; } .cam-card .ops-row .icon-btn:not(:last-child) { display:none !important; } .cam-card.recording::after { content:'REC'; position:absolute; left:8px; bottom:8px; z-index:28; padding:3px 6px; border-radius:5px; background:rgba(220,38,38,.92); color:#fff; font-size:10px; font-weight:800; letter-spacing:.04em; pointer-events:none; } .cam-card.recording-waiting::after { content:'REC PAUSED'; background:rgba(217,119,6,.94); } .status-layer { color:#cbd5e1 !important; background:linear-gradient(180deg,rgba(15,23,42,.10),rgba(15,23,42,.24)) !important; } .status-layer .status-chip { background:rgba(255,255,255,.06) !important; color:#e5e7eb !important; border-color:rgba(255,255,255,.10) !important; } body.rg-viewer-mode .app-shell { padding:0 !important; gap:0 !important; } body.rg-viewer-mode .sidebar, body.rg-viewer-mode header, body.rg-viewer-mode .top-accent { display:none !important; } body.rg-viewer-mode main { width:100vw !important; height:100vh !important; border:0 !important; border-radius:0 !important; } body.rg-viewer-mode .grid.view-grid { padding:4px !important; gap:4px !important; } body.rg-focus-mode { background:#000 !important; } body.rg-focus-mode .app-shell { padding:0 !important; gap:0 !important; } body.rg-focus-mode .sidebar, body.rg-focus-mode .top-accent { display:none !important; } body.rg-focus-mode main { width:100vw !important; height:100vh !important; border:0 !important; border-radius:0 !important; box-shadow:none !important; background:#000 !important; } body.rg-focus-mode header { transform:translateY(calc(-100% + 4px)) !important; opacity:.02 !important; pointer-events:auto !important; } body.rg-focus-mode header:hover, body.rg-focus-mode header:focus-within { transform:translateY(0) !important; opacity:1 !important; pointer-events:auto !important; } body.rg-focus-mode::before { content:''; position:fixed; left:0; right:0; top:0; height:14px; z-index:69; pointer-events:none; } body.rg-focus-mode:hover header { pointer-events:auto !important; } .grid.view-focus { padding:0 !important; background:#000 !important; } .grid.view-focus .focused-row .cam-card { border:0 !important; border-radius:0 !important; } .grid.view-focus .focused-row .name-label { top:auto !important; bottom:14px !important; left:14px !important; background:rgba(0,0,0,.34) !important; opacity:0 !important; } .grid.view-focus .focused-row .cam-card:hover .name-label, .grid.view-focus .focused-row .cam-card:focus-within .name-label { opacity:1 !important; } .grid.view-focus .focused-row .ops-row { top:14px !important; right:14px !important; opacity:0 !important; } .grid.view-focus .focused-row .cam-card:hover .ops-row, .grid.view-focus .focused-row .cam-card:focus-within .ops-row { opacity:1 !important; } .grid.view-focus .thumbs-row { transform:translateY(calc(100% + 6px)) !important; opacity:0 !important; bottom:8px !important; left:8px !important; right:8px !important; height:104px !important; } .grid.view-focus .thumbs-row:hover, .grid.view-focus .thumbs-row:focus-within { transform:translateY(0) !important; opacity:1 !important; } body:not(.rg-focus-thumbs-collapsed) .grid.view-focus::after { content:''; position:absolute; left:50%; bottom:7px; width:64px; height:4px; transform:translateX(-50%); border-radius:999px; background:rgba(255,255,255,.22); z-index:11; pointer-events:none; } body.rg-focus-thumbs-collapsed .grid.view-focus::after { display:none !important; } .grid.view-focus .thumbs-row .cam-card { border-radius:6px !important; } .grid.view-focus .thumbs-row .cam-card.is-focus-main { outline:2px solid rgba(96,165,250,.92) !important; outline-offset:-2px; } /* —— 纯净模式:隐藏工具栏、按钮和所有浮层,只保留画面 —— */ body.rg-pure-mode { background:#000; } body.rg-pure-mode .sidebar, body.rg-pure-mode header, body.rg-pure-mode .top-accent, body.rg-pure-mode .mc-hover-ui, body.rg-pure-mode .pill, body.rg-pure-mode .name-label, body.rg-pure-mode .status-layer, body.rg-pure-mode .resizer, body.rg-pure-mode .menu-pop, body.rg-pure-mode .mc-tooltip { display:none !important; } body.rg-pure-mode main { width:100vw; height:100vh; background:#000; } body.rg-pure-mode .grid { padding:0 !important; gap:0 !important; background:#000; } body.rg-pure-mode .grid.view-focus { padding:0 !important; gap:0 !important; grid-template-areas:'main'; grid-template-rows:minmax(0,1fr) !important; } body.rg-pure-mode .grid.view-focus .focused-row { grid-area:main; } body.rg-pure-mode .grid.view-focus .thumbs-row { display:none !important; } body.rg-pure-mode .cam-card { border:none !important; border-radius:0 !important; box-shadow:none !important; background:#000 !important; outline:none !important; } body.rg-pure-mode .cam-video { background:#000 !important; } body.rg-pure-mode.pure-cursor-hidden, body.rg-pure-mode.pure-cursor-hidden * { cursor:none !important; } .pure-exit-chip { display:none; position:fixed; right:10px; bottom:10px; z-index:1000001; border:1px solid rgba(255,255,255,.22); background:rgba(0,0,0,.22); color:rgba(255,255,255,.72); border-radius:999px; padding:5px 9px; font-size:11px; cursor:pointer; opacity:.10; transition:opacity .15s, background .15s; } body.rg-pure-mode .pure-exit-chip { display:block; } body.rg-pure-mode .pure-exit-chip:hover, body.rg-pure-mode .pure-exit-chip:focus { opacity:1; background:rgba(0,0,0,.55); } /* 顶部一抹橙色细线,作为品牌呼应 */ .top-accent { height:3px; background:linear-gradient(90deg, #60a5fa 0%, var(--accent) 35%, #22c55e 100%); flex-shrink:0; } /* v15 —— normal multiview product surface: light tone, collapsible shell, paged layouts */ body { background:#f3f1ea !important; } .app-shell { background:#f3f1ea !important; gap:8px !important; padding:8px !important; } main { background:#fbfaf6 !important; border-color:#dedbd2 !important; border-radius:14px !important; } .sidebar { background:#fbfaf6 !important; border-color:#dedbd2 !important; border-radius:14px !important; box-shadow:none !important; } header { background:#fbfaf6 !important; border-bottom-color:#dedbd2 !important; padding:10px 12px !important; gap:8px !important; } .top-accent { display:none !important; } .toolbar-group { background:#fffefb !important; border-color:#e3e0d7 !important; border-radius:10px !important; box-shadow:none !important; padding:6px 8px !important; } .toolbar-group-title { font-size:11px; line-height:1; font-weight:700; color:#7a7468; margin-right:2px; white-space:nowrap; } .ctrl-btn, .ctrl-input { border-radius:9px !important; min-height:34px !important; box-shadow:none !important; } .seg { border-radius:9px !important; box-shadow:none !important; } .seg button { border-radius:7px !important; } .layout-seg button { min-width:38px; } .page-indicator { min-width:74px; text-align:center; font-size:12px; color:var(--text-secondary); font-weight:700; } .shell-controls { position:fixed; left:10px; top:10px; z-index:1000001; display:flex; gap:6px; pointer-events:auto; } .shell-controls button { border:1px solid #dedbd2; background:rgba(255,254,251,.92); color:#334155; border-radius:9px; padding:6px 9px; min-height:30px; font-size:12px; cursor:pointer; box-shadow:0 6px 18px rgba(15,23,42,.08); } body:not(.rg-toolbar-collapsed) .shell-controls .show-toolbar-btn { display:none; } body:not(.rg-sidebar-collapsed) .shell-controls .show-sidebar-btn { display:none; } body.rg-toolbar-collapsed header, body.rg-toolbar-collapsed .top-accent { display:none !important; } body.rg-sidebar-collapsed .sidebar { display:none !important; } body.rg-sidebar-collapsed .app-shell { grid-template-columns:1fr !important; } .grid { background:#ece9df !important; padding:10px !important; } .grid.view-grid { height:100%; overflow:hidden !important; align-content:stretch !important; align-items:stretch !important; } .grid.view-grid .cam-card { border-radius:8px !important; border-color:#d4d0c6 !important; background:#f8f6ee !important; box-shadow:none !important; } .cam-card { background:#f8f6ee !important; } .cam-video { background:#f8f6ee !important; } .status-layer { background:#f8f6ee !important; } .cam-card .name-label, .cam-card .pill { background:rgba(255,254,251,.88) !important; color:#334155 !important; border-color:#e3e0d7 !important; box-shadow:none !important; } .cam-card .ops-row { bottom:8px !important; } .icon-btn { background:rgba(255,254,251,.88) !important; border-color:#e3e0d7 !important; box-shadow:none !important; color:#334155 !important; } .icon-btn:hover { background:#fff !important; color:var(--accent) !important; transform:none !important; } /* Focus multiview: main top-left, secondary right and bottom; resizers adapt secondaries */ .grid.view-focus { display:grid !important; height:100% !important; overflow:hidden !important; grid-template-columns:minmax(260px,var(--focus-main-w,62fr)) 8px minmax(190px,var(--focus-side-w,38fr)) !important; grid-template-rows:minmax(220px,var(--focus-main-h,64fr)) 8px minmax(120px,var(--focus-bottom-h,36fr)) !important; grid-template-areas:'main vbar side' 'hbar hbar side' 'bottom bottom side' !important; gap:8px !important; padding:10px !important; background:#ece9df !important; } .grid.view-focus.no-bottom { grid-template-rows:minmax(0,1fr) !important; grid-template-areas:'main vbar side' !important; } .grid.view-focus.no-bottom .focus-bottom-row, .grid.view-focus.no-bottom .focus-h-resizer { display:none !important; } .grid.view-focus .focused-row { grid-area:main !important; display:grid !important; place-items:stretch !important; min-width:0; min-height:0; overflow:hidden; } .grid.view-focus .focus-side-row { grid-area:side; display:grid; grid-auto-flow:row; gap:8px; min-width:0; min-height:0; overflow:hidden; } .grid.view-focus .focus-bottom-row { grid-area:bottom; display:grid; grid-auto-flow:column; gap:8px; min-width:0; min-height:0; overflow:hidden; } .grid.view-focus .focus-side-row .cam-card, .grid.view-focus .focus-bottom-row .cam-card { width:100% !important; height:100% !important; min-height:0 !important; aspect-ratio:auto !important; border-radius:8px !important; } .grid.view-focus .focused-row .cam-card { width:100% !important; height:100% !important; max-width:100% !important; max-height:100% !important; border-radius:8px !important; } .grid.view-focus .resizer { display:block !important; background:transparent !important; position:relative; z-index:3; } .grid.view-focus .focus-v-resizer { grid-area:vbar; cursor:ew-resize; } .grid.view-focus .focus-h-resizer { grid-area:hbar; cursor:ns-resize; } .grid.view-focus .resizer::before { content:''; position:absolute; inset:0; margin:auto; border-radius:999px; background:#d4d0c6; opacity:.72; transition:opacity .12s, background .12s; } .grid.view-focus .focus-v-resizer::before { width:3px; height:52px; } .grid.view-focus .focus-h-resizer::before { width:52px; height:3px; } .grid.view-focus .resizer:hover::before, .grid.view-focus .resizer.dragging::before { background:var(--accent); opacity:1; } .grid.view-focus .thumbs-row { display:none !important; } body.rg-viewer-mode { background:#f3f1ea !important; } body.rg-viewer-mode .app-shell { padding:0 !important; gap:0 !important; background:#f3f1ea !important; } body.rg-viewer-mode main { background:#f3f1ea !important; } body.rg-viewer-mode .grid { background:#ece9df !important; padding:8px !important; } body.rg-viewer-mode .sidebar, body.rg-viewer-mode header, body.rg-viewer-mode .top-accent { display:none !important; } /* v15.1 repairs: reliable collapsed sidebar, wheel-safe controls and readable group contrast */ .app-shell { display:flex !important; min-width:0 !important; min-height:0 !important; } .sidebar { width:220px !important; min-width:220px !important; max-width:220px !important; flex:0 0 220px !important; color:#1f2937 !important; } .sidebar.is-collapsed, body.rg-sidebar-collapsed .sidebar { display:none !important; width:0 !important; min-width:0 !important; max-width:0 !important; flex:0 0 0 !important; flex-basis:0 !important; padding:0 !important; margin:0 !important; border:0 !important; overflow:hidden !important; } body.rg-sidebar-collapsed main { flex:1 1 100% !important; min-width:0 !important; } body.rg-toolbar-collapsed header, body.rg-toolbar-collapsed .top-accent { display:none !important; } .shell-controls { display:flex; align-items:center; } body:not(.rg-toolbar-collapsed):not(.rg-sidebar-collapsed) .shell-controls { display:none !important; } body.rg-toolbar-collapsed .shell-controls .show-toolbar-btn { display:inline-flex !important; } body.rg-sidebar-collapsed .shell-controls .show-sidebar-btn { display:inline-flex !important; } .sidebar-brand .title { color:#111827 !important; } .sidebar-section-title { color:#5f574a !important; font-weight:800 !important; letter-spacing:.08em !important; } .sidebar .group-tab { color:#293241 !important; font-weight:650 !important; background:transparent !important; border-color:transparent !important; } .sidebar .group-tab:hover { background:#f1efe7 !important; color:#111827 !important; border-color:#d8d3c5 !important; box-shadow:none !important; } .sidebar .group-tab.active { background:#e8efff !important; color:#0f172a !important; border-color:#b8c9ff !important; box-shadow:inset 3px 0 0 #2563eb !important; } .sidebar .group-tab > span:first-child { color:inherit !important; } .sidebar .group-tab > span:last-child { color:#475569 !important; background:#eee9dc !important; border-color:#d8d3c5 !important; } .sidebar .group-tab.active > span:last-child { color:#1d4ed8 !important; background:#dbeafe !important; border-color:#bfdbfe !important; } .sidebar-collapse-btn { border:1px solid #d8d3c5; background:#f1efe7; color:#334155; border-radius:8px; padding:4px 8px; font-size:12px; line-height:1.2; cursor:pointer; } .sidebar-collapse-btn:hover { background:#e7e2d5; color:#0f172a; border-color:#c9c1b1; } .sidebar .group-tab { display:flex !important; align-items:center !important; justify-content:space-between !important; gap:8px !important; } .sidebar .group-name { color:inherit !important; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .sidebar .group-count { color:#334155 !important; background:#eee9dc !important; border:1px solid #d8d3c5 !important; border-radius:999px; padding:2px 7px; min-width:26px; text-align:center; font-size:11px; line-height:1.25; font-weight:750; } .sidebar .group-tab.active .group-count { color:#1d4ed8 !important; background:#dbeafe !important; border-color:#bfdbfe !important; } .sidebar .group-tab[style] { color:#293241 !important; } .sidebar-stat { background:#fffefb !important; border-color:#e3e0d7 !important; } .sidebar-stat .k { color:#6b6256 !important; } .sidebar-stat .v { color:#111827 !important; } input[type=range], input[type=number], select { overscroll-behavior:contain; } /* v15.3: top controls stay on one row; compact mode hides labels on narrower screens. */ header { flex-wrap:nowrap !important; overflow-x:auto !important; overflow-y:hidden !important; scrollbar-width:thin; white-space:nowrap; } header .toolbar-group { flex-wrap:nowrap !important; flex:0 0 auto !important; align-items:center !important; } header .toolbar-spacer { flex-wrap:nowrap !important; flex:0 0 auto !important; margin-left:auto !important; } header .ctrl-input { height:34px !important; } header select.ctrl-input { max-width:150px; } header .roomgrid-compact-select { width:auto !important; min-width:92px; padding-right:24px !important; } header .toolbar-group-title { flex:0 0 auto; } @media (max-width: 1360px) { header { gap:8px !important; padding:10px 12px !important; } header .toolbar-group { gap:6px !important; padding:4px 6px !important; } header .toolbar-group-title { display:none !important; } header .ctrl-input { height:32px !important; padding-top:5px !important; padding-bottom:5px !important; } header .ctrl-btn { min-height:32px !important; padding:5px 8px !important; } header .ctrl-btn .btn-label { display:none !important; } header input[placeholder] { max-width:150px !important; } header select.ctrl-input { max-width:118px !important; } header input[type=range] { width:72px !important; } } /* v15.4: interaction polish. Keep core layout intact; make common actions one click and reduce visual jank. */ .cam-card { backface-visibility:hidden; transform:translateZ(0); } .cam-card.rg-card-enter { animation:rg-card-enter .16s ease-out both; } @keyframes rg-card-enter { from { opacity:.55; transform:scale(.985); } to { opacity:1; transform:scale(1); } } .cam-card .ops-row { transition:opacity .12s ease, transform .12s ease !important; } .cam-card .ops-row .icon-btn.quick-op { display:flex !important; } .cam-card.tiny .ops-row .icon-btn.quick-optional { display:none !important; } .cam-card .ops-row .icon-btn.quick-more { display:flex !important; } .cam-card .ops-row .icon-btn:active, .ctrl-btn:active, .group-tab:active { transform:scale(.96) !important; } .grid.view-focus .focus-side-row .cam-card, .grid.view-focus .focus-bottom-row .cam-card { cursor:pointer; transition:border-color .12s ease, transform .12s ease, opacity .12s ease; } .grid.view-focus .focus-side-row .cam-card:hover, .grid.view-focus .focus-bottom-row .cam-card:hover { transform:translateY(-1px); border-color:rgba(37,99,235,.62) !important; } .roomgrid-modal-backdrop { position:fixed; inset:0; z-index:1000003; display:flex; align-items:center; justify-content:center; padding:18px; background:rgba(15,23,42,.40); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px); } .roomgrid-modal { width:min(560px, calc(100vw - 36px)); background:#fffefb; border:1px solid #e3e0d7; border-radius:14px; box-shadow:0 22px 80px rgba(15,23,42,.24); padding:14px; color:#111827; } .roomgrid-modal-title { font-size:15px; font-weight:800; margin-bottom:6px; } .roomgrid-modal-hint { font-size:12px; line-height:1.45; color:#64748b; margin-bottom:10px; } .roomgrid-modal-textarea { width:100%; min-height:220px; resize:vertical; border:1px solid #dedbd2; border-radius:10px; padding:10px; outline:none; font:13px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; background:#fff; color:#111827; } .roomgrid-modal-textarea:focus { border-color:rgba(37,99,235,.45); box-shadow:0 0 0 3px rgba(37,99,235,.10); } .roomgrid-modal-count { margin-top:8px; font-size:12px; color:#64748b; } .roomgrid-modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; } @media (prefers-reduced-motion: reduce) { .cam-card, .ctrl-btn, .icon-btn, .group-tab, .menu-pop, .mc-tooltip { transition:none !important; animation:none !important; } } `), })); // ---- 布局骨架 ---- const sidebar = $('aside', { class: 'sidebar', style: { width: '248px', background: 'linear-gradient(180deg, rgba(255,255,255,.92), rgba(255,255,255,.78))', border: '1px solid var(--border)', borderRadius: '18px', padding: '14px 10px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '6px', flexShrink: '0', transition: 'width .2s, padding .2s', boxShadow: 'var(--shadow-md)', } }); const main = $('main', { style: { flex: '1', display: 'flex', flexDirection: 'column', minWidth: '0', background: 'linear-gradient(180deg, rgba(255,255,255,.82), rgba(255,255,255,.72))', border: '1px solid var(--border)', borderRadius: '18px', overflow: 'hidden', boxShadow: 'var(--shadow-md)' } }); const topAccent = $('div', { class: 'top-accent' }); const toolbar = $('header', { style: { padding: '14px 16px', background: 'transparent', borderBottom: '1px solid var(--border)', display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'nowrap', flexShrink: '0', overflowX: 'auto', overflowY: 'hidden', } }); const grid = $('section', { class: 'grid view-grid', style: { flex: '1', overflowY: 'auto', padding: '16px', } }); main.append(topAccent, toolbar, grid); document.body.append($('div', { class: 'app-shell' }, [sidebar, main])); const toastHost = $('div', { style: { position: 'fixed', right: '16px', top: '16px', zIndex: '1000000', display: 'flex', flexDirection: 'column', gap: '8px', pointerEvents: 'none' } }); document.body.appendChild(toastHost); function toast(msg) { const el = $('div', { style: { maxWidth: '360px', background: 'rgba(17,24,39,.88)', color: '#fff', padding: '9px 12px', borderRadius: '8px', boxShadow: 'var(--shadow-md)', fontSize: '12px', lineHeight: '1.35' } }, String(msg || '')); toastHost.appendChild(el); setTimeout(() => { try { el.style.opacity = '0'; el.style.transform = 'translateY(-4px)'; el.style.transition = 'opacity .18s, transform .18s'; } catch (_) {} }, 2200); setTimeout(() => { try { el.remove(); } catch (_) {} }, 2600); } function installHintSystem() { let tooltip = null; let timer = 0; let activeEl = null; const autoText = (el) => { if (!el) return ''; const explicit = el.dataset?.hint || el.getAttribute?.('aria-label') || el.title; if (explicit) return explicit; if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') return el.placeholder || ''; if (el.tagName === 'SELECT') return el.title || t('hintSort'); if (el.tagName === 'BUTTON') return (el.textContent || '').replace(/\s+/g, ' ').trim(); return ''; }; const ensure = (el) => { const text = autoText(el); if (text) setElementHint(el, text); return text; }; const fill = (root = document.body) => { try { root.querySelectorAll('button,input,select,textarea,[title]').forEach(el => ensure(el)); } catch (_) {} }; const hide = () => { clearTimeout(timer); timer = 0; activeEl = null; if (tooltip) { try { tooltip.remove(); } catch (_) {} tooltip = null; } }; const show = (el) => { if (!el || el.disabled || document.body.classList.contains('rg-pure-mode')) return; const text = ensure(el); if (!text) return; if (!tooltip) tooltip = $('div', { class: 'mc-tooltip' }); tooltip.textContent = text; document.body.appendChild(tooltip); const rect = el.getBoundingClientRect(); const tw = Math.min(tooltip.offsetWidth || 240, window.innerWidth - 24); let left = Math.max(8, Math.min(window.innerWidth - tw - 8, rect.left + rect.width / 2 - tw / 2)); let top = rect.bottom + 8; if (top + (tooltip.offsetHeight || 32) > window.innerHeight - 8) top = Math.max(8, rect.top - (tooltip.offsetHeight || 32) - 8); tooltip.style.left = left + 'px'; tooltip.style.top = top + 'px'; requestAnimationFrame(() => tooltip?.classList.add('show')); }; const schedule = (el) => { clearTimeout(timer); activeEl = el; timer = setTimeout(() => show(el), 420); }; document.addEventListener('pointerover', (e) => { const el = e.target?.closest?.('button,input,select,textarea,[data-hint],[title]'); if (!el || el === activeEl) return; schedule(el); }, true); document.addEventListener('pointerout', (e) => { if (!activeEl) return; if (!activeEl.contains(e.relatedTarget)) hide(); }, true); document.addEventListener('focusin', (e) => { const el = e.target?.closest?.('button,input,select,textarea,[data-hint],[title]'); if (el) schedule(el); }, true); document.addEventListener('focusout', hide, true); document.addEventListener('click', hide, true); window.addEventListener('scroll', hide, true); try { const mo = new MutationObserver(ms => { for (const m of ms) for (const n of m.addedNodes || []) if (n.nodeType === 1) fill(n); }); mo.observe(document.body, { childList: true, subtree: true }); } catch (_) {} fill(document.body); } installHintSystem(); function installWheelInputGuard() { const isGuarded = (el) => { if (!el || !el.closest) return false; const target = el.closest('input, select, textarea'); if (!target) return false; const tag = target.tagName; const type = String(target.getAttribute('type') || '').toLowerCase(); return tag === 'SELECT' || type === 'range' || type === 'number'; }; document.addEventListener('wheel', (e) => { if (!isGuarded(e.target)) return; e.preventDefault(); e.stopPropagation(); const target = e.target.closest('input, select, textarea'); // 浏览器默认会用滚轮改 range / number / select 的值,进而触发整页重排;这里直接阻断。 try { target.blur(); } catch (_) {} }, { capture: true, passive: false }); } installWheelInputGuard(); const recordings = new Map(); const recordingLog = []; const pureExitChip = $('button', { class: 'pure-exit-chip', title: t('pureExitHint'), onclick: () => setPureMode(false), }, t('pureExitChip')); document.body.appendChild(pureExitChip); const shellControls = $('div', { class: 'shell-controls' }); const showToolbarBtn = $('button', { class: 'show-toolbar-btn', title: LANG === 'zh' ? '显示顶部工具栏' : 'Show toolbar', onclick: () => { document.body.classList.remove('rg-toolbar-collapsed'); store.patchSettings({ toolbarCollapsed: false }); }, }, LANG === 'zh' ? '工具' : 'Tools'); const showSidebarBtn = $('button', { class: 'show-sidebar-btn', title: LANG === 'zh' ? '显示左侧分组' : 'Show sidebar', onclick: () => { document.body.classList.remove('rg-sidebar-collapsed'); sidebar.classList.remove('is-collapsed'); store.patchSettings({ sidebarCollapsed: false }); }, }, LANG === 'zh' ? '分组' : 'Groups'); shellControls.append(showToolbarBtn, showSidebarBtn); document.body.appendChild(shellControls); function syncShellControls() { if (!shellControls) return; const hidden = !!store.state.settings.pureMode || !!store.state.settings.viewerMode; shellControls.style.display = hidden ? 'none' : 'flex'; } let pureCursorTimer = 0; function closeTransientUi() { document.querySelectorAll('.menu-pop,.mc-tooltip').forEach(el => { try { el.remove(); } catch (_) {} }); } function applyPureModeState() { const on = !!store.state.settings.pureMode; const viewerOn = !!store.state.settings.viewerMode && !on; const thumbsCollapsed = !!store.state.settings.focusThumbsCollapsed; const videoFit = store.state.settings.videoFit === 'cover' ? 'cover' : 'contain'; document.body.classList.toggle('rg-pure-mode', on); document.body.classList.toggle('rg-viewer-mode', viewerOn); document.body.classList.toggle('rg-toolbar-collapsed', !!store.state.settings.toolbarCollapsed && !on && !viewerOn); document.body.classList.toggle('rg-sidebar-collapsed', !!store.state.settings.sidebarCollapsed && !on && !viewerOn); document.body.classList.toggle('rg-focus-thumbs-collapsed', thumbsCollapsed); document.body.classList.toggle('rg-video-cover', videoFit === 'cover'); document.body.classList.toggle('rg-video-contain', videoFit !== 'cover'); document.body.classList.toggle('rg-focus-mode', store.state.settings.viewMode === 'focus' && !on && !viewerOn); syncShellControls?.(); document.body.classList.remove('pure-cursor-hidden'); setElementHint(pureExitChip, t('pureExitHint')); pureExitChip.textContent = t('pureExitChip'); if (typeof pureModeBtn !== 'undefined' && pureModeBtn) { setTrustedHtml(pureModeBtn, trustedHtml(iconLabel('clean', on ? t('pureModeOff') : t('pureMode')))); pureModeBtn.classList.toggle('primary', on); setElementHint(pureModeBtn, on ? t('pureModeOff') + ' · Alt+P/C' : t('pureModeHint')); } if (typeof viewerModeBtn !== 'undefined' && viewerModeBtn) { setTrustedHtml(viewerModeBtn, trustedHtml(iconLabel('focus', viewerOn ? t('viewerModeOff') : t('viewerMode')))); viewerModeBtn.classList.toggle('primary', viewerOn); setElementHint(viewerModeBtn, viewerOn ? t('viewerModeOff') + ' · Alt+V' : t('viewerModeHint')); } if (typeof focusThumbToggleBtn !== 'undefined' && focusThumbToggleBtn) { setTrustedHtml(focusThumbToggleBtn, trustedHtml(iconLabel('grid', thumbsCollapsed ? t('focusThumbsShow') : t('focusThumbsHide')))); focusThumbToggleBtn.classList.toggle('primary', !thumbsCollapsed && store.state.settings.viewMode === 'focus'); setElementHint(focusThumbToggleBtn, t('focusThumbsHint')); } if (typeof videoFitBtn !== 'undefined' && videoFitBtn) { setTrustedHtml(videoFitBtn, trustedHtml(iconLabel('expand', videoFit === 'cover' ? t('videoFitCover') : t('videoFitContain')))); videoFitBtn.classList.toggle('primary', videoFit === 'cover'); setElementHint(videoFitBtn, t('videoFitHint')); } if (on) closeTransientUi(); requestAnimationFrame(() => { applyGridSize(); applyFocusMainSizing(); }); } function setPureMode(on) { store.patchSettings({ pureMode: !!on }); if (on) toast(t('pureModeOn') + ' · Alt+P/C / Esc'); } function togglePureMode() { setPureMode(!store.state.settings.pureMode); } function toggleViewerMode() { store.patchSettings({ viewerMode: !store.state.settings.viewerMode }); } function toggleFocusThumbs() { store.patchSettings({ focusThumbsCollapsed: !store.state.settings.focusThumbsCollapsed }); } function toggleVideoFit() { store.patchSettings({ videoFit: store.state.settings.videoFit === 'cover' ? 'contain' : 'cover' }); } function bumpPureCursor() { if (!store.state.settings.pureMode) return; document.body.classList.remove('pure-cursor-hidden'); clearTimeout(pureCursorTimer); pureCursorTimer = setTimeout(() => { if (store.state.settings.pureMode) document.body.classList.add('pure-cursor-hidden'); }, 1800); } document.addEventListener('mousemove', bumpPureCursor, true); document.addEventListener('pointerdown', bumpPureCursor, true); const patchSettingsSoft = (() => { let pending = {}; const flush = debounce(() => { const patch = pending; pending = {}; if (Object.keys(patch).length) store.patchSettings(patch); }, 180); return (patch) => { Object.assign(pending, patch || {}); flush(); }; })(); // ---- Toolbar 渲染 ---- const tbInput = $('input', { class: 'ctrl-input', placeholder: t('addPlaceholder'), title: t('hintAddInput'), style: { width: '200px' }, onpaste: (e) => { setTimeout(() => { const raw = String(e.target.value || ''); const parts = raw.split(/[\s,;,;]+/).map(x => x.trim()).filter(Boolean); if (parts.length <= 1) return; const r = importUsernameList(parts); e.target.value = ''; toast(t('manualImportDone', r.added, r.exists)); }, 0); }, onkeypress: (e) => { if (e.key === 'Enter') { const v = normalizeUsername(e.target.value); if (!v) return; if (!isLikelyUsername(v)) { toast(t('invalidUsername')); return; } if (store.addRoom(v)) service.start(v); e.target.value = ''; } }, }); const searchInput = $('input', { class: 'ctrl-input', placeholder: t('searchPlaceholder'), title: t('hintSearchInput'), value: store.state.settings.searchQuery || '', style: { width: '150px' }, oninput: debounce((e) => store.patchSettings({ searchQuery: normalizeUsername(e.target.value), pageIndex: 0 }), 80), }); const tempUrlBtn = $('button', { class: 'ctrl-btn', title: LANG === 'zh' ? '导入临时 URL,刷新后消失,不保存到配置' : 'Import temporary URLs; they disappear after refresh', onclick: () => importTemporaryUrls(), }, LANG === 'zh' ? '导入URL' : 'URL'); const mkToggle = (label, key, sub = false) => { const cb = $('input', { type: 'checkbox', checked: sub ? store.state.settings.filter[key] : store.state.settings[key], onchange: (e) => sub ? store.update(s => { s.settings.filter[key] = e.target.checked; s.settings.pageIndex = 0; }, 'settings') : store.patchSettings({ [key]: e.target.checked, pageIndex: 0 }) }); return $('label', { class: 'toggle', title: String(label).replace(/^[^\p{L}\p{N}]+/u, '').trim() || String(label) }, [cb, label]); }; function filterModeFromState() { const f = store.state.settings.filter || {}; if (f.onlyOnline) return 'online'; if (f.hideOffline && f.hidePrivate) return 'hideOfflinePrivate'; if (f.hideOffline) return 'hideOffline'; if (f.hidePrivate) return 'hidePrivate'; return 'all'; } function applyFilterMode(mode) { const next = { hideOffline: false, hidePrivate: false, onlyOnline: false }; if (mode === 'online') next.onlyOnline = true; else if (mode === 'hideOffline') next.hideOffline = true; else if (mode === 'hidePrivate') next.hidePrivate = true; else if (mode === 'hideOfflinePrivate') { next.hideOffline = true; next.hidePrivate = true; } store.update(s => { Object.assign(s.settings.filter, next); s.settings.pageIndex = 0; }, 'settings:filter,pageIndex'); } const filterSel = $('select', { class: 'ctrl-input roomgrid-compact-select', title: LANG === 'zh' ? '筛选当前分组' : 'Filter current group', onchange: (e) => applyFilterMode(e.target.value), }, [ $('option', { value: 'all' }, LANG === 'zh' ? '全部状态' : 'All status'), $('option', { value: 'online' }, t('onlyOnline')), $('option', { value: 'hideOffline' }, t('hideOffline')), $('option', { value: 'hidePrivate' }, t('hidePrivate')), $('option', { value: 'hideOfflinePrivate' }, LANG === 'zh' ? '隐藏离线/私密' : 'Hide offline/private'), ]); filterSel.value = filterModeFromState(); const sortSel = $('select', { class: 'ctrl-input', title: t('hintSort'), style: { padding: '6px 8px' }, onchange: (e) => store.patchSettings({ sortBy: e.target.value, pageIndex: 0 }), }, [ $('option', { value: 'manual' }, t('sortManual')), $('option', { value: 'status' }, t('sortStatus')), $('option', { value: 'name' }, t('sortName')), $('option', { value: 'addedAt' }, t('sortAdded')), ]); sortSel.value = store.state.settings.sortBy; const refreshAllBtn = $('button', { class: 'ctrl-btn primary', title: t('refreshAll') + ' · R', style: { cursor: 'pointer' }, onclick: () => service.refreshAll(), html: trustedHtml(iconLabel('refresh', t('refreshAll'))), }); const sidebarToggleBtn = $('button', { class: 'ctrl-btn', title: t('hintSidebarToggle'), style: { cursor: 'pointer', minWidth: '38px', padding: '6px 10px' }, onclick: () => { const v = !store.state.settings.sidebarCollapsed; document.body.classList.toggle('rg-sidebar-collapsed', v); sidebar.classList.toggle('is-collapsed', v); store.patchSettings({ sidebarCollapsed: v }); }, html: trustedHtml(iconSvg('menu', 16)), }); const toolbarCollapseBtn = $('button', { class: 'ctrl-btn', title: LANG === 'zh' ? '收起顶部工具栏' : 'Collapse toolbar', style: { cursor: 'pointer', minWidth: '38px', padding: '6px 10px' }, onclick: () => store.patchSettings({ toolbarCollapsed: true }), }, LANG === 'zh' ? '收起' : 'Hide'); // —— 视图模式:使用 select 减少顶部按钮数量 —— // function setViewMode(mode) { if (mode !== 'focus') { store.patchSettings({ viewMode: 'grid' }); return; } if (!store.state.settings.focusedRoomId) { const vr = visibleRooms(); const first = vr.find(r => r.lastStatus === 'online') || vr[0]; if (first) store.patchSettings({ viewMode: 'focus', focusedRoomId: first.id, focusThumbsCollapsed: false }); else store.patchSettings({ viewMode: 'focus', focusThumbsCollapsed: false }); } else { store.patchSettings({ viewMode: 'focus', focusThumbsCollapsed: false }); } } const viewModeSel = $('select', { class: 'ctrl-input roomgrid-compact-select', title: t('viewModeLabel') + ' · G/F', onchange: (e) => setViewMode(e.target.value), }, [ $('option', { value: 'grid' }, t('viewGrid')), $('option', { value: 'focus' }, t('viewFocus')), ]); function syncViewSeg() { viewModeSel.value = store.state.settings.viewMode === 'focus' ? 'focus' : 'grid'; } syncViewSeg(); const layoutSel = $('select', { class: 'ctrl-input roomgrid-compact-select', title: LANG === 'zh' ? '单屏窗口数量' : 'Windows per page', onchange: (e) => store.patchSettings({ layoutSize: Number(e.target.value), pageIndex: 0 }), }, [2, 4, 6, 9].map(n => $('option', { value: String(n) }, LANG === 'zh' ? `${n} 窗口` : `${n} rooms`))); layoutSel.value = String(store.state.settings.layoutSize || 4); const pagePrevBtn = $('button', { class: 'ctrl-btn', title: LANG === 'zh' ? '上一页' : 'Previous page', onclick: () => setPageIndex((store.state.settings.pageIndex || 0) - 1), }, '‹'); const pageNextBtn = $('button', { class: 'ctrl-btn', title: LANG === 'zh' ? '下一页' : 'Next page', onclick: () => setPageIndex((store.state.settings.pageIndex || 0) + 1), }, '›'); const pageIndicator = $('span', { class: 'page-indicator' }, '1 / 1'); // 全局音量 const volSlider = $('input', { type: 'range', min: '0', max: '100', value: String(store.state.settings.volume * 100), title: t('hintVolume'), style: { width: '90px', cursor: 'pointer', accentColor: 'var(--accent)' }, oninput: (e) => { const v = Number(e.target.value) / 100; store.state.settings.volume = v; patchSettingsSoft({ volume: v }); requestAnimationFrame(() => store.state.rooms.forEach(r => applyMute(r.id))); }, onchange: (e) => store.patchSettings({ volume: Number(e.target.value) / 100 }), }); const volLabel = $('span', { style: { fontSize: '14px', color: 'var(--text-muted)', display:'inline-flex', alignItems:'center' }, html: trustedHtml(iconSvg('volume', 15)) }); // 网格大小 const sizeSlider = $('input', { type: 'range', min: '220', max: '900', value: String(store.state.settings.gridSize), title: t('hintGridSize'), style: { width: '110px', cursor: 'pointer', accentColor: 'var(--accent)' }, oninput: (e) => { const gridSize = parseInt(e.target.value, 10); store.state.settings.gridSize = gridSize; patchSettingsSoft({ gridSize }); applyGridSize(); }, onchange: (e) => store.patchSettings({ gridSize: parseInt(e.target.value, 10) }), }); const focusScaleSlider = $('input', { type: 'range', min: '78', max: '94', value: String(store.state.settings.focusMainPct || 84), title: t('hintMainRatio'), style: { width: '95px', cursor: 'pointer', accentColor: 'var(--accent)' }, oninput: (e) => { const focusMainPct = parseInt(e.target.value, 10); store.state.settings.focusMainPct = focusMainPct; patchSettingsSoft({ focusMainPct }); applyGridSize(); applyFocusMainSizing(); }, onchange: (e) => store.patchSettings({ focusMainPct: parseInt(e.target.value, 10) }), }); const focusAspectSel = $('select', { class: 'ctrl-input', title: t('hintMainAspect'), style: { padding: '6px 8px', width: '86px' }, onchange: (e) => { store.patchSettings({ focusAspect: e.target.value }); applyFocusMainSizing(); }, }, [ $('option', { value: 'auto' }, LANG === 'zh' ? '自适应' : 'Auto'), $('option', { value: '16:9' }, '16:9'), $('option', { value: '4:3' }, '4:3'), $('option', { value: '1:1' }, '1:1'), $('option', { value: '9:16' }, '9:16'), ]); focusAspectSel.value = store.state.settings.focusAspect || 'auto'; const focusThumbSlider = $('input', { type: 'range', min: '96', max: '260', value: String(store.state.settings.focusThumbSize || 150), title: t('hintThumbSize'), style: { width: '80px', cursor: 'pointer', accentColor: 'var(--accent)' }, oninput: (e) => { const focusThumbSize = parseInt(e.target.value, 10); store.state.settings.focusThumbSize = focusThumbSize; patchSettingsSoft({ focusThumbSize }); applyGridSize(); }, onchange: (e) => store.patchSettings({ focusThumbSize: parseInt(e.target.value, 10) }), }); const viewerModeBtn = $('button', { class: 'ctrl-btn', title: t('viewerModeHint'), style: { cursor: 'pointer' }, onclick: () => toggleViewerMode(), html: trustedHtml(iconLabel('focus', t('viewerMode'))), }); const focusThumbToggleBtn = $('button', { class: 'ctrl-btn', title: t('focusThumbsHint'), style: { cursor: 'pointer' }, onclick: () => toggleFocusThumbs(), html: trustedHtml(iconLabel('grid', t('focusThumbsHide'))), }); const videoFitBtn = $('button', { class: 'ctrl-btn', title: t('videoFitHint'), style: { cursor: 'pointer' }, onclick: () => toggleVideoFit(), html: trustedHtml(iconLabel('expand', t('videoFitContain'))), }); const pureModeBtn = $('button', { class: 'ctrl-btn', title: t('pureModeHint'), style: { cursor: 'pointer' }, onclick: () => togglePureMode(), html: trustedHtml(iconLabel('clean', t('pureMode'))), }); // 通知开关 const notifyToggle = $('label', { class: 'toggle', title: t('notifyTitle') }, [ $('input', { type: 'checkbox', checked: store.state.settings.notifyOnline, onchange: async (e) => { if (e.target.checked) { const ok = await Notify.request(); if (!ok) alert(t('permDenied')); } store.patchSettings({ notifyOnline: e.target.checked }); }, }), t('notifyOnline'), ]); function importUsernameList(usernames) { const clean = [...new Set((usernames || []) .map(u => normalizeUsername(u)) .filter(isLikelyUsername))]; let added = 0, exists = 0; clean.forEach(u => { const hadRoom = !!store.state.rooms.find(r => r.id === u); if (store.addRoom(u)) { if (!hadRoom) service.start(u); added++; } else { exists++; } }); return { added, exists, total: clean.length }; } function openManualImportPrompt() { closeTransientUi(); const backdrop = $('div', { class: 'roomgrid-modal-backdrop' }); const panel = $('div', { class: 'roomgrid-modal' }); const title = $('div', { class: 'roomgrid-modal-title' }, t('manualImport')); const hint = $('div', { class: 'roomgrid-modal-hint' }, t('manualImportPrompt')); const ta = $('textarea', { class: 'roomgrid-modal-textarea', placeholder: LANG === 'zh' ? 'alice\nbob\ncarol' : 'alice\nbob\ncarol', }); const countEl = $('div', { class: 'roomgrid-modal-count' }, '0'); let escHandler = null; const close = () => { if (escHandler) { document.removeEventListener('keydown', escHandler); escHandler = null; } try { backdrop.remove(); } catch (_) {} }; const parse = () => String(ta.value || '').split(/[\s,;,;]+/).map(s => s.trim()).filter(Boolean); const update = () => { const count = [...new Set(parse().map(u => normalizeUsername(u)).filter(isLikelyUsername))].length; countEl.textContent = LANG === 'zh' ? `可添加 ${count} 个用户名` : `${count} valid usernames`; }; const cancel = $('button', { class: 'ctrl-btn', onclick: close }, t('importReviewCancel')); const apply = $('button', { class: 'ctrl-btn primary', onclick: () => { const r = importUsernameList(parse()); close(); toast(t('manualImportDone', r.added, r.exists)); }, }, LANG === 'zh' ? '添加' : 'Add'); ta.addEventListener('input', update); panel.append(title, hint, ta, countEl, $('div', { class: 'roomgrid-modal-actions' }, [cancel, apply])); backdrop.appendChild(panel); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); escHandler = (ev) => { if (ev.key === 'Escape') close(); }; document.addEventListener('keydown', escHandler); document.body.appendChild(backdrop); update(); setTimeout(() => ta.focus(), 0); } // 批量添加按钮:只处理用户粘贴的用户名,不再抓取 followed-cams。 const manualImportBtn = $('button', { class: 'ctrl-btn', style: { cursor: 'pointer' }, title: t('manualImport'), onclick: openManualImportPrompt, }, t('manualImport')); // 更多菜单按钮(含语言切换、关于、捐赠) const moreBtn = $('button', { class: 'ctrl-btn', style: { cursor: 'pointer', minWidth: '40px', padding: '6px 12px' }, title: t('hintMoreMenu'), onclick: (e) => openMoreMenu(e.currentTarget), }, t('moreMenu')); // 横向分隔条:使用 var(--border) 而非硬编码 const toolbarGroup = (children, style = {}, compact = false) => $('div', { class: 'toolbar-group' + (compact ? ' compact' : ''), style }, children); const lbl = (text) => $('span', { style: { fontSize: '12px', color: 'var(--text-muted)', fontWeight: '600' } }, text); const groupTitle = (text) => $('span', { class: 'toolbar-group-title' }, text); toolbar.append( toolbarGroup([sidebarToggleBtn, groupTitle(LANG === 'zh' ? '房间' : 'Rooms'), tbInput, tempUrlBtn, searchInput]), toolbarGroup([groupTitle(LANG === 'zh' ? '视图' : 'View'), viewModeSel, layoutSel, pagePrevBtn, pageIndicator, pageNextBtn, videoFitBtn, pureModeBtn, toolbarCollapseBtn], {}, true), toolbarGroup([groupTitle(LANG === 'zh' ? '筛选' : 'Filter'), filterSel, sortSel], {}, true), $('div', { class: 'toolbar-spacer' }, [ toolbarGroup([groupTitle(LANG === 'zh' ? '播放' : 'Playback'), volLabel, volSlider, refreshAllBtn], {}, true), moreBtn, ]), ); function layoutSize() { const n = Number(store.state.settings.layoutSize || 4); return [2, 4, 6, 9].includes(n) ? n : 4; } function layoutShape() { const n = layoutSize(); if (n === 2) return { cols: 2, rows: 1 }; if (n === 6) return { cols: 3, rows: 2 }; if (n === 9) return { cols: 3, rows: 3 }; return { cols: 2, rows: 2 }; } function fullVisibleRooms() { return visibleRooms(); } function maxPageFor(total = fullVisibleRooms().length) { return Math.max(0, Math.ceil(total / layoutSize()) - 1); } function clampCurrentPage(total = fullVisibleRooms().length) { const max = maxPageFor(total); const cur = Math.max(0, Math.min(max, Number(store.state.settings.pageIndex || 0))); if (cur !== store.state.settings.pageIndex) store.state.settings.pageIndex = cur; return cur; } function pagedRooms(list = fullVisibleRooms()) { const page = clampCurrentPage(list.length); const size = layoutSize(); return list.slice(page * size, page * size + size); } function setPageIndex(n) { const max = maxPageFor(); store.patchSettings({ pageIndex: Math.max(0, Math.min(max, Number(n) || 0)) }); } function syncLayoutControls() { const size = layoutSize(); layoutSel.value = String(size); const total = fullVisibleRooms().length; const max = maxPageFor(total); const page = clampCurrentPage(total); pageIndicator.textContent = `${page + 1} / ${max + 1}`; pageIndicator.title = LANG === 'zh' ? `当前页 ${page + 1} / ${max + 1},共 ${total} 个窗口` : `Page ${page + 1} / ${max + 1}, ${total} rooms`; pagePrevBtn.disabled = page <= 0; pageNextBtn.disabled = page >= max; } function applyGridSize() { const mode = store.state.settings.viewMode; if (mode === 'focus') { const w = Math.max(45, Math.min(76, Number(store.state.settings.focusMainPct || 62))); const h = Math.max(44, Math.min(78, Number(store.state.settings.focusMainHPct || 64))); grid.style.display = 'grid'; grid.style.gridTemplateColumns = `minmax(260px, ${w}fr) 8px minmax(190px, ${100 - w}fr)`; grid.style.gridTemplateRows = `minmax(220px, ${h}fr) 8px minmax(120px, ${100 - h}fr)`; grid.style.gridTemplateAreas = `'main vbar side' 'hbar hbar side' 'bottom bottom side'`; grid.style.gap = '8px'; grid.style.alignItems = 'stretch'; grid.style.alignContent = 'stretch'; grid.style.overflow = 'hidden'; grid.style.setProperty('--focus-main-w', w + 'fr'); grid.style.setProperty('--focus-side-w', (100 - w) + 'fr'); grid.style.setProperty('--focus-main-h', h + 'fr'); grid.style.setProperty('--focus-bottom-h', (100 - h) + 'fr'); requestAnimationFrame(applyFocusMainSizing); return; } const shape = layoutShape(); grid.style.display = 'grid'; grid.style.gridTemplateAreas = ''; grid.style.gridTemplateColumns = `repeat(${shape.cols}, minmax(0, 1fr))`; grid.style.gridTemplateRows = `repeat(${shape.rows}, minmax(0, 1fr))`; grid.style.gridAutoRows = ''; grid.style.gridAutoFlow = 'row'; grid.style.gap = '8px'; grid.style.alignItems = 'stretch'; grid.style.alignContent = 'stretch'; grid.style.overflow = 'hidden'; } function gridMetrics() { const base = clampInt(store.state.settings.gridCellSize || 80, 56, 120, 80); const row = Math.max(32, Math.round(base * 9 / 16)); return { base, row }; } function defaultTileSize() { const { base, row } = gridMetrics(); const target = clampInt(store.state.settings.gridSize || 400, 220, 900, 400); const cols = clampInt(Math.round(target / base), 3, 18, 5); const rows = clampInt(Math.round((cols * base * 9 / 16) / row), 3, 18, cols); return { cols, rows }; } function tileSizeForRoom(room) { const groupId = store.state.settings.activeGroup || DEFAULT_GROUP_ID; const map = normalizeCardSizeMap(room?.cardSizeByGroup); return normalizeCardSize(map[groupId]) || defaultTileSize(); } function applyCardGridSizing(card, room) { if (!card) return; card.classList.remove('is-focus-main'); card.style.gridColumn = ''; card.style.gridRow = ''; card.style.width = '100%'; card.style.height = '100%'; card.style.maxWidth = ''; card.style.maxHeight = ''; card.style.aspectRatio = 'auto'; } function parseFocusAspect() { const v = store.state.settings.focusAspect || 'auto'; if (v === 'auto') return null; if (v === '4:3') return 4 / 3; if (v === '1:1') return 1; if (v === '9:16') return 9 / 16; return 16 / 9; } function resetCardSizing(card) { if (!card) return; card.classList.remove('is-focus-main'); card.style.width = ''; card.style.height = ''; card.style.flex = ''; card.style.margin = ''; card.style.maxWidth = ''; card.style.maxHeight = ''; card.style.aspectRatio = '16 / 9'; card.style.gridColumn = ''; card.style.gridRow = ''; } function applyFocusMainSizing() { if (store.state.settings.viewMode !== 'focus') return; const row = grid.querySelector('.focused-row'); const card = row && row.querySelector('.cam-card'); if (!row || !card) return; card.classList.add('is-focus-main'); card.style.width = '100%'; card.style.height = '100%'; card.style.maxWidth = '100%'; card.style.maxHeight = '100%'; card.style.flex = '1 1 auto'; card.style.aspectRatio = 'auto'; } window.addEventListener('resize', debounce(applyFocusMainSizing, 120)); // ---- 侧边栏渲染 ---- function renderSidebar() { sidebar.replaceChildren(); const collapsed = !!store.state.settings.sidebarCollapsed; sidebar.dataset.collapsed = collapsed ? 'true' : 'false'; sidebar.classList.toggle('is-collapsed', collapsed); document.body.classList.toggle('rg-sidebar-collapsed', collapsed); if (collapsed) { sidebar.style.setProperty('display', 'none', 'important'); sidebar.style.setProperty('width', '0px', 'important'); sidebar.style.setProperty('min-width', '0px', 'important'); sidebar.style.setProperty('max-width', '0px', 'important'); sidebar.style.setProperty('flex-basis', '0px', 'important'); sidebar.style.setProperty('padding', '0px', 'important'); sidebar.style.setProperty('border-width', '0px', 'important'); return; } sidebar.style.setProperty('display', 'flex', 'important'); sidebar.style.setProperty('width', '220px', 'important'); sidebar.style.setProperty('min-width', '220px', 'important'); sidebar.style.setProperty('max-width', '220px', 'important'); sidebar.style.setProperty('flex-basis', '220px', 'important'); sidebar.style.setProperty('padding', '10px 8px', 'important'); sidebar.style.setProperty('border-width', '1px', 'important'); const counts = countByGroup(); const sidebarRooms = allRoomsForView(); const total = sidebarRooms.length; const online = sidebarRooms.filter(r => r.lastStatus === 'online').length; const muted = sidebarRooms.filter(r => r.muted).length; sidebar.append($('div', { class: 'sidebar-brand' }, [ $('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' } }, [ $('div', { class: 'title' }, 'RoomGrid'), $('button', { class: 'sidebar-collapse-btn', title: LANG === 'zh' ? '收起左侧分组' : 'Collapse groups', onclick: () => { document.body.classList.add('rg-sidebar-collapsed'); sidebar.classList.add('is-collapsed'); store.patchSettings({ sidebarCollapsed: true }); }, }, LANG === 'zh' ? '收起' : 'Hide'), ]), $('div', { class: 'sub' }, t('appTagline')), ])); sidebar.append($('div', { class: 'sidebar-section-title' }, t('groupsHeading'))); const ordered = [...store.state.groups].sort((a, b) => a.order - b.order); const groupDisplayName = (g) => { if (g.name === '__library__') return t('groupLibrary'); if (g.name === '__all__') return t('groupAll'); if (g.name === '__online__') return t('groupOnline'); if (g.name === '__fav__') return t('groupFav'); return g.name; }; for (const g of ordered) { const isActive = store.state.settings.activeGroup === g.id; const tab = $('button', { class: 'group-tab' + (isActive ? ' active' : ''), dataset: { groupId: g.id }, title: g.id === LIBRARY_GROUP_ID ? t('hintLibraryTab') : t('hintGroupTab', groupDisplayName(g)), onclick: () => store.setActiveGroup(g.id), oncontextmenu: (e) => { if (!g.system) { e.preventDefault(); openGroupMenu(e, g); } }, ondragover: (e) => { if (g.id === LIBRARY_GROUP_ID || g.id === ONLINE_GROUP_ID) return; e.preventDefault(); tab.classList.add('drop-target'); }, ondragleave: () => tab.classList.remove('drop-target'), ondrop: (e) => { if (g.id === LIBRARY_GROUP_ID || g.id === ONLINE_GROUP_ID) return; e.preventDefault(); tab.classList.remove('drop-target'); const id = e.dataTransfer.getData('text/room-id'); if (id) store.moveToGroup(id, g.id); }, }, [ $('span', { class: 'group-name' }, groupDisplayName(g)), $('span', { class: 'group-count' }, String(counts[g.id] || 0)), ]); sidebar.appendChild(tab); // v15.6-minimal: 分组内用户展开列表。只读展示,不改变原来的分组数据。 const collapsedMap = loadGroupCollapseState(); const collapsedUsers = collapsedMap[g.id] === true; const members = (g.id === LIBRARY_GROUP_ID ? allRoomsForView() : allRoomsForView().filter(r => roomInGroup(r, g.id))) .sort((a, b) => roomOrderInGroup(a, g.id) - roomOrderInGroup(b, g.id)); const memberBox = $('div', { class: 'group-user-list', style: { display: collapsedUsers ? 'none' : 'flex', flexDirection: 'column', gap: '2px', padding: '2px 4px 6px 18px' } }); tab.insertBefore($('span', { class: 'group-fold-toggle', title: LANG === 'zh' ? '展开/折叠组内用户' : 'Expand/collapse users', onclick: (ev) => { ev.preventDefault(); ev.stopPropagation(); const m = loadGroupCollapseState(); m[g.id] = !(m[g.id] === true); saveGroupCollapseState(m); renderSidebar(); }, style: { opacity: '.65', minWidth: '12px', display: 'inline-block', textAlign: 'center' }, }, collapsedUsers ? '›' : '⌄'), tab.firstChild); members.forEach(r => { memberBox.appendChild($('button', { class: 'group-user-item', title: r.sourceUrl || r.id, style: { border: '0', background: 'transparent', textAlign: 'left', cursor: 'pointer', padding: '3px 6px', borderRadius: '6px', fontSize: '11px', color: r.lastStatus === 'online' ? 'var(--success)' : 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onclick: () => { store.setActiveGroup(g.id === LIBRARY_GROUP_ID ? LIBRARY_GROUP_ID : g.id); store.patchSettings({ focusedRoomId: r.id, pageIndex: Math.max(0, Math.floor(members.findIndex(x => x.id === r.id) / layoutSize())) }); }, }, `${r.temporary ? '临时 · ' : ''}${r.displayName || r.id}`)); }); if (members.length) sidebar.appendChild(memberBox); } sidebar.appendChild($('button', { class: 'group-tab', title: t('hintNewGroup'), style: { color: 'var(--text-secondary)', marginTop: '8px', justifyContent: 'center', borderStyle: 'dashed' }, onclick: () => { const name = prompt(t('newGroupPrompt')); if (name && name.trim()) store.addGroup(name.trim()); }, }, t('newGroup'))); const statColorOnline = 'var(--success)'; sidebar.appendChild($('div', { class: 'sidebar-stats' }, [ $('div', { class: 'sidebar-stat' }, [ $('div', { class: 'k' }, t('statTotal')), $('div', { class: 'v' }, String(total)) ]), $('div', { class: 'sidebar-stat' }, [ $('div', { class: 'k' }, t('statOnline')), $('div', { class: 'v', style: { color: statColorOnline } }, String(online)) ]), $('div', { class: 'sidebar-stat' }, [ $('div', { class: 'k' }, t('statMuted')), $('div', { class: 'v' }, String(muted)) ]), ])); } function countByGroup() { const rooms = allRoomsForView(); const c = { [LIBRARY_GROUP_ID]: rooms.length, [ONLINE_GROUP_ID]: rooms.filter(r => r.lastStatus === 'online').length }; for (const r of rooms) for (const g of getRoomGroups(r)) c[g] = (c[g] || 0) + 1; return c; } function openGroupMenu(e, g) { const menu = $('div', { class: 'menu-pop', style: { left: e.clientX + 'px', top: e.clientY + 'px' } }, [ $('button', { onclick: () => { const n = prompt(t('renameGroupPrompt'), g.name); if (n) store.renameGroup(g.id, n.trim()); menu.remove(); } }, t('renameGroup')), $('button', { class: 'danger', onclick: () => { if (confirm(t('deleteGroupConfirm', g.name))) store.removeGroup(g.id); menu.remove(); } }, t('deleteGroup')), ]); document.body.appendChild(menu); const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close); } }; setTimeout(() => document.addEventListener('click', close), 0); } // ---- 卡片管理 ---- const cardMap = new Map(); // id -> {root, video, statusEl, badgeEl} // v15.6-minimal: 临时 URL 窗口。只存在于当前页面内存,刷新后消失;不写入 localStorage。 const tempRooms = []; const tempGroupCollapsedKey = 'ryujo_multicam_group_user_collapsed_v1'; function loadGroupCollapseState() { try { return JSON.parse(localStorage.getItem(tempGroupCollapsedKey) || '{}') || {}; } catch (_) { return {}; } } function saveGroupCollapseState(v) { try { localStorage.setItem(tempGroupCollapsedKey, JSON.stringify(v || {})); } catch (_) {} } function allRoomsForView() { return [...store.state.rooms, ...tempRooms]; } function findRoomAny(id) { id = String(id || ''); return store.state.rooms.find(r => r.id === id) || tempRooms.find(r => r.id === id) || null; } function isDirectMediaUrl(url) { return /\.(m3u8|mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(String(url || '')); } function isHlsUrl(url) { return /\.m3u8(?:[?#].*)?$/i.test(String(url || '')) || /m3u8/i.test(String(url || '')); } function tempIdFromUrl(url) { try { const u = new URL(String(url)); const name = (u.hostname + u.pathname).replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'url'; return 'tmp_' + name + '_' + Math.random().toString(36).slice(2, 7); } catch (_) { return 'tmp_url_' + Math.random().toString(36).slice(2, 9); } } function extractMediaUrlFromHtml(html, baseUrl) { const srcs = []; const push = (v) => { if (!v) return; let s = String(v).replace(/\\\//g, '/').replace(/&/g, '&').trim(); try { s = new URL(s, baseUrl).href; } catch (_) {} if (isSafeHttpUrl(s) && isDirectMediaUrl(s)) srcs.push(s); }; String(html || '').replace(/https?:\\?\/\\?\/[^\s"'<>]+?(?:m3u8|mp4|webm|mov|m4v)(?:[^\s"'<>]*)?/ig, m => { push(m); return m; }); String(html || '').replace(/(?:src|source|file|url)["']?\s*[:=]\s*["']([^"']+)["']/ig, (_, m) => { push(m); return _; }); return srcs.find(isHlsUrl) || srcs[0] || ''; } async function fetchTextBestEffort(url) { const res = await fetch(url, { credentials: 'include' }); if (!res.ok) throw new Error('http ' + res.status); return res.text(); } async function importTemporaryUrls() { const raw = prompt(LANG === 'zh' ? '粘贴 URL,支持房间页、m3u8、mp4/webm。普通网页会尝试解析真实播放源;失败请手动粘贴真实播放地址。' : 'Paste URLs. Room pages, m3u8, mp4/webm are supported. Normal pages will be parsed best-effort.'); if (!raw) return; const parts = String(raw).split(/[\s,,]+/).map(x => x.trim()).filter(Boolean); let added = 0, failed = 0; for (const part of parts) { try { if (!isSafeHttpUrl(part)) { failed++; continue; } const u = new URL(part, location.href); if (safeChaturbateHost(u.hostname)) { const username = normalizeUsername(part); if (username && isLikelyUsername(username)) { const activeGroup = store.state.settings.activeGroup; const groupId = (!activeGroup || activeGroup === LIBRARY_GROUP_ID || activeGroup === ONLINE_GROUP_ID) ? DEFAULT_GROUP_ID : activeGroup; if (!findRoomAny(username)) tempRooms.push({ id: username, group: groupId, groups: [groupId], addedAt: Date.now(), order: 100000 + tempRooms.length, lastStatus: 'unknown', lastSeenOnline: 0, muted: false, temporary: true }); service.start(username); added++; continue; } } let mediaUrl = isDirectMediaUrl(part) ? part : ''; if (!mediaUrl) { try { mediaUrl = extractMediaUrlFromHtml(await fetchTextBestEffort(part), part); } catch (_) {} } if (!mediaUrl) { failed++; continue; } const id = tempIdFromUrl(mediaUrl); const activeGroup = store.state.settings.activeGroup; const groupId = (!activeGroup || activeGroup === LIBRARY_GROUP_ID || activeGroup === ONLINE_GROUP_ID) ? DEFAULT_GROUP_ID : activeGroup; tempRooms.push({ id, displayName: new URL(mediaUrl).hostname, sourceUrl: mediaUrl, group: groupId, groups: [groupId], addedAt: Date.now(), order: 100000 + tempRooms.length, lastStatus: 'online', lastSeenOnline: Date.now(), muted: false, temporary: true }); added++; } catch (_) { failed++; } } store.patchSettings({ pageIndex: 0 }); renderSidebar(); renderGrid(); toast(LANG === 'zh' ? `临时 URL:新增 ${added},失败 ${failed}` : `Temporary URLs: ${added} added, ${failed} failed`); } function statusMeta(s) { switch (s) { case 'online': return { color: '#16a34a', label: t('stOnline') }; case 'offline': return { color: '#64748b', label: t('stOffline') }; case 'private': return { color: '#d97706', label: t('stPrivate') }; case 'loading': return { color: '#2563eb', label: t('stLoading') }; case 'error': return { color: '#dc2626', label: t('stError') }; default: return { color: '#64748b', label: t('stUnknown') }; } } function buildCard(room) { // 卡片本身不做 draggable,避免 video controls 拦截 dragstart const card = $('div', { class: 'cam-card rg-card-enter', dataset: { roomId: room.id } }); setTimeout(() => { try { card.classList.remove('rg-card-enter'); } catch (_) {} }, 220); // 状态徽标(左上) const badge = $('div', { class: 'pill', style: { position: 'absolute', top: '8px', left: '8px', zIndex: '20' } }); // 名字(左下,常驻) const name = $('div', { class: 'name-label', style: { position: 'absolute', bottom: '8px', left: '8px', zIndex: '20', background: 'rgba(15,23,42,.46)', color: '#fff', padding: '5px 9px', borderRadius: '999px', fontSize: '12px', fontWeight: '650', pointerEvents: 'none', maxWidth: '72%', border: '1px solid rgba(255,255,255,.16)', backdropFilter: 'blur(10px)', WebkitBackdropFilter: 'blur(10px)', boxShadow: 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, }, room.id); // 拖拽手柄(hover 显示,承担 dragstart) const dragHandle = $('div', { class: 'mc-hover-ui drag-handle', draggable: 'true', title: LANG === 'zh' ? '拖动以排序 / 跨组' : 'Drag to reorder / move group', style: { position: 'absolute', top: '6px', left: '50%', right: 'auto', bottom: 'auto', transform: 'translateX(-50%)', zIndex: '25', padding: '5px 14px', borderRadius: '10px', background: 'rgba(255,255,255,.86)', color: 'var(--text-secondary)', cursor: 'grab', fontSize: '13px', userSelect: 'none', letterSpacing: '2px', lineHeight: '1', fontFamily: 'sans-serif', }, ondragstart: (e) => { e.dataTransfer.setData('text/room-id', room.id); e.dataTransfer.effectAllowed = 'move'; // 自定义拖拽缩略图(用未加 dragging 状态的 card 拍快照) try { e.dataTransfer.setDragImage(card, card.offsetWidth / 2, 24); } catch (_) {} // 推迟到下一帧再加 .dragging 类,避免缩略图被半透明状态污染 setTimeout(() => card.classList.add('dragging'), 0); dragHandle.style.cursor = 'grabbing'; }, ondragend: () => { card.classList.remove('dragging'); dragHandle.style.cursor = 'grab'; // 清掉所有 drop indicator document.querySelectorAll('.cam-card').forEach(c => c.classList.remove('drop-before', 'drop-after')); }, html: trustedHtml(iconSvg('drag', 15)), }); // 操作条(右上,hover 显示) const opsRow = $('div', { class: 'mc-hover-ui ops-row', style: { position: 'absolute', top: '10px', right: '10px', left: 'auto', bottom: 'auto', zIndex: '25', display: 'flex', gap: '6px', background: 'transparent', width: 'auto', height: 'auto' } }); const mkOp = (iconName, title, onclick, opts = {}) => setElementHint($('button', { class: 'icon-btn' + (opts.danger ? ' danger' : '') + (opts.extra ? ' ops-extra' : ''), title, html: trustedHtml(iconSvg(iconName, 15)), onclick: (e) => { e.stopPropagation(); onclick(e); }, }), title); const playBtn = mkOp('pause', t('opPause'), () => { service.togglePause(room.id); requestAnimationFrame(() => updateCardButtons(room.id)); }); const refreshBtn = mkOp('refresh', t('opRefresh'), () => service.refresh(room.id), { extra: true }); const muteBtn = mkOp(room.muted ? 'volume' : 'volumeOff', t('opMuteToggle'), () => { const target = store.state.rooms.find(r => r.id === room.id); if (!target) return; store.patchRoom(room.id, { muted: !target.muted }); requestAnimationFrame(() => applyMute(room.id)); }); const shotBtn = mkOp('camera', t('opScreenshot'), () => captureCardScreenshot(room.id), { extra: true }); const recordBtn = mkOp('record', t('opRecordStart'), () => toggleCardRecording(room.id), { extra: true }); const pipBtn = mkOp('pip', t('opPiP'), async () => { const v = cardMap.get(room.id)?.video; if (!v) return; try { if (document.pictureInPictureElement) await document.exitPictureInPicture(); else await v.requestPictureInPicture(); } catch (_) {} }, { extra: true }); const fullBtn = mkOp('expand', t('opFullscreen'), () => { document.fullscreenElement ? document.exitFullscreen() : card.requestFullscreen().catch(() => {}); }, { extra: true }); const moreOpsBtn = mkOp('more', t('moreOps'), (ev) => openCardOpsMenu(ev, room.id, card)); const removeBtn = mkOp('close', t('opRemove'), () => { stopCardRecording(room.id, true); const globallyRemoved = store.removeRoomFromActiveGroup(room.id); if (globallyRemoved) service.stop(room.id); else service.detachVideo(room.id); }, { danger: true }); playBtn.classList.add('quick-op', 'quick-play'); muteBtn.classList.add('quick-op', 'quick-mute'); refreshBtn.classList.add('quick-op', 'quick-refresh', 'quick-optional'); fullBtn.classList.add('quick-op', 'quick-full', 'quick-optional'); moreOpsBtn.classList.add('quick-op', 'quick-more'); opsRow.append(playBtn, muteBtn, refreshBtn, recordBtn, fullBtn, moreOpsBtn); // 状态文字(中央覆盖层) const statusEl = $('div', { class: 'status-layer' }); card.append(badge, name, opsRow, dragHandle, statusEl); // —— 双击全屏 —— card.addEventListener('mouseenter', () => { try { card.style.filter = 'none'; card.style.opacity = '1'; const v = card.querySelector('.cam-video'); if (v) { v.style.filter = 'none'; v.style.opacity = '1'; v.controls = false; v.removeAttribute('controls'); } } catch (_) {} }); card.addEventListener('dblclick', (e) => { if (e.target.closest('.icon-btn') || e.target.closest('.drag-handle')) return; const transform = getVideoTransform(room.id); if (transform.zoom !== 1 || transform.x || transform.y) { e.preventDefault(); e.stopPropagation(); patchVideoTransform(room.id, { zoom: 1, x: 0, y: 0 }); return; } document.fullscreenElement ? document.exitFullscreen() : card.requestFullscreen().catch(() => {}); }); card.addEventListener('contextmenu', (e) => { if (e.target.closest('.icon-btn') || e.target.closest('.drag-handle')) return; e.preventDefault(); openCardOpsMenu(e, room.id, card); }); // —— 卡片作为 drop 目标(dragover/drop 不会被 video 拦截)—— card.addEventListener('dragover', (e) => { const draggingEl = document.querySelector('.cam-card.dragging'); if (!draggingEl) return; const draggingId = draggingEl.dataset.roomId; if (draggingId === room.id) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; // 计算插入位置:鼠标在卡片左/上半 → before,右/下半 → after const rect = card.getBoundingClientRect(); // grid 布局可能横可能竖,取占比更大的轴 const useHoriz = rect.width >= rect.height; const ratio = useHoriz ? (e.clientX - rect.left) / rect.width : (e.clientY - rect.top) / rect.height; const before = ratio < 0.5; // 仅切换两个 class,避免反复 toggle 导致动画抖动 if (before) { if (!card.classList.contains('drop-before')) { card.classList.add('drop-before'); card.classList.remove('drop-after'); } } else { if (!card.classList.contains('drop-after')) { card.classList.add('drop-after'); card.classList.remove('drop-before'); } } }); card.addEventListener('dragleave', (e) => { // 只有真的离开卡片才移除(避免子元素事件冒泡误清) if (!card.contains(e.relatedTarget)) { card.classList.remove('drop-before', 'drop-after'); } }); card.addEventListener('drop', (e) => { e.preventDefault(); const fromId = e.dataTransfer.getData('text/room-id'); const isBefore = card.classList.contains('drop-before'); card.classList.remove('drop-before', 'drop-after'); if (!fromId || fromId === room.id) return; reorderByDrop(fromId, room.id, isBefore ? 'before' : 'after'); }); installCardZoomHandlers(card, room.id); cardMap.set(room.id, { root: card, video: null, statusEl, badge, playBtn, muteBtn, recordBtn, removeBtn, resizeObserver: null }); // —— 响应式:根据卡片宽度自动加 .compact / .tiny class —— // 避免按钮 + pill + 名字标签在小卡片时互相覆盖 try { const ro = new ResizeObserver(entries => { for (const entry of entries) { const w = entry.contentRect.width; card.classList.toggle('compact', w < 320); card.classList.toggle('tiny', w < 230); } }); ro.observe(card); const entry = cardMap.get(room.id); if (entry) entry.resizeObserver = ro; } catch (_) { /* 旧浏览器忽略 */ } // —— focus 模式:单击非主屏卡片(缩略图)→ 设为主屏 —— card.addEventListener('click', (e) => { if (store.state.settings.viewMode !== 'focus') return; if (e.target.tagName === 'VIDEO') return; // 让 video 控件正常响应 if (e.target.closest('.icon-btn') || e.target.closest('.drag-handle')) return; if (store.state.settings.focusedRoomId === room.id) return; // v15.4:新主屏布局的右侧 / 下方副窗口也可以直接点击切主屏。 const p = card.parentElement; const canPromote = p?.classList.contains('thumbs-row') || p?.classList.contains('focus-side-row') || p?.classList.contains('focus-bottom-row'); if (!canPromote) return; focusRoom(room.id); }); return card; } function updateCardButtons(id) { const c = cardMap.get(id); if (!c) return; const room = store.state.rooms.find(r => r.id === id); const paused = service.isPaused(id); if (c.playBtn) { setTrustedHtml(c.playBtn, trustedHtml(iconSvg(paused ? 'play' : 'pause', 15))); setElementHint(c.playBtn, paused ? t('opResume') : t('opPause')); } if (c.muteBtn && room) { setTrustedHtml(c.muteBtn, trustedHtml(iconSvg(room.muted ? 'volume' : 'volumeOff', 15))); setElementHint(c.muteBtn, room.muted ? (LANG === 'zh' ? '取消静音' : 'Unmute') : (LANG === 'zh' ? '静音' : 'Mute')); } if (c.removeBtn) { setElementHint(c.removeBtn, (store.state.settings.activeGroup === LIBRARY_GROUP_ID || store.state.settings.activeGroup === ONLINE_GROUP_ID) ? t('opDeleteRoom') : t('opRemove')); } const rec = recordings.get(id); if (c.recordBtn) { setTrustedHtml(c.recordBtn, trustedHtml(iconSvg(rec ? 'stop' : 'record', 15))); setElementHint(c.recordBtn, rec?.waitingForSource ? t('recordingWaiting') : (rec ? t('opRecordStop') : t('opRecordStart'))); c.recordBtn.classList.toggle('recording', !!rec); c.recordBtn.classList.toggle('waiting', !!rec?.waitingForSource); } c.root.classList.toggle('recording', !!rec); c.root.classList.toggle('recording-waiting', !!rec?.waitingForSource); } function captureCardScreenshot(roomId) { const c = cardMap.get(roomId); const video = c?.video; if (!video || !video.videoWidth || !video.videoHeight) { toast(t('captureFailed')); return; } try { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) => { if (!blob) { toast(t('captureFailed')); return; } downloadBlob(blob, `roomgrid-${safeFilePart(roomId)}-${stampForFile()}.png`); toast(t('screenshotSaved')); }, 'image/png'); } catch (err) { console.warn('[RoomGrid] screenshot failed', err); toast(t('captureFailed')); } } function getVideoCaptureStream(video) { if (!video) return null; if (typeof video.captureStream === 'function') return video.captureStream(); if (typeof video.mozCaptureStream === 'function') return video.mozCaptureStream(); return null; } function recordingSegmentMs() { return clampInt(store.state.settings.recordingSegmentMinutes, 1, 180, 10) * 60 * 1000; } function recordingVideoBitrate() { return clampInt(store.state.settings.recordingVideoBitrate, 500000, 20000000, 6000000); } function recordingMimeType() { if (typeof MediaRecorder === 'undefined') return ''; const mimes = ['video/mp4;codecs=h264,aac', 'video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm']; return mimes.find(m => { try { return MediaRecorder.isTypeSupported(m); } catch (_) { return false; } }) || ''; } function recordingFileExt(mimeType) { return String(mimeType || '').includes('mp4') ? 'mp4' : 'webm'; } function recordingReasonSuffix(reason) { if (reason === 'source-loss') return '-source-paused'; if (reason === 'manual' || reason === 'final') return '-final'; return ''; } function getRecordingVideo(roomId) { const c = cardMap.get(roomId); if (c?.video) return c.video; try { return document.querySelector(`video[data-multicam-room-id="${String(roomId).replace(/"/g, '')}"]`); } catch (_) { return null; } } function createRecordingStream(video) { const source = getVideoCaptureStream(video); if (!source) return null; const videoTracks = source.getVideoTracks().filter(track => track.readyState === 'live'); const audioTracks = source.getAudioTracks().filter(track => track.readyState === 'live'); if (!videoTracks.length || typeof MediaRecorder === 'undefined') { try { source.getTracks().forEach(track => track.stop()); } catch (_) {} return null; } return new MediaStream([...videoTracks, ...audioTracks]); } function cleanupRecordingStream(rec) { try { rec.stream?.getTracks?.().forEach(track => track.stop()); } catch (_) {} rec.stream = null; } function saveRecordingSegment(roomId, rec, reason = 'segment') { const chunks = Array.isArray(rec?.chunks) ? rec.chunks.filter(chunk => chunk && chunk.size) : []; if (!chunks.length) return false; const mimeType = rec.mimeType || 'video/webm'; const blob = new Blob(chunks, { type: mimeType }); const part = String(rec.segmentIndex || 1).padStart(3, '0'); const ext = recordingFileExt(mimeType); downloadBlob(blob, `roomgrid-${safeFilePart(roomId)}-${stampForFile()}-part${part}${recordingReasonSuffix(reason)}.${ext}`); rec.savedSegments = (Number(rec.savedSegments) || 0) + 1; rec.savedBytes = (Number(rec.savedBytes) || 0) + blob.size; rec.lastSavedAt = Date.now(); recordingLog.unshift({ roomId, reason, ts: Date.now(), size: blob.size, part, ext }); recordingLog.splice(80); return true; } function finishCurrentRecordingSegment(roomId, rec, reason) { if (!rec || rec.stopping) return; try { clearTimeout(rec.timer); } catch (_) {} rec.timer = null; rec.stopReason = reason; const recorder = rec.recorder; if (recorder && recorder.state !== 'inactive') { rec.stopping = true; try { recorder.requestData?.(); } catch (_) {} try { recorder.stop(); } catch (_) { handleRecorderStop(roomId, rec); } } else { handleRecorderStop(roomId, rec); } } function handleRecorderStop(roomId, rec) { if (!rec) return; const reason = rec.stopReason || (rec.manualStop ? 'manual' : 'segment'); try { clearTimeout(rec.timer); } catch (_) {} rec.timer = null; rec.stopping = false; const hadData = saveRecordingSegment(roomId, rec, reason); cleanupRecordingStream(rec); rec.recorder = null; rec.chunks = []; if (recordings.get(roomId) !== rec) return; if (rec.manualStop || reason === 'final' || reason === 'manual') { recordings.delete(roomId); setRecordingIntent(roomId, false); updateCardButtons(roomId); if (!rec.silentStop) toast(hadData ? t('recordingFinalSaved') : t('recordingNoData')); return; } if (reason === 'source-loss') { rec.waitingForSource = true; updateCardButtons(roomId); if (!rec.sourceLossToastShown && !rec.silentStop) { rec.sourceLossToastShown = true; toast(t('recordingPausedSource')); } return; } if (reason === 'segment') { if (hadData && !rec.silentStop) toast(t('recordingSegmentSaved')); if (!startRecordingSegment(roomId, rec)) { rec.waitingForSource = true; updateCardButtons(roomId); if (!rec.sourceLossToastShown && !rec.silentStop) { rec.sourceLossToastShown = true; toast(t('recordingPausedSource')); } } } } function startRecordingSegment(roomId, rec) { const video = getRecordingVideo(roomId); const stream = createRecordingStream(video); if (!stream) return false; const mimeType = recordingMimeType(); const options = { videoBitsPerSecond: recordingVideoBitrate() }; if (mimeType) options.mimeType = mimeType; let recorder; try { recorder = new MediaRecorder(stream, options); } catch (_) { try { stream.getTracks().forEach(track => track.stop()); } catch (e) {} return false; } rec.roomId = roomId; rec.stream = stream; rec.recorder = recorder; rec.mimeType = mimeType || 'video/webm'; rec.chunks = []; rec.startedAt = Date.now(); rec.segmentIndex = (Number(rec.segmentIndex) || 0) + 1; rec.waitingForSource = false; rec.sourceLossToastShown = false; rec.manualStop = false; rec.silentStop = false; rec.stopReason = ''; recorder.ondataavailable = ev => { if (ev.data && ev.data.size) rec.chunks.push(ev.data); }; recorder.onstop = () => handleRecorderStop(roomId, rec); try { recorder.start(1000); } catch (_) { cleanupRecordingStream(rec); rec.recorder = null; rec.chunks = []; return false; } rec.timer = setTimeout(() => { const current = recordings.get(roomId); if (current === rec && !rec.manualStop && !rec.waitingForSource) { finishCurrentRecordingSegment(roomId, rec, 'segment'); } }, recordingSegmentMs()); updateCardButtons(roomId); return true; } function startCardRecording(roomId, options = {}) { if (recordings.has(roomId)) return; const opts = options && typeof options === 'object' ? options : {}; if (!opts.skipConfirm && !confirm(t('recordingConsent'))) return; const rec = { roomId, segmentIndex: 0, chunks: [] }; recordings.set(roomId, rec); if (!startRecordingSegment(roomId, rec)) { if (opts.waitForSource) { rec.waitingForSource = true; rec.sourceLossToastShown = true; rec.startedAt = Date.now(); setRecordingIntent(roomId, true); updateCardButtons(roomId); return; } recordings.delete(roomId); updateCardButtons(roomId); toast(t('recordingUnsupported')); return; } setRecordingIntent(roomId, true); toast(t('recordingStarted')); } function pauseRecordingForSourceLoss(roomId, opts = {}) { const rec = recordings.get(roomId); if (!rec || rec.manualStop) return false; if (rec.waitingForSource && !rec.recorder) return false; rec.waitingForSource = true; rec.silentStop = !!opts.silent; finishCurrentRecordingSegment(roomId, rec, 'source-loss'); updateCardButtons(roomId); return true; } function resumeWaitingRecording(roomId) { const rec = recordings.get(roomId); if (!rec || !rec.waitingForSource || rec.manualStop || rec.recorder) return false; if (!startRecordingSegment(roomId, rec)) return false; toast(t('recordingResumed')); return true; } function stopCardRecording(roomId, options = false) { const rec = recordings.get(roomId); if (!rec) return; const opts = typeof options === 'boolean' ? { silent: options, final: true } : (options || {}); rec.manualStop = true; rec.waitingForSource = false; rec.silentStop = !!opts.silent; if (rec.recorder) { finishCurrentRecordingSegment(roomId, rec, opts.final === false ? 'manual' : 'final'); } else { cleanupRecordingStream(rec); recordings.delete(roomId); setRecordingIntent(roomId, false); updateCardButtons(roomId); if (!rec.silentStop) toast(t('recordingNoData')); } } function toggleCardRecording(roomId) { if (recordings.has(roomId)) stopCardRecording(roomId); else startCardRecording(roomId); } function stopAllRecordings(options = {}) { [...recordings.keys()].forEach(id => stopCardRecording(id, options)); } function openRecordingSettings() { openRecordingSettingsPanel(); } function fmtDuration(ms) { const sec = Math.max(0, Math.floor(Number(ms || 0) / 1000)); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; } function fmtBytes(bytes) { const n = Math.max(0, Number(bytes) || 0); if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; } function openToolPanel(title, build) { closeTransientUi(); document.querySelectorAll('.roomgrid-modal-backdrop').forEach(el => { try { el.remove(); } catch (_) {} }); const backdrop = $('div', { class: 'roomgrid-modal-backdrop' }); const panel = $('div', { class: 'roomgrid-modal', style: { width: 'min(760px, calc(100vw - 36px))', maxHeight: 'min(780px, calc(100vh - 36px))', overflowY: 'auto' } }); const head = $('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', marginBottom: '10px' } }, [ $('div', { class: 'roomgrid-modal-title', style: { marginBottom: '0' } }, title), $('button', { class: 'ctrl-btn', onclick: () => close() }, '×'), ]); const body = $('div'); let cleanup = null; const close = () => { try { cleanup?.(); } catch (_) {} try { backdrop.remove(); } catch (_) {} }; backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }); panel.append(head, body); backdrop.appendChild(panel); document.body.appendChild(backdrop); cleanup = build?.(body, close) || null; return { body, close }; } function buildShareSnapshot() { const snapshot = sanitizeState({ ...store.state, settings: { ...store.state.settings, pageIndex: 0, focusedRoomId: null, pureMode: false, viewerMode: false, toolbarCollapsed: false, sidebarCollapsed: false, showRecordingOnly: false, }, }); return snapshot; } function buildShareLink() { const url = new URL(location.href); url.searchParams.set('multicam_mode', '1'); url.hash = 'roomgrid=' + encodeSharePayload(buildShareSnapshot()); return url.toString(); } async function openSharePanel() { openToolPanel(t('sharePanel'), (body) => { const link = buildShareLink(); const input = $('textarea', { class: 'roomgrid-modal-textarea', readonly: true, value: link, style: { minHeight: '120px' }, onclick: (e) => e.currentTarget.select(), }); body.append( $('div', { class: 'roomgrid-modal-hint' }, t('shareLinkHint')), input, $('div', { class: 'roomgrid-modal-actions' }, [ $('button', { class: 'ctrl-btn primary', onclick: async () => { await copyText(link); toast(t('copied')); } }, t('shareCopyLink')), ]), ); }); } function loadSharedWorkspaceFromHash() { const hash = String(location.hash || '').replace(/^#/, ''); const payload = hash.startsWith('roomgrid=') ? hash.slice('roomgrid='.length) : ''; if (!payload) return false; try { const shared = sanitizeState(decodeSharePayload(payload)); if (!shared.rooms.length) throw new Error('empty share'); if (!confirm(t('shareImportPrompt', shared.rooms.length))) return false; backupCurrentConfig(); store.replaceState(shared, 'all'); toast(t('shareImported')); try { const url = new URL(location.href); url.hash = ''; history.replaceState(null, '', url.toString()); } catch (_) {} return true; } catch (err) { console.warn('[RoomGrid] shared workspace import failed', err); toast(t('shareInvalid')); return false; } } function currentPageRoomIds() { return pagedRooms(fullVisibleRooms()).map(r => r.id); } function currentGroupRoomIds() { return fullVisibleRooms().map(r => r.id); } function recordRoomIds(ids) { const clean = [...new Set((ids || []).map(normalizeUsername).filter(Boolean))]; if (!clean.length) return; if (!confirm(t('recordingConsent'))) return; clean.forEach(id => startCardRecording(id, { skipConfirm: true, waitForSource: true })); renderGrid(); } function recordCurrentPage() { recordRoomIds(currentPageRoomIds()); } function recordCurrentGroup() { recordRoomIds(currentGroupRoomIds()); } function recordOnlineRooms() { recordRoomIds(fullVisibleRooms().filter(r => r.lastStatus === 'online').map(r => r.id)); } function pauseCurrentPage() { const ids = currentPageRoomIds(); service.pauseAll(ids); requestAnimationFrame(() => ids.forEach(updateCardButtons)); toast(t('pausedVisible')); } function openCurrentPageRooms() { currentPageRoomIds().forEach(id => openNoopener(location.origin + '/' + id + '/')); } function moveCurrentPageToGroup() { const name = prompt(t('newGroupPrompt')); if (!name || !name.trim()) return; const safeName = safeGroupName(name, ''); let group = store.state.groups.find(g => safeGroupName(g.name, g.id).toLowerCase() === safeName.toLowerCase()); const groupId = group?.id || store.addGroup(safeName); currentPageRoomIds().forEach(id => store.moveToGroup(id, groupId)); } function queueRecordingIntent(roomId) { roomId = normalizeUsername(roomId); if (!roomId || recordings.has(roomId)) return; recordings.set(roomId, { roomId, segmentIndex: 0, chunks: [], waitingForSource: true, sourceLossToastShown: true, startedAt: Date.now(), }); setRecordingIntent(roomId, true); updateCardButtons(roomId); if (findRoomAny(roomId)?.lastStatus === 'online') resumeWaitingRecording(roomId); else if (service.has(roomId)) service.refresh(roomId); else service.start(roomId); } function checkRecordingIntentRecovery() { const ids = loadRecordingIntents().filter(id => findRoomAny(id)); if (!ids.length) return; if (!confirm(t('recordingRecoverPrompt', ids.length))) { saveRecordingIntents([]); return; } ids.forEach(queueRecordingIntent); renderGrid(); } function renderRecordingCenterBody(body) { body.replaceChildren(); const actions = $('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' } }, [ $('button', { class: 'ctrl-btn primary', onclick: recordCurrentPage }, t('recordCurrentPage')), $('button', { class: 'ctrl-btn', onclick: recordCurrentGroup }, t('recordCurrentGroup')), $('button', { class: 'ctrl-btn', onclick: recordOnlineRooms }, t('recordOnlineRooms')), $('button', { class: 'ctrl-btn', onclick: () => { store.patchSettings({ showRecordingOnly: !store.state.settings.showRecordingOnly, pageIndex: 0 }); } }, store.state.settings.showRecordingOnly ? t('hideRecordingOnly') : t('showRecordingOnly')), $('button', { class: 'ctrl-btn danger', onclick: () => stopAllRecordings({ final: true }) }, t('stopAllRecordings')), ]); body.appendChild(actions); const rows = [...recordings.entries()]; if (!rows.length) { body.appendChild($('div', { class: 'roomgrid-modal-hint' }, t('recordingCenterEmpty'))); } else { const table = $('div', { style: { display: 'grid', gap: '8px' } }); rows.forEach(([id, rec]) => { const status = rec.waitingForSource ? t('recordingWaitingShort') : t('recordingActive'); const duration = rec.startedAt ? fmtDuration(Date.now() - rec.startedAt) : '0:00'; table.appendChild($('div', { style: { border: '1px solid var(--border)', borderRadius: '8px', padding: '10px', display: 'grid', gridTemplateColumns: '1fr auto', gap: '8px', alignItems: 'center' } }, [ $('div', {}, [ $('div', { style: { fontWeight: '750' } }, id), $('div', { style: { fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' } }, `${status} · ${t('recordingDuration')}: ${duration} · ${t('recordingSavedSegments')}: ${Number(rec.savedSegments) || 0} · ${t('recordingBitrate')}: ${(recordingVideoBitrate() / 1000000).toFixed(1)} Mbps`), ]), $('button', { class: 'ctrl-btn danger', onclick: () => stopCardRecording(id) }, t('opRecordStop')), ])); }); body.appendChild(table); } if (recordingLog.length) { body.appendChild($('div', { class: 'roomgrid-modal-title', style: { marginTop: '14px' } }, LANG === 'zh' ? '最近保存' : 'Recent saves')); body.appendChild($('div', { style: { display: 'grid', gap: '4px', fontSize: '12px', color: 'var(--text-muted)' } }, recordingLog.slice(0, 12).map(log => $('div', {}, `${new Date(log.ts).toLocaleTimeString()} · ${log.roomId} · ${log.ext.toUpperCase()} · ${fmtBytes(log.size)}`)))); } } function openRecordingCenter() { openToolPanel(t('recordingCenter'), (body) => { renderRecordingCenterBody(body); const timer = setInterval(() => renderRecordingCenterBody(body), 1000); return () => clearInterval(timer); }); } function openRecordingSettingsPanel() { const curMinutes = clampInt(store.state.settings.recordingSegmentMinutes, 1, 180, 10); const curMbps = Math.round(recordingVideoBitrate() / 100000) / 10; openToolPanel(t('recordingSettingsTitle'), (body, close) => { const minutesInput = $('input', { class: 'ctrl-input', type: 'number', min: '1', max: '180', value: String(curMinutes), style: { width: '100%' } }); const bitrateInput = $('input', { class: 'ctrl-input', type: 'number', min: '0.5', max: '20', step: '0.5', value: String(curMbps), style: { width: '100%' } }); const exitWarn = $('input', { type: 'checkbox', checked: store.state.settings.recordingExitWarn !== false }); body.append( $('div', { class: 'roomgrid-modal-hint' }, t('recordingFormatHint')), $('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' } }, [ $('label', { style: { display: 'grid', gap: '5px', fontSize: '12px', color: 'var(--text-muted)' } }, [t('recordingSegmentPrompt'), minutesInput]), $('label', { style: { display: 'grid', gap: '5px', fontSize: '12px', color: 'var(--text-muted)' } }, [t('recordingBitratePrompt'), bitrateInput]), ]), $('label', { class: 'toggle', style: { marginTop: '10px' } }, [exitWarn, t('recordingExitWarnToggle')]), $('div', { class: 'roomgrid-modal-actions' }, [ $('button', { class: 'ctrl-btn', onclick: close }, t('importReviewCancel')), $('button', { class: 'ctrl-btn primary', onclick: () => { const minutes = clampInt(minutesInput.value, 1, 180, curMinutes); const bitrate = clampInt(Number(bitrateInput.value) * 1000000, 500000, 20000000, recordingVideoBitrate()); store.patchSettings({ recordingSegmentMinutes: minutes, recordingVideoBitrate: bitrate, recordingExitWarn: !!exitWarn.checked }); toast(t('recordingSettingsSaved')); close(); } }, t('saveSettings')), ]), ); }); } function openLayoutSettings() { openToolPanel(t('layoutSettings'), (body, close) => { const layout = $('select', { class: 'ctrl-input' }, [2, 4, 6, 9].map(n => $('option', { value: String(n), selected: Number(store.state.settings.layoutSize) === n }, LANG === 'zh' ? `${n} 窗口` : `${n} rooms`))); const mainW = $('input', { class: 'ctrl-input', type: 'number', min: '45', max: '76', value: String(store.state.settings.focusMainPct || 62) }); const mainH = $('input', { class: 'ctrl-input', type: 'number', min: '44', max: '78', value: String(store.state.settings.focusMainHPct || 64) }); const thumb = $('input', { class: 'ctrl-input', type: 'number', min: '96', max: '260', value: String(store.state.settings.focusThumbSize || 150) }); const notify = $('input', { type: 'checkbox', checked: !!store.state.settings.notifyOnline }); body.append( $('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '10px' } }, [ $('label', { style: { display: 'grid', gap: '5px', fontSize: '12px', color: 'var(--text-muted)' } }, [LANG === 'zh' ? '单页窗口数' : 'Rooms per page', layout]), $('label', { style: { display: 'grid', gap: '5px', fontSize: '12px', color: 'var(--text-muted)' } }, [t('hintThumbSize'), thumb]), $('label', { style: { display: 'grid', gap: '5px', fontSize: '12px', color: 'var(--text-muted)' } }, [LANG === 'zh' ? '主屏宽度' : 'Main width', mainW]), $('label', { style: { display: 'grid', gap: '5px', fontSize: '12px', color: 'var(--text-muted)' } }, [LANG === 'zh' ? '主屏高度' : 'Main height', mainH]), ]), $('label', { class: 'toggle', style: { marginTop: '10px' } }, [notify, t('notifyOnline')]), $('div', { class: 'roomgrid-modal-actions' }, [ $('button', { class: 'ctrl-btn', onclick: close }, t('importReviewCancel')), $('button', { class: 'ctrl-btn primary', onclick: async () => { if (notify.checked && !store.state.settings.notifyOnline) await Notify.request(); store.patchSettings({ layoutSize: Number(layout.value), focusMainPct: clampInt(mainW.value, 45, 76, 62), focusMainHPct: clampInt(mainH.value, 44, 78, 64), focusThumbSize: clampInt(thumb.value, 96, 260, 150), notifyOnline: !!notify.checked, }); close(); } }, t('saveSettings')), ]), ); }); } function openPlaybackSettingsPanel() { openToolPanel(t('playbackSettingsTitle'), (body, close) => { const quality = $('select', { class: 'ctrl-input' }, [ $('option', { value: '0' }, t('maxQualityAuto')), ...[240, 360, 480, 720, 1080, 1440, 2160].map(n => $('option', { value: String(n) }, `${n}p`)), ]); quality.value = String(Number(store.state.settings.maxStreamHeight) || 0); const freeZoom = $('input', { type: 'checkbox', checked: store.state.settings.freeZoom !== false }); body.append( $('div', { style: { display: 'grid', gridTemplateColumns: '1fr', gap: '10px' } }, [ $('label', { style: { display: 'grid', gap: '5px', fontSize: '12px', color: 'var(--text-muted)' } }, [t('maxStreamHeight'), quality]), $('label', { class: 'toggle' }, [freeZoom, t('freeZoomLabel')]), ]), $('div', { class: 'roomgrid-modal-hint', style: { marginTop: '10px' } }, t('freeZoomHint')), $('div', { class: 'roomgrid-modal-actions' }, [ $('button', { class: 'ctrl-btn', onclick: close }, t('importReviewCancel')), $('button', { class: 'ctrl-btn primary', onclick: () => { store.patchSettings({ maxStreamHeight: Number(quality.value) || 0, freeZoom: !!freeZoom.checked }); service.refreshQuality(); close(); } }, t('saveSettings')), ]), ); }); } function openBackupPanel() { openToolPanel(t('backupPanel'), (body) => { const keys = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(CONFIG_BACKUP_PREFIX)) keys.push(key); } keys.sort((a, b) => Number(b.slice(CONFIG_BACKUP_PREFIX.length)) - Number(a.slice(CONFIG_BACKUP_PREFIX.length))); if (!keys.length) { body.appendChild($('div', { class: 'roomgrid-modal-hint' }, t('noBackups'))); return; } keys.forEach(key => { const ts = Number(key.slice(CONFIG_BACKUP_PREFIX.length)); body.appendChild($('div', { style: { display: 'grid', gridTemplateColumns: '1fr auto auto', gap: '8px', alignItems: 'center', borderBottom: '1px solid var(--border)', padding: '8px 0' } }, [ $('div', {}, Number.isFinite(ts) ? new Date(ts).toLocaleString() : key), $('button', { class: 'ctrl-btn', onclick: () => { const raw = localStorage.getItem(key); if (raw) { backupCurrentConfig(); localStorage.setItem(STORE_KEY, raw); location.reload(); } } }, t('restoreBackup')), $('button', { class: 'ctrl-btn danger', onclick: () => { localStorage.removeItem(key); openBackupPanel(); } }, t('deleteBackup')), ])); }); }); } function openStatusHistoryPanel() { openToolPanel(t('statusHistory'), (body) => { const all = loadRoomStatusHistory(); const rows = Object.entries(all).flatMap(([id, list]) => (Array.isArray(list) ? list : []).map(item => ({ id, ...item }))) .sort((a, b) => Number(b.ts || 0) - Number(a.ts || 0)) .slice(0, 120); if (!rows.length) { body.appendChild($('div', { class: 'roomgrid-modal-hint' }, t('noStatusHistory'))); return; } rows.forEach(row => { body.appendChild($('div', { style: { display: 'grid', gridTemplateColumns: '150px 1fr auto', gap: '8px', padding: '6px 0', borderBottom: '1px solid var(--border)', fontSize: '12px' } }, [ $('span', { style: { color: 'var(--text-muted)' } }, new Date(row.ts).toLocaleString()), $('span', {}, row.id), $('span', { style: { fontWeight: '750' } }, row.privateLabel || row.status), ])); }); }); } function openGroupRulesPanel() { openToolPanel(t('groupRules'), (body, close) => { const favorite = $('input', { type: 'checkbox', checked: store.state.settings.favoriteFirst !== false }); body.append( $('label', { class: 'toggle' }, [favorite, t('favoriteFirst')]), $('div', { class: 'roomgrid-modal-actions' }, [ $('button', { class: 'ctrl-btn', onclick: close }, t('importReviewCancel')), $('button', { class: 'ctrl-btn primary', onclick: () => { store.patchSettings({ favoriteFirst: !!favorite.checked }); close(); } }, t('saveSettings')), ]), ); }); } function openTemporaryUrlManager() { openToolPanel(t('tempUrlManager'), (body) => { const saved = readJsonStorage(SAVED_TEMP_URLS_KEY, []); const current = tempRooms.filter(r => r.sourceUrl); body.appendChild($('button', { class: 'ctrl-btn primary', onclick: () => { const next = [...new Map([...saved, ...current.map(r => ({ url: r.sourceUrl, name: r.displayName || r.id }))].filter(x => x?.url).map(x => [x.url, x])).values()]; writeJsonStorage(SAVED_TEMP_URLS_KEY, next.slice(0, 80)); toast(t('copied')); } }, t('saveTempUrls'))); const rows = [...saved, ...current.map(r => ({ url: r.sourceUrl, name: r.displayName || r.id }))]; if (!rows.length) body.appendChild($('div', { class: 'roomgrid-modal-hint', style: { marginTop: '10px' } }, t('noTempUrls'))); rows.forEach(row => body.appendChild($('div', { style: { padding: '6px 0', borderBottom: '1px solid var(--border)', fontSize: '12px', wordBreak: 'break-all' } }, `${row.name || ''} ${row.url || ''}`))); }); } function openShortcutPanel() { openToolPanel(t('shortcutPanel'), (body, close) => { const actions = [ ['focusAdd', t('shortcutFocusAdd')], ['refreshAll', t('shortcutRefreshAll')], ['gridView', t('shortcutGridView')], ['focusView', t('shortcutFocusView')], ['pureMode', t('shortcutPureMode')], ['viewerMode', t('shortcutViewerMode')], ['focusThumbs', t('shortcutFocusThumbs')], ['recordingCenter', t('shortcutRecordingCenter')], ['recordPage', t('shortcutRecordPage')], ]; const current = sanitizeShortcuts(store.state.settings.shortcuts, defaultShortcuts()); const inputs = {}; const gridBox = $('div', { style: { display: 'grid', gridTemplateColumns: 'minmax(160px,1fr) minmax(140px,220px)', gap: '8px 10px', alignItems: 'center' } }); actions.forEach(([action, label]) => { const input = $('input', { class: 'ctrl-input', value: shortcutLabel(current[action]), readonly: true, onkeydown: (e) => { e.preventDefault(); e.stopPropagation(); if (e.key === 'Backspace' || e.key === 'Delete') { input.dataset.spec = ''; input.value = ''; return; } const spec = shortcutFromEvent(e); if (!spec) return; input.dataset.spec = spec; input.value = shortcutLabel(spec); }, }); input.dataset.spec = current[action]; inputs[action] = input; gridBox.append($('div', { style: { fontSize: '12px', color: 'var(--text-muted)' } }, label), input); }); body.append( $('div', { class: 'roomgrid-modal-hint' }, t('shortcutCaptureHint')), gridBox, $('div', { class: 'roomgrid-modal-actions' }, [ $('button', { class: 'ctrl-btn', onclick: () => { const defaults = defaultShortcuts(); for (const [action, input] of Object.entries(inputs)) { input.dataset.spec = defaults[action] || ''; input.value = shortcutLabel(defaults[action] || ''); } } }, t('resetShortcuts')), $('button', { class: 'ctrl-btn', onclick: close }, t('importReviewCancel')), $('button', { class: 'ctrl-btn primary', onclick: () => { const next = {}; for (const [action, input] of Object.entries(inputs)) next[action] = normalizeShortcutSpec(input.dataset.spec || ''); store.patchSettings({ shortcuts: sanitizeShortcuts(next, defaultShortcuts()) }); close(); } }, t('saveSettings')), ]), ); }); } function loadSavedTemporarySources() { const saved = readJsonStorage(SAVED_TEMP_URLS_KEY, []); if (!Array.isArray(saved)) return; saved.slice(0, 80).forEach(item => { if (!isSafeHttpUrl(item?.url) || !isDirectMediaUrl(item.url)) return; const id = tempIdFromUrl(item.url); if (findRoomAny(id)) return; const activeGroup = store.state.settings.activeGroup; const groupId = (!activeGroup || activeGroup === LIBRARY_GROUP_ID || activeGroup === ONLINE_GROUP_ID) ? DEFAULT_GROUP_ID : activeGroup; tempRooms.push({ id, displayName: item.name || new URL(item.url).hostname, sourceUrl: item.url, group: groupId, groups: [groupId], addedAt: Date.now(), order: 100000 + tempRooms.length, lastStatus: 'online', lastSeenOnline: Date.now(), muted: false, temporary: true }); }); } /* ---- 卡片"更多"操作菜单(PiP / 全屏 / 移动到分组 / 主屏聚焦)---- */ function openCardOpsMenu(e, roomId, card) { // 关闭已有的卡片菜单 const existing = document.querySelector('.card-ops-menu-pop'); if (existing) { existing.remove(); return; } const rect = e.currentTarget?.getBoundingClientRect?.() || card?.getBoundingClientRect?.() || { left: e.clientX || 0, right: e.clientX || 0, bottom: e.clientY || 0 }; const usePointer = Number.isFinite(e.clientX) && Number.isFinite(e.clientY) && !e.currentTarget?.classList?.contains('icon-btn'); const menuStyle = usePointer ? { left: Math.min(e.clientX, window.innerWidth - 220) + 'px', top: Math.min(e.clientY, window.innerHeight - 360) + 'px', minWidth: '190px' } : { right: (window.innerWidth - rect.right) + 'px', top: (rect.bottom + 4) + 'px', minWidth: '190px' }; const menu = $('div', { class: 'menu-pop card-ops-menu-pop', style: menuStyle }); const item = (icon, label, onclick) => setElementHint($('button', { title: label, onclick: () => { menu.remove(); onclick(); }, }, label), label); // 主屏切换(focus 模式专属) if (store.state.settings.viewMode === 'focus') { const isFocused = store.state.settings.focusedRoomId === roomId; menu.appendChild(item(isFocused ? '' : '', isFocused ? (LANG === 'zh' ? '已是主屏' : 'Currently main') : (LANG === 'zh' ? '设为主屏' : 'Set as main'), () => { if (!isFocused) store.patchSettings({ focusedRoomId: roomId }); })); } menu.appendChild(item('', service.isPaused(roomId) ? t('opResume') : t('opPause'), () => service.togglePause(roomId))); menu.appendChild(item('', t('opRefresh'), () => service.refresh(roomId))); const currentRoom = store.state.rooms.find(x => x.id === roomId); if (currentRoom) { menu.appendChild(item('', currentRoom.muted ? (LANG === 'zh' ? '取消静音' : 'Unmute') : (LANG === 'zh' ? '静音' : 'Mute'), () => { store.patchRoom(roomId, { muted: !currentRoom.muted }); requestAnimationFrame(() => applyMute(roomId)); })); } const inFav = currentRoom ? roomInGroup(currentRoom, FAVORITE_GROUP_ID) : false; menu.appendChild(item(inFav ? '' : '', inFav ? t('opFavoriteRemove') : t('opFavoriteAdd'), () => store.toggleRoomInGroup(roomId, FAVORITE_GROUP_ID))); if (!inFav) menu.appendChild(item('', t('opMoveToFavorites'), () => store.moveOnlyToGroup(roomId, FAVORITE_GROUP_ID))); menu.appendChild(item('', t('opScreenshot'), () => captureCardScreenshot(roomId))); menu.appendChild(item('', recordings.has(roomId) ? t('opRecordStop') : t('opRecordStart'), () => toggleCardRecording(roomId))); menu.appendChild(item('', t('opMirror'), () => { const cur = getVideoTransform(roomId); patchVideoTransform(roomId, { mirror: !cur.mirror }); })); menu.appendChild(item('', t('opFlip'), () => { const cur = getVideoTransform(roomId); patchVideoTransform(roomId, { flip: !cur.flip }); })); menu.appendChild(item('', t('opRotateLeft'), () => { const cur = getVideoTransform(roomId); patchVideoTransform(roomId, { rotation: (cur.rotation + 270) % 360 }); })); menu.appendChild(item('', t('opRotateRight'), () => { const cur = getVideoTransform(roomId); patchVideoTransform(roomId, { rotation: (cur.rotation + 90) % 360 }); })); menu.appendChild(item('', t('opResetView'), () => resetVideoTransform(roomId))); menu.appendChild(item('', t('opOpenRoom'), () => openNoopener(location.origin + '/' + roomId + '/'))); menu.appendChild(item('', t('opCopyUsername'), async () => { await copyText(roomId); toast(t('copied')); })); menu.appendChild(item('', t('opPiP'), async () => { const v = cardMap.get(roomId)?.video; if (!v) return; try { if (document.pictureInPictureElement) await document.exitPictureInPicture(); else await v.requestPictureInPicture(); } catch (_) {} })); menu.appendChild(item('', t('opFullscreen'), () => { document.fullscreenElement ? document.exitFullscreen() : card.requestFullscreen().catch(() => {}); })); menu.appendChild(item('', t('opMoveGroup'), () => openMoveMenu(e, roomId))); menu.appendChild(item('', t('opDeleteRoom'), () => { stopCardRecording(roomId, true); service.stop(roomId); store.removeRoom(roomId); })); document.body.appendChild(menu); const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close); } }; setTimeout(() => document.addEventListener('click', close), 0); } /* ---- 拖拽落点写回 store ---- */ function reorderByDrop(fromId, toId, position /* 'before' | 'after' */) { fromId = normalizeUsername(fromId); toId = normalizeUsername(toId); if (!fromId || !toId || fromId === toId) return; const ag = store.state.settings.activeGroup || DEFAULT_GROUP_ID; const targetGroup = (ag === LIBRARY_GROUP_ID || ag === ONLINE_GROUP_ID) ? undefined : ag; const manualRooms = (ag === LIBRARY_GROUP_ID ? [...store.state.rooms] : store.state.rooms.filter(r => roomInGroup(r, ag))) .sort((a, b) => roomOrderInGroup(a, ag) - roomOrderInGroup(b, ag) || a.id.localeCompare(b.id)); const manualIds = manualRooms.map(r => r.id); // 以当前屏幕实际可见顺序为准进行插入;再把结果合并回完整手动顺序。 // 这样在分页、搜索、隐藏离线/私密时,隐藏房间不会被错误重排或产生重复 order。 const visibleIds = visibleRooms().map(r => r.id).filter(id => manualIds.includes(id)); const fi = visibleIds.indexOf(fromId); let ti = visibleIds.indexOf(toId); if (fi < 0 || ti < 0) return; visibleIds.splice(fi, 1); if (fi < ti) ti--; if (position === 'after') ti++; visibleIds.splice(Math.max(0, Math.min(visibleIds.length, ti)), 0, fromId); const visibleSet = new Set(visibleIds); let cursor = 0; const mergedIds = manualIds.map(id => visibleSet.has(id) ? visibleIds[cursor++] : id); while (cursor < visibleIds.length) mergedIds.push(visibleIds[cursor++]); const patch = {}; if (store.state.settings.sortBy !== 'manual') patch.sortBy = 'manual'; if (store.state.settings.viewMode === 'focus') { const focusedId = store.state.settings.focusedRoomId; // 主屏与副屏互拖时按“交换屏幕位置”处理:拖副屏到主屏,副屏变主屏;拖主屏到副屏,目标变主屏。 if (focusedId === toId && fromId !== focusedId) patch.focusedRoomId = fromId; else if (focusedId === fromId && toId !== focusedId) patch.focusedRoomId = toId; } store.reorderRooms(mergedIds, targetGroup); if (Object.keys(patch).length) store.patchSettings(patch); } function openMoveMenu(e, roomId) { const groups = [...store.state.groups].sort((a, b) => a.order - b.order); const r = store.state.rooms.find(x => x.id === roomId); const groupDisplayName = (g) => { if (g.name === '__library__') return t('groupLibrary'); if (g.name === '__all__') return t('groupAll'); if (g.name === '__online__') return t('groupOnline'); if (g.name === '__fav__') return t('groupFav'); return g.name; }; const menu = $('div', { class: 'menu-pop', style: { left: e.clientX + 'px', top: e.clientY + 'px' } }); groups.filter(g => g.id !== LIBRARY_GROUP_ID && g.id !== ONLINE_GROUP_ID).forEach(g => { const inGroup = roomInGroup(r, g.id); const label = (inGroup ? ' ' : ' ') + groupDisplayName(g); menu.appendChild($('button', { title: groupDisplayName(g), onclick: () => { store.toggleRoomInGroup(roomId, g.id); menu.remove(); }, style: inGroup ? { color: 'var(--accent)' } : {}, }, label)); }); document.body.appendChild(menu); const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close); } }; setTimeout(() => document.addEventListener('click', close), 0); } /* ---- 更多菜单按钮触发 ---- */ let _moreMenuClose = null; function openMoreMenu(anchor) { const existing = document.querySelector('.more-menu-pop'); if (existing) { existing.remove(); if (_moreMenuClose) { document.removeEventListener('click', _moreMenuClose); _moreMenuClose = null; } return; } const rect = anchor.getBoundingClientRect(); const menu = $('div', { class: 'menu-pop more-menu-pop', style: { right: (window.innerWidth - rect.right) + 'px', top: (rect.bottom + 6) + 'px', minWidth: '240px', maxHeight: 'min(620px, calc(100vh - 96px))', overflowY: 'auto', }, }); const sectionLabel = (zh, en) => LANG === 'zh' ? zh : en; const divider = () => $('div', { style: { height: '1px', background: 'var(--border)', margin: '6px 0' } }); const sectionTitle = (label) => $('div', { style: { padding: '7px 10px 4px', fontSize: '11px', lineHeight: '1', color: 'var(--text-muted)', fontWeight: '750', letterSpacing: '.02em', }, }, label); const item = (label, onClick, opts = {}) => $('button', { class: opts.danger ? 'danger' : '', title: opts.title || label, onclick: () => { menu.remove(); if (_moreMenuClose) { document.removeEventListener('click', _moreMenuClose); _moreMenuClose = null; } try { onClick?.(); } catch (err) { console.warn('[RoomGrid] menu action failed', err); } }, }, label); const addSection = (label, items) => { menu.appendChild(sectionTitle(label)); items.filter(Boolean).forEach(el => menu.appendChild(el)); menu.appendChild(divider()); }; const currentPageIds = () => { const list = typeof fullVisibleRooms === 'function' ? pagedRooms(fullVisibleRooms()) : visibleRooms(); return list.map(r => r.id); }; addSection(sectionLabel('界面', 'Interface'), [ item(t('menuLayoutSettings'), () => openLayoutSettings()), item(t('menuPlaybackSettings'), () => openPlaybackSettingsPanel()), item(store.state.settings.toolbarCollapsed ? sectionLabel('显示顶部工具栏', 'Show top controls') : sectionLabel('收起顶部工具栏', 'Collapse top controls'), () => { { const v = !store.state.settings.toolbarCollapsed; document.body.classList.toggle('rg-toolbar-collapsed', v); store.patchSettings({ toolbarCollapsed: v }); } }), item(store.state.settings.sidebarCollapsed ? sectionLabel('显示左侧分组', 'Show groups') : sectionLabel('收起左侧分组', 'Collapse groups'), () => { { const v = !store.state.settings.sidebarCollapsed; document.body.classList.toggle('rg-sidebar-collapsed', v); sidebar.classList.toggle('is-collapsed', v); store.patchSettings({ sidebarCollapsed: v }); } }), item(t('menuViewerMode'), () => toggleViewerMode(), { title: t('viewerModeHint') }), item(t('menuPureMode'), () => togglePureMode(), { title: t('pureModeHint') }), ]); addSection(sectionLabel('窗口', 'Windows'), [ item(t('menuToggleFit'), () => toggleVideoFit(), { title: t('videoFitHint') }), item(t('menuToggleThumbs'), () => toggleFocusThumbs(), { title: t('focusThumbsHint') }), item(t('menuPauseVisible'), () => { const ids = currentPageIds(); service.pauseAll(ids); requestAnimationFrame(() => ids.forEach(updateCardButtons)); toast(t('pausedVisible')); }, { title: t('menuPauseVisible') }), item(t('menuResumeVisible'), () => { const ids = currentPageIds(); service.resumeAll(ids); requestAnimationFrame(() => ids.forEach(updateCardButtons)); toast(t('resumedVisible')); }, { title: t('menuResumeVisible') }), item(t('menuMuteAll'), () => { store.setAllMuted(true); requestAnimationFrame(() => store.state.rooms.forEach(r => applyMute(r.id))); }), item(t('menuUnmuteAll'), () => { store.setAllMuted(false); requestAnimationFrame(() => store.state.rooms.forEach(r => applyMute(r.id))); }), item(t('batchOpenCurrentPage'), () => openCurrentPageRooms()), item(t('batchMoveCurrentPage'), () => moveCurrentPageToGroup()), ]); addSection(sectionLabel('录制', 'Recording'), [ item(t('menuRecordingCenter'), () => openRecordingCenter()), item(t('menuRecordingSettings'), () => openRecordingSettingsPanel()), item(t('recordCurrentPage'), () => recordCurrentPage()), item(t('recordCurrentGroup'), () => recordCurrentGroup()), item(t('recordOnlineRooms'), () => recordOnlineRooms()), item(store.state.settings.showRecordingOnly ? t('hideRecordingOnly') : t('showRecordingOnly'), () => { store.patchSettings({ showRecordingOnly: !store.state.settings.showRecordingOnly, pageIndex: 0 }); }), item(t('menuStopRecordings'), () => stopAllRecordings({ final: true })), ]); addSection(sectionLabel('数据', 'Data'), [ item(t('manualImport'), () => openManualImportPrompt(), { title: t('manualImport') }), item(t('menuBackupPanel'), () => openBackupPanel()), item(t('menuStatusHistory'), () => openStatusHistoryPanel()), item(t('menuGroupRules'), () => openGroupRulesPanel()), item(t('menuTempUrlManager'), () => openTemporaryUrlManager()), item(t('menuShareWorkspace'), () => openSharePanel()), item(t('menuExport'), () => { const data = JSON.stringify(sanitizeState(store.state), null, 2); const blob = new Blob([data], { type: 'application/json' }); downloadBlob(blob, `roomgrid-config-${Date.now()}.json`); }), item(t('menuImport'), () => { const inp = $('input', { type: 'file', accept: 'application/json', style: { display: 'none' }, onchange: async (e) => { const f = e.target.files[0]; if (!f) { try { inp.remove(); } catch (_) {} return; } try { if (f.size > MAX_CONFIG_BYTES) throw new Error('file too large'); const text = await f.text(); const obj = sanitizeState(JSON.parse(text)); if (!obj.rooms.length && !confirm(LANG === 'zh' ? '导入文件里没有有效房间,仍要继续?' : 'No valid rooms found in this file. Continue?')) return; const summary = LANG === 'zh' ? `导入配置将替换当前配置。\n\n当前:${store.state.rooms.length} 个房间 / ${store.state.groups.length} 个分组\n导入:${obj.rooms.length} 个房间 / ${obj.groups.length} 个分组\n\n已尽量保存最近 ${MAX_CONFIG_BACKUPS} 个备份。继续?` : `Importing will replace the current config.\n\nCurrent: ${store.state.rooms.length} rooms / ${store.state.groups.length} groups\nImport: ${obj.rooms.length} rooms / ${obj.groups.length} groups\n\nThe last ${MAX_CONFIG_BACKUPS} backups are kept when possible. Continue?`; if (!confirm(summary)) return; obj.settings.toolbarCollapsed = false; obj.settings.sidebarCollapsed = false; backupCurrentConfig(); writeStoreRaw(JSON.stringify(obj)); location.reload(); } catch (err) { alert('Import failed: ' + err.message); } finally { setTimeout(() => { try { inp.remove(); } catch (_) {} }, 0); } }, }); document.body.appendChild(inp); inp.click(); setTimeout(() => { try { if (!inp.files || !inp.files.length) inp.remove(); } catch (_) {} }, 60000); }), item(t('menuCopyUsernames'), async () => { await copyText(store.state.rooms.map(r => r.id).sort().join('\n')); toast(t('copied')); }), item(t('menuExportUsernames'), () => { const names = store.state.rooms.map(r => r.id).sort().join('\n'); downloadBlob(new Blob([names + (names ? '\n' : '')], { type: 'text/plain;charset=utf-8' }), `roomgrid-usernames-${Date.now()}.txt`); }), item(t('menuRepairData'), () => { store.repairData(); alert(t('repairDone')); }), ]); addSection(sectionLabel('语言', 'Language'), [ $('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px', padding: '0 8px 4px' } }, [ $('button', { style: { padding: '7px 8px', borderRadius: '8px', border: '1px solid var(--border)', background: LANG === 'zh' ? 'var(--accent)' : 'var(--bg-input)', color: LANG === 'zh' ? '#fff' : 'var(--text)', cursor: 'pointer', fontSize: '12px', }, onclick: () => { menu.remove(); setLang('zh'); }, }, t('langZh')), $('button', { style: { padding: '7px 8px', borderRadius: '8px', border: '1px solid var(--border)', background: LANG === 'en' ? 'var(--accent)' : 'var(--bg-input)', color: LANG === 'en' ? '#fff' : 'var(--text)', cursor: 'pointer', fontSize: '12px', }, onclick: () => { menu.remove(); setLang('en'); }, }, t('langEn')), ]), ]); addSection(sectionLabel('帮助', 'Help'), [ item(t('menuShortcutPanel'), () => openShortcutPanel(), { title: t('menuShortcutPanel') }), item(t('menuShortcutHelp'), () => alert(t('shortcutsHelp')), { title: t('menuShortcutHelp') }), item(t('menuAbout'), () => showAboutPanel(), { title: t('menuAbout') }), ]); menu.appendChild(item(t('menuClearAll'), () => { if (confirm(t('clearAllConfirm'))) { try { service.stopAll(); } catch (_) { stopAllPageMedia(); } Storage.clearAll(); location.reload(); } }, { danger: true })); document.body.appendChild(menu); const close = (ev) => { if (!menu.contains(ev.target) && ev.target !== anchor) { menu.remove(); document.removeEventListener('click', close); _moreMenuClose = null; } }; _moreMenuClose = close; setTimeout(() => document.addEventListener('click', close), 0); } /* ---- 关于面板(含 ETH 捐赠地址)---- */ function showAboutPanel() { const overlay = $('div', { style: { position: 'fixed', inset: '0', background: 'rgba(17,24,39,.32)', backdropFilter: 'blur(2px)', zIndex: '10000', display: 'flex', alignItems: 'center', justifyContent: 'center', }, onclick: (e) => { if (e.target === overlay) overlay.remove(); }, }); const card = $('div', { style: { background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: '14px', padding: '26px 28px', minWidth: '340px', maxWidth: '440px', boxShadow: 'var(--shadow-lg)', color: 'var(--text)', }, }); const row = (label, value) => $('div', { style: { display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid var(--border)', fontSize: '13px' }, }, [ $('span', { style: { color: 'var(--text-muted)' } }, label), $('span', { style: { color: 'var(--text)', fontFamily: 'ui-monospace,monospace' } }, value), ]); const copyBtn = $('button', { style: { padding: '4px 10px', fontSize: '11px', background: 'var(--bg-input)', color: 'var(--text)', border: '1px solid var(--border)', border: 'none', borderRadius: '5px', cursor: 'pointer', marginLeft: '8px', }, onclick: async () => { try { await navigator.clipboard.writeText(META.eth); copyBtn.textContent = t('aboutCopied'); setTimeout(() => { copyBtn.textContent = t('aboutCopyAddr'); }, 1500); } catch (_) { // fallback const ta = $('textarea', { value: META.eth, style: { position: 'fixed', opacity: '0' } }); document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); copyBtn.textContent = t('aboutCopied'); } catch(_) {} ta.remove(); setTimeout(() => { copyBtn.textContent = t('aboutCopyAddr'); }, 1500); } }, }, t('aboutCopyAddr')); card.append( $('div', { style: { fontSize: '20px', fontWeight: '700', marginBottom: '4px' } }, t('aboutTitle')), $('div', { style: { fontSize: '13px', color: 'var(--text-muted)', marginBottom: '14px', lineHeight: '1.45' } }, `${t('title')} · ${t('appTagline')}`), row(t('aboutAuthor'), META.author), row(t('aboutVersion'), 'v' + META.version), row(t('aboutLicense'), META.license), // 捐赠区(次级展示) $('div', { style: { marginTop: '20px', padding: '14px', background: 'var(--bg)', border: '1px dashed var(--border-strong)', borderRadius: '10px', }, }, [ $('div', { style: { fontSize: '12px', color: 'var(--text-muted)', marginBottom: '8px' } }, t('aboutDonate')), $('div', { style: { fontSize: '11px', color: 'var(--text-muted)', marginBottom: '4px' } }, t('aboutDonateAddrLabel') + ' (Ethereum / EVM):'), $('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--bg-input)', padding: '8px 10px', borderRadius: '6px', fontSize: '11px', fontFamily: 'ui-monospace,monospace', wordBreak: 'break-all', }, }, [ $('span', { style: { flex: '1', color: 'var(--text-secondary)' } }, META.eth), copyBtn, ]), ]), $('div', { style: { display: 'flex', justifyContent: 'flex-end', marginTop: '20px' } }, [ $('button', { style: { padding: '8px 18px', background: 'var(--accent)', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '13px' }, onclick: () => overlay.remove(), }, t('aboutClose')), ]), ); overlay.appendChild(card); document.body.appendChild(overlay); } function visibleRooms() { const s = store.state; const ag = s.settings.activeGroup || DEFAULT_GROUP_ID; let list = ag === LIBRARY_GROUP_ID ? [...allRoomsForView()] : allRoomsForView().filter(r => roomInGroup(r, ag)); const q = normalizeUsername(s.settings.searchQuery || ''); if (q) list = list.filter(r => normalizeUsername(r.id).includes(q)); const f = s.settings.filter; if (f.hideOffline) list = list.filter(r => r.lastStatus !== 'offline'); if (f.hidePrivate) list = list.filter(r => r.lastStatus !== 'private'); if (f.onlyOnline) list = list.filter(r => r.lastStatus === 'online'); if (s.settings.showRecordingOnly) list = list.filter(r => recordings.has(r.id)); const sb = s.settings.sortBy; if (sb === 'manual') list.sort((a, b) => roomOrderInGroup(a, ag) - roomOrderInGroup(b, ag)); else if (sb === 'name') list.sort((a, b) => a.id.localeCompare(b.id)); else if (sb === 'addedAt') list.sort((a, b) => b.addedAt - a.addedAt); else if (sb === 'status') { const rank = { online: 0, private: 1, loading: 2, error: 3, offline: 4, unknown: 5 }; list.sort((a, b) => (rank[a.lastStatus] ?? 9) - (rank[b.lastStatus] ?? 9)); } if (s.settings.favoriteFirst !== false && ag !== FAVORITE_GROUP_ID) { list = list .map((room, idx) => ({ room, idx, fav: roomInGroup(room, FAVORITE_GROUP_ID) ? 1 : 0 })) .sort((a, b) => (b.fav - a.fav) || (a.idx - b.idx)) .map(item => item.room); } return list; } function renderCardState(room) { const c = cardMap.get(room.id); if (!c) return; const muted = room.muted; const meta = statusMeta(room.lastStatus); c.badge.replaceChildren(); c.badge.append( $('span', { class: 'dot', style: { background: meta.color } }), $('span', { class: 'pill-text' }, meta.label), muted ? $('span', { style: { marginLeft: '4px', fontSize: '10px', color: 'var(--text-muted)' } }, LANG === 'zh' ? '静音' : 'Muted') : null, ); updateCardButtons(room.id); // 状态覆盖层文本 + video DOM 清理 if (room.lastStatus === 'online') { c.root.classList.remove('not-online'); c.statusEl.style.display = 'none'; applyMute(room.id); resumeWaitingRecording(room.id); } else { c.root.classList.add('not-online'); c.statusEl.style.display = 'flex'; pauseRecordingForSourceLoss(room.id); // 关键修复:状态非 online 时彻底清理 video 节点。 // 否则会在卡片中央显示大黑块,且可能残留音频。 if (c.video) { service.detachVideo(room.id); c.video = null; } else { service.detachVideo(room.id); } const lines = [ $('div', { class: 'status-icon', style: { color: meta.color } }, [$('span', { class: 'status-dot-large' })]), $('div', { class: 'status-chip', style: { color: meta.color } }, meta.label), room.lastSeenOnline ? $('div', { style: { fontSize: '11px', color: 'var(--text-muted)' } }, t('lastSeen', fmtTime(room.lastSeenOnline))) : null, (room.lastStatus === 'offline' || room.lastStatus === 'private') ? $('div', { style: { fontSize: '10px', color: 'var(--text-muted)', opacity: '.7' } }, t('autoDetect')) : null, ]; c.statusEl.replaceChildren(); lines.forEach(l => l && c.statusEl.appendChild(l)); } } function applyMute(id) { const c = cardMap.get(id); const r = findRoomAny(id); if (!c || !c.video || !r) return; const v = store.state.settings.volume; c.video.volume = r.muted ? 0 : v; c.video.muted = r.muted || v === 0; } function getVideoTransform(roomId) { return sanitizeVideoTransform(store.state.settings.videoTransforms?.[normalizeUsername(roomId)]); } function buildVideoTransformCss(transform) { const t = sanitizeVideoTransform(transform); const parts = []; if (t.x || t.y) parts.push(`translate(${t.x}px, ${t.y}px)`); if (t.zoom !== 1) parts.push(`scale(${t.zoom})`); if (t.mirror || t.flip) parts.push(`scale(${t.mirror ? -1 : 1}, ${t.flip ? -1 : 1})`); if (t.rotation) parts.push(`rotate(${t.rotation}deg)`); return parts.join(' '); } function patchVideoTransform(roomId, patch) { roomId = normalizeUsername(roomId); if (!roomId) return; const next = sanitizeVideoTransform({ ...getVideoTransform(roomId), ...(patch || {}) }); store.update(s => { s.settings.videoTransforms = sanitizeVideoTransformMap(s.settings.videoTransforms); if (isDefaultVideoTransform(next)) delete s.settings.videoTransforms[roomId]; else s.settings.videoTransforms[roomId] = next; }, 'settings:videoTransforms'); applyVideoTransform(roomId); } function resetVideoTransform(roomId) { patchVideoTransform(roomId, defaultVideoTransform()); } function applyVideoTransform(roomId) { const c = cardMap.get(normalizeUsername(roomId)); if (!c?.video) return; const transform = getVideoTransform(roomId); const css = buildVideoTransformCss(transform); c.video.style.transform = css; c.video.style.transformOrigin = 'center center'; c.video.style.cursor = transform.zoom > 1 ? 'grab' : ''; c.root.classList.toggle('video-transformed', !isDefaultVideoTransform(transform)); c.root.classList.toggle('video-zoomed', transform.zoom > 1); } function applyAllVideoTransforms() { cardMap.forEach((_, id) => applyVideoTransform(id)); } function installCardZoomHandlers(card, roomId) { let dragging = false; let lastX = 0; let lastY = 0; const isUiTarget = (target) => !!target?.closest?.('.icon-btn,.drag-handle,.menu-pop,.roomgrid-modal-backdrop,button,input,select,textarea,a'); card.addEventListener('wheel', (e) => { if (store.state.settings.freeZoom === false || (!e.ctrlKey && !e.metaKey) || isUiTarget(e.target)) return; e.preventDefault(); const cur = getVideoTransform(roomId); const nextZoom = Math.max(1, Math.min(20, cur.zoom * (e.deltaY < 0 ? 1.12 : 0.88))); const rounded = Math.round(nextZoom * 100) / 100; patchVideoTransform(roomId, { zoom: rounded, x: rounded === 1 ? 0 : cur.x, y: rounded === 1 ? 0 : cur.y }); }, { passive: false }); card.addEventListener('mousedown', (e) => { if (store.state.settings.freeZoom === false || e.button !== 0 || isUiTarget(e.target)) return; const cur = getVideoTransform(roomId); if (cur.zoom <= 1) return; dragging = true; lastX = e.clientX; lastY = e.clientY; card.classList.add('video-panning'); const c = cardMap.get(roomId); if (c?.video) c.video.style.cursor = 'grabbing'; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!dragging) return; const cur = getVideoTransform(roomId); patchVideoTransform(roomId, { x: cur.x + e.clientX - lastX, y: cur.y + e.clientY - lastY, }); lastX = e.clientX; lastY = e.clientY; }); window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; card.classList.remove('video-panning'); applyVideoTransform(roomId); }); } function attachVideoElement(roomId) { const c = cardMap.get(roomId); if (!c) return null; // 移除旧 video:必须同步销毁 HLS,否则旧 buffer 可能继续出声。 if (c.video) { pauseRecordingForSourceLoss(roomId); service.detachVideo(roomId); c.video = null; } const video = $('video', { controls: false, autoplay: true, playsInline: true, draggable: false, disablePictureInPicture: false, controlsList: 'nodownload noplaybackrate nofullscreen', dataset: { multicamRoom: roomId, multicamRoomId: roomId }, class: 'cam-video', style: { pointerEvents: 'none', filter: 'none', opacity: '1' }, }); try { video.controls = false; video.removeAttribute('controls'); } catch (_) {} const r = findRoomAny(roomId); const v = store.state.settings.volume; video.volume = r?.muted ? 0 : v; video.muted = r?.muted || v === 0; // 插入到状态层之前 c.root.insertBefore(video, c.statusEl); c.video = video; video.addEventListener('play', () => updateCardButtons(roomId)); video.addEventListener('pause', () => updateCardButtons(roomId)); video.addEventListener('loadeddata', () => resumeWaitingRecording(roomId)); video.addEventListener('playing', () => resumeWaitingRecording(roomId)); service.attachVideo(roomId, video); applyVideoTransform(roomId); updateCardButtons(roomId); setTimeout(() => resumeWaitingRecording(roomId), 1200); return video; } function attachTemporarySource(room) { if (!room || !room.sourceUrl) return; const c = cardMap.get(room.id); if (!c) return; const video = attachVideoElement(room.id); if (!video) return; video.controls = true; video.style.pointerEvents = 'auto'; try { c.tempHls?.destroy?.(); } catch (_) {} c.tempHls = null; if (isHlsUrl(room.sourceUrl) && window.Hls && Hls.isSupported()) { const hls = new Hls({ lowLatencyMode: true, enableWorker: true }); c.tempHls = hls; hls.loadSource(room.sourceUrl); hls.attachMedia(video); } else { video.src = room.sourceUrl; } video.play?.().catch?.(() => {}); renderCardState(room); } // ---- Grid 渲染(增量,支持 grid / focus 双模式)---- function renderGrid() { const mode = store.state.settings.viewMode; // 切换 grid class grid.classList.toggle('view-grid', mode === 'grid'); grid.classList.toggle('view-focus', mode === 'focus'); applyGridSize(); const fullList = fullVisibleRooms(); clampCurrentPage(fullList.length); syncLayoutControls(); const list = pagedRooms(fullList); const wantIds = new Set(list.map(r => r.id)); // 移除不可见/已删除的卡片:先停流再移 DOM,避免删除后残留声音。 // 如果只是被过滤/分组隐藏,不删除 session,只 detach 媒体,保留后续状态轮询。 const allRoomIds = new Set(allRoomsForView().map(r => r.id)); for (const [id, c] of cardMap) { if (!wantIds.has(id)) { pauseRecordingForSourceLoss(id, { silent: true }); const rr = findRoomAny(id); if (rr && rr.sourceUrl) { try { c.tempHls?.destroy?.(); } catch (_) {} } else if (allRoomIds.has(id)) service.detachVideo(id); else service.stop(id); try { c.video?.pause(); } catch (_) {} try { c.video?.remove(); } catch (_) {} c.video = null; try { c.resizeObserver?.disconnect(); } catch (_) {} c.resizeObserver = null; try { c.root.remove(); } catch (_) {} cardMap.delete(id); } } // 空态 if (fullList.length === 0) { if (!grid.querySelector('.empty-state')) { grid.replaceChildren(); grid.style.display = 'flex'; grid.append($('div', { class: 'empty-state' }, [ $('div', { style: { fontSize: '60px', opacity: '.3' } }, ''), $('div', { style: { fontSize: '15px' } }, t('emptyTitle')), $('div', { style: { fontSize: '12px', color: 'var(--text-muted)' } }, t('emptyHint')), ])); } return; } // 清掉空态(如果有) const empty = grid.querySelector('.empty-state'); if (empty) empty.remove(); if (mode === 'focus') { renderFocusLayout(list); } else { renderGridLayout(list); } } /* —— grid 布局:均分 —— */ function renderGridLayout(list) { syncLayoutControls(); // 清掉 focus 模式专属容器 grid.querySelectorAll('.focused-row, .thumbs-row, .focus-side-row, .focus-bottom-row, .resizer').forEach(el => { // 先把里面的 cam-card detach 出来,避免一并被 remove el.querySelectorAll('.cam-card').forEach(c => grid.appendChild(c)); el.remove(); }); list.forEach((room, idx) => { let c = cardMap.get(room.id); if (!c) { buildCard(room); c = cardMap.get(room.id); grid.appendChild(c.root); if (room.sourceUrl) attachTemporarySource(room); else if (room.lastStatus === 'online') service.refresh(room.id); else if (!service.has(room.id)) service.start(room.id); } else { resetCardSizing(c.root); const cards = [...grid.children].filter(el => el.classList && el.classList.contains('cam-card')); const ref = cards[idx]; if (c.root.parentElement !== grid || ref !== c.root) grid.insertBefore(c.root, ref || null); if (room.sourceUrl && !c.video) attachTemporarySource(room); else if (room.lastStatus === 'online' && !c.video) service.refresh(room.id); } resetCardSizing(c.root); applyCardGridSizing(c.root, room); renderCardState(room); }); } function focusRoom(roomId) { if (!roomId) return; const all = fullVisibleRooms(); const idx = all.findIndex(r => r.id === roomId); const page = idx >= 0 ? Math.floor(idx / layoutSize()) : (store.state.settings.pageIndex || 0); store.patchSettings({ viewMode: 'focus', focusedRoomId: roomId, pageIndex: page, focusThumbsCollapsed: false }); } function focusStep(delta) { const list = visibleRooms(); if (!list.length) return; const cur = store.state.settings.focusedRoomId; let idx = list.findIndex(r => r.id === cur); if (idx < 0) idx = 0; const next = list[(idx + delta + list.length) % list.length]; if (next) focusRoom(next.id); } /* —— focus 模式分隔条拖动:调整主屏宽高,副窗口自适应 —— */ function attachFocusResizerHandlers(resizer, axis) { let dragging = false, startX = 0, startY = 0, startW = 62, startH = 64, currentW = 62, currentH = 64; const apply = (w, h) => { currentW = Math.max(45, Math.min(76, w)); currentH = Math.max(44, Math.min(78, h)); grid.style.gridTemplateColumns = `minmax(260px, ${currentW}fr) 8px minmax(190px, ${100 - currentW}fr)`; grid.style.gridTemplateRows = `minmax(220px, ${currentH}fr) 8px minmax(120px, ${100 - currentH}fr)`; grid.style.setProperty('--focus-main-w', currentW + 'fr'); grid.style.setProperty('--focus-side-w', (100 - currentW) + 'fr'); grid.style.setProperty('--focus-main-h', currentH + 'fr'); grid.style.setProperty('--focus-bottom-h', (100 - currentH) + 'fr'); requestAnimationFrame(applyFocusMainSizing); }; resizer.addEventListener('mousedown', (e) => { dragging = true; startX = e.clientX; startY = e.clientY; startW = Math.max(45, Math.min(76, Number(store.state.settings.focusMainPct || 62))); startH = Math.max(44, Math.min(78, Number(store.state.settings.focusMainHPct || 64))); currentW = startW; currentH = startH; resizer.classList.add('dragging'); document.body.style.cursor = axis === 'x' ? 'ew-resize' : 'ns-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); const move = (e) => { if (!dragging) return; if (axis === 'x') { const gridW = Math.max(1, grid.clientWidth || window.innerWidth || 1); apply(startW + ((e.clientX - startX) / gridW) * 100, currentH); } else { const gridH = Math.max(1, grid.clientHeight || window.innerHeight || 1); apply(currentW, startH + ((e.clientY - startY) / gridH) * 100); } }; const up = () => { if (!dragging) return; dragging = false; resizer.classList.remove('dragging'); document.body.style.cursor = ''; document.body.style.userSelect = ''; store.patchSettings({ focusMainPct: Math.round(currentW), focusMainHPct: Math.round(currentH) }); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); } /* —— focus 布局:左上主屏;右侧 / 右下 / 下方为副窗口 —— */ function renderFocusLayout(list) { const full = fullVisibleRooms(); const pageList = pagedRooms(full); if (list !== pageList) list = pageList; let focusedId = store.state.settings.focusedRoomId; if (!list.some(r => r.id === focusedId)) { const preferred = list.find(r => r.lastStatus === 'online') || list[0]; focusedId = preferred?.id || null; if (focusedId !== store.state.settings.focusedRoomId) store.state.settings.focusedRoomId = focusedId; } let focusedRow = grid.querySelector('.focused-row'); let sideRow = grid.querySelector('.focus-side-row'); let bottomRow = grid.querySelector('.focus-bottom-row'); let vResizer = grid.querySelector('.focus-v-resizer'); let hResizer = grid.querySelector('.focus-h-resizer'); if (!focusedRow) focusedRow = $('div', { class: 'focused-row' }); if (!sideRow) sideRow = $('div', { class: 'focus-side-row' }); if (!bottomRow) bottomRow = $('div', { class: 'focus-bottom-row' }); if (!vResizer) { vResizer = $('div', { class: 'resizer focus-v-resizer', title: LANG === 'zh' ? '拖动调整主屏宽度' : 'Drag to resize main width' }); attachFocusResizerHandlers(vResizer, 'x'); } if (!hResizer) { hResizer = $('div', { class: 'resizer focus-h-resizer', title: LANG === 'zh' ? '拖动调整主屏高度' : 'Drag to resize main height' }); attachFocusResizerHandlers(hResizer, 'y'); } focusedRow.style.gridArea = 'main'; vResizer.style.gridArea = 'vbar'; hResizer.style.gridArea = 'hbar'; sideRow.style.gridArea = 'side'; bottomRow.style.gridArea = 'bottom'; [focusedRow, vResizer, hResizer, sideRow, bottomRow].forEach(el => { if (el.parentElement !== grid) grid.appendChild(el); }); // Remove legacy containers if any grid.querySelectorAll('.thumbs-row').forEach(el => { el.querySelectorAll('.cam-card').forEach(c => sideRow.appendChild(c)); el.remove(); }); // Direct children cards are collected before assigning. [...grid.children].forEach(el => { if (el.classList && el.classList.contains('cam-card')) sideRow.appendChild(el); }); const focusedRoom = list.find(r => r.id === focusedId); const secondary = list.filter(r => r.id !== focusedId); const sideCount = secondary.length <= 1 ? secondary.length : Math.min(4, Math.ceil(secondary.length / 2)); const sideList = secondary.slice(0, sideCount); const bottomList = secondary.slice(sideCount); grid.classList.toggle('no-bottom', bottomList.length === 0); sideRow.style.gridTemplateRows = sideList.length ? `repeat(${sideList.length}, minmax(0, 1fr))` : '1fr'; sideRow.style.gridTemplateColumns = 'minmax(0, 1fr)'; bottomRow.style.gridTemplateColumns = bottomList.length ? `repeat(${bottomList.length}, minmax(0, 1fr))` : '1fr'; bottomRow.style.gridTemplateRows = 'minmax(0, 1fr)'; if (!bottomList.length) { grid.style.gridTemplateRows = 'minmax(0, 1fr)'; grid.style.gridTemplateAreas = `'main vbar side'`; } else { applyGridSize(); } const ensureCard = (room, parent, idx) => { let c = cardMap.get(room.id); if (!c) { buildCard(room); c = cardMap.get(room.id); if (room.lastStatus === 'online') service.refresh(room.id); else if (!service.has(room.id)) service.start(room.id); } resetCardSizing(c.root); const cards = [...parent.children].filter(el => el.classList && el.classList.contains('cam-card')); const ref = cards[idx]; if (c.root.parentElement !== parent || ref !== c.root) parent.insertBefore(c.root, ref || null); if (room.lastStatus === 'online' && !c.video) service.refresh(room.id); renderCardState(room); c.root.classList.toggle('is-focus-main', room.id === focusedId); return c; }; if (focusedRoom) { const c = ensureCard(focusedRoom, focusedRow, 0); c.root.classList.add('is-focus-main'); applyFocusMainSizing(); } else { [...focusedRow.children].forEach(el => { if (el.classList && el.classList.contains('cam-card')) sideRow.appendChild(el); }); } sideList.forEach((room, idx) => ensureCard(room, sideRow, idx)); bottomList.forEach((room, idx) => ensureCard(room, bottomRow, idx)); const keep = new Set(list.map(r => r.id)); [focusedRow, sideRow, bottomRow].forEach(parent => { [...parent.children].forEach(el => { const id = el.dataset?.roomId; if (id && !keep.has(id)) sideRow.appendChild(el); }); }); requestAnimationFrame(applyFocusMainSizing); syncLayoutControls(); } // ---- 渲染调度:合并同一帧内的多次状态变化,减少批量添加、切页和状态刷新时的抖动 ---- let sidebarRenderRaf = 0; let gridRenderRaf = 0; let focusSizingRaf = 0; function scheduleSidebarRender() { if (sidebarRenderRaf) return; sidebarRenderRaf = requestAnimationFrame(() => { sidebarRenderRaf = 0; renderSidebar(); }); } function scheduleGridRender() { if (gridRenderRaf) return; gridRenderRaf = requestAnimationFrame(() => { gridRenderRaf = 0; renderGrid(); }); } function scheduleFocusSizing() { if (focusSizingRaf) return; focusSizingRaf = requestAnimationFrame(() => { focusSizingRaf = 0; applyFocusMainSizing(); }); } // ---- Store 订阅 ---- store.subscribe((state, path) => { const isSettingsPath = path === 'settings' || (typeof path === 'string' && path.startsWith('settings:')); const settingKeys = isSettingsPath && typeof path === 'string' && path.includes(':') ? path.slice(path.indexOf(':') + 1).split(',').filter(Boolean) : []; const hasSetting = (...keys) => !settingKeys.length || keys.some(k => settingKeys.includes(k) || settingKeys.some(x => x.startsWith(k + '.'))); if (path === 'groups' || path === 'rooms' || path === 'all' || isSettingsPath) { const fullRefresh = path === 'groups' || path === 'rooms' || path === 'all' || path === 'settings' || !settingKeys.length; const needsSidebar = fullRefresh || hasSetting('activeGroup', 'sidebarCollapsed'); const needsGrid = fullRefresh || hasSetting( 'activeGroup', 'filter', 'sortBy', 'searchQuery', 'layoutSize', 'pageIndex', 'viewMode', 'focusedRoomId', 'focusMainPct', 'focusMainHPct', 'focusAspect', 'focusThumbSize', 'showRecordingOnly', 'favoriteFirst' ); if (needsSidebar) scheduleSidebarRender(); if (needsGrid) scheduleGridRender(); sortSel.value = state.settings.sortBy; filterSel.value = filterModeFromState(); sizeSlider.value = String(state.settings.gridSize); if (document.activeElement !== searchInput) searchInput.value = state.settings.searchQuery || ''; focusScaleSlider.value = String(state.settings.focusMainPct || 62); focusAspectSel.value = state.settings.focusAspect || 'auto'; focusThumbSlider.value = String(state.settings.focusThumbSize || 150); syncViewSeg(); syncLayoutControls(); syncShellControls(); applyPureModeState(); if (hasSetting('maxStreamHeight')) service.refreshQuality(); if (hasSetting('videoTransforms')) applyAllVideoTransforms(); if (needsGrid || hasSetting('toolbarCollapsed', 'sidebarCollapsed', 'viewerMode', 'pureMode')) scheduleFocusSizing(); } if (path && path.startsWith('room:')) { const id = path.slice(5); const r = state.rooms.find(x => x.id === id); if (r) renderCardState(r); scheduleSidebarRender(); // 状态排序时,单卡状态变化也要重排 if (state.settings.activeGroup === ONLINE_GROUP_ID || state.settings.sortBy === 'status' || state.settings.filter?.hideOffline || state.settings.filter?.hidePrivate || state.settings.filter?.onlyOnline) scheduleGridRender(); } }); // ---- EventBus 订阅 ---- EventBus.on('room:online', ({ id, hlsSource }) => { if (!findRoomAny(id)) { service.stop(id); return; } const video = attachVideoElement(id); if (!video) return; service.startHls(id, hlsSource); const r = findRoomAny(id); if (r) renderCardState(r); requestAnimationFrame(applyFocusMainSizing); }); EventBus.on('room:flash', (id) => { const c = cardMap.get(id); if (!c) return; c.root.classList.remove('flash'); void c.root.offsetWidth; // 强制重排重启动画 c.root.classList.add('flash'); setTimeout(() => c.root.classList.remove('flash'), 5000); }); // ---- 跨标签页实时同步 ---- // 当用户在主播页点击「加入」时,已打开的工作台立刻感知并新增卡片(无需刷新) window.addEventListener('storage', (e) => { if (e.key !== STORE_KEY) return; try { if (!e.newValue) { syncFromExternalState(defaultState()); return; } const ext = sanitizeState(JSON.parse(e.newValue)); if (!ext || !Array.isArray(ext.rooms)) return; syncFromExternalState(ext); } catch (_) {} }); function syncFromExternalState(ext) { const normalized = sanitizeState(ext && typeof ext === 'object' ? ext : defaultState()); const currentById = new Map(store.state.rooms.map(r => [r.id, r])); const extRooms = (Array.isArray(normalized.rooms) ? normalized.rooms : []).map(r => { const local = currentById.get(r.id); if (local && isStableRoomStatus(local.lastStatus) && isTransientRoomStatus(r.lastStatus)) { return { ...r, lastStatus: local.lastStatus, lastSeenOnline: Math.max(numeric(local.lastSeenOnline, 0), numeric(r.lastSeenOnline, 0)), privateLabel: local.lastStatus === 'private' ? (local.privateLabel || r.privateLabel) : r.privateLabel, }; } return r; }); const curIds = new Set(store.state.rooms.map(r => r.id)); const nextIds = new Set(extRooms.map(r => r.id)); const removedRoomIds = [...curIds].filter(id => !nextIds.has(id)); const newRoomIds = [...nextIds].filter(id => !curIds.has(id)); // 多窗口只同步房间 / 分组,不同步 viewMode / focusedRoomId / filter 等本窗口视图设置。 // 否则一个窗口切主屏会把另一个窗口的主屏也顶掉。 const localSettings = JSON.parse(JSON.stringify(store.state.settings || defaultState().settings)); const nextState = { ...normalized, rooms: extRooms, groups: Array.isArray(normalized.groups) && normalized.groups.length ? normalized.groups : store.state.groups, settings: localSettings, }; const groupIds = new Set(nextState.groups.map(g => g.id)); if (!groupIds.has(nextState.settings.activeGroup)) nextState.settings.activeGroup = DEFAULT_GROUP_ID; if (nextState.settings.focusedRoomId && !nextIds.has(nextState.settings.focusedRoomId)) { const preferred = extRooms.find(r => r.lastStatus === 'online') || extRooms[0]; nextState.settings.focusedRoomId = preferred?.id || null; } // 外部标签页同步只替换内存状态,不回写 localStorage,避免多个工作台互相触发 storage ping-pong。 store.replaceState(nextState, 'all'); // service 启停(replaceState 之后,避免 service 提前 fire 状态时找不到对应数据) for (const id of removedRoomIds) service.stop(id); for (const id of newRoomIds) service.start(id); for (const id of nextIds) { if (!service.has(id)) service.start(id); } } // ---- 全局拖动结束 ---- grid.addEventListener('dragover', (e) => e.preventDefault()); // ---- 快捷键 ---- function shortcutMatches(e, action) { const shortcuts = sanitizeShortcuts(store.state.settings.shortcuts, defaultShortcuts()); return shortcutFromEvent(e) === shortcuts[action]; } document.addEventListener('keydown', (e) => { const key = String(e.key || '').toLowerCase(); if (e.key === 'Escape') closeTransientUi(); if (shortcutMatches(e, 'pureMode') || (e.altKey && key === 'c')) { e.preventDefault(); togglePureMode(); return; } if (shortcutMatches(e, 'viewerMode')) { e.preventDefault(); toggleViewerMode(); return; } if (shortcutMatches(e, 'focusThumbs')) { e.preventDefault(); toggleFocusThumbs(); return; } if (e.key === 'Escape' && store.state.settings.pureMode) { e.preventDefault(); setPureMode(false); return; } if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (shortcutMatches(e, 'refreshAll')) { e.preventDefault(); service.refreshAll(); return; } if (shortcutMatches(e, 'focusAdd')) { e.preventDefault(); tbInput.focus(); return; } if (shortcutMatches(e, 'recordingCenter')) { e.preventDefault(); openRecordingCenter(); return; } if (shortcutMatches(e, 'recordPage')) { e.preventDefault(); recordCurrentPage(); return; } if (e.key === 'Escape' && document.fullscreenElement) document.exitFullscreen(); if (store.state.settings.viewMode === 'focus') { if (e.code === 'Space') { e.preventDefault(); const id = store.state.settings.focusedRoomId; if (id) { service.togglePause(id); requestAnimationFrame(() => updateCardButtons(id)); } } if (e.key === 'ArrowRight' || e.key === ']') { e.preventDefault(); focusStep(1); } if (e.key === 'ArrowLeft' || e.key === '[') { e.preventDefault(); focusStep(-1); } } // 视图切换:g = grid, f = focus if (shortcutMatches(e, 'gridView')) { e.preventDefault(); store.patchSettings({ viewMode: 'grid' }); return; } if (shortcutMatches(e, 'focusView')) { e.preventDefault(); if (!store.state.settings.focusedRoomId) { const vr = visibleRooms(); const first = vr.find(r => r.lastStatus === 'online') || vr[0]; if (first) store.patchSettings({ viewMode: 'focus', focusedRoomId: first.id, focusThumbsCollapsed: false }); else store.patchSettings({ viewMode: 'focus', focusThumbsCollapsed: false }); } else { store.patchSettings({ viewMode: 'focus', focusThumbsCollapsed: false }); } return; } }); // ---- 首次渲染 + 全量启动 service ---- // service 独立于 UI 运行:所有 store 中的房间都参与轮询, // 这样切到其它分组时,被隐藏房间的上线事件也能被检测到并触发桌面通知。 loadSharedWorkspaceFromHash(); loadSavedTemporarySources(); renderSidebar(); renderGrid(); applyPureModeState(); for (const r of store.state.rooms) service.start(r.id); setTimeout(checkRecordingIntentRecovery, 1200); // 兜底:定期检查 sessions 与 rooms 是否一致(防止某些边缘 case 数据漂移) setInterval(() => { for (const r of store.state.rooms) { if (!service.has(r.id)) service.start(r.id); } }, 60000); // 调试入口默认不暴露到页面;需要时先在控制台设置 localStorage.ryujo_multicam_debug = '1' 后刷新。 try { if (localStorage.getItem('ryujo_multicam_debug') === '1') window.__multicam = { store, service, EventBus }; else if (window.__multicam) delete window.__multicam; } catch (_) {} } })();