International multi-room workstation with fixed 2/4/6/9 paged multiview layouts, hardened storage, validated streams, smoother HUD controls, focus multiview, smart reconnect, alerts, and config export/import.
// ==UserScript== // @name RoomGrid MultiCam Pro // @name:zh-CN RoomGrid MultiCam Pro // @namespace https://github.com/ryujo/roomgrid-multicam-pro // @version 15.5 // @description International multi-room workstation with fixed 2/4/6/9 paged multiview layouts, hardened storage, validated streams, smoother HUD controls, focus multiview, smart reconnect, alerts, and config export/import. // @description:zh-CN RoomGrid 多房间工作台:2/4/6/9 分页窗口布局、存储加固、流地址校验、丝滑交互、主屏多窗口模式、智能重连、提醒与配置导入导出。 // @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 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 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', 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.', 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', 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 (video only)', opRecordStop: 'Stop recording', opPiP: 'Picture-in-Picture', opFullscreen: 'Fullscreen', opMoveGroup: 'Add to group', opRemove: 'Remove from current group', opDeleteRoom: 'Delete saved room', opResize: 'Resize tile', 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 video-only. 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', // ---- 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 button —', 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:', 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', 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.\n\nShortcuts:\n/ Focus username input\nr Refresh all\ng Grid view\nf Focus view\nAlt+P or Alt+C Clean mode on/off\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', menuResetTileSizes: 'Reset tile sizes', 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', 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: '在完整显示和裁切填满之间切换。', 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: '默认', groupFav: '收藏', newGroup: '新建分组', newGroupPrompt: '新分组名称:', renameGroup: '重命名', renameGroupPrompt: '重命名为:', deleteGroup: '删除分组', deleteGroupConfirm: (n) => `删除分组「${n}」?房间仍会保留,只会从该分组移除。`, statTotal: '总计', statOnline: '在线', statMuted: '静音', opRefresh: '刷新', opMuteToggle: '静音切换', opPause: '暂停', opResume: '继续播放', opScreenshot: '截图当前画面', opRecordStart: '录制当前窗口(仅视频)', opRecordStop: '停止录制', opPiP: '画中画', opFullscreen: '全屏', opMoveGroup: '加入分组', opRemove: '移出当前分组', opDeleteRoom: '彻底删除房间', opResize: '缩放窗口', opOpenRoom: '打开房间', opCopyUsername: '复制用户名', opFavoriteAdd: '加入收藏', opFavoriteRemove: '取消收藏', opMoveToFavorites: '仅移到收藏', copied: '已复制', screenshotSaved: '截图已保存', captureFailed: '截图失败。浏览器跨域/CORS 可能阻止绘制该视频流。', recordingConsent: '录制只保存在本地,且只录视频轨道。请只在你有权保存该内容时使用。继续?', recordingStarted: '已开始录制', recordingSaved: '录制已保存', recordingUnsupported: '当前视频或浏览器不支持录制', 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: '— 折叠按钮 —', added: '已加入', exists: '已存在', addFailed: '失败', addedNamed: (n) => `${n} 已加入`, removedNamed: (n) => `${n} 已移除`, quickAddTitle: '加入工作台', quickRemoveTitle: '已在工作台 — 点击移除', notifyTitleText: '主播上线', notifyBody: (n) => `${n} 已开播`, permDenied: '未获得桌面通知权限,仅卡片闪烁会生效。\n\n你可以去浏览器设置里手动开启。', manualImport: '批量添加', manualImportPrompt: '粘贴用户名,支持一行一个,或用空格/逗号分隔:', manualImportDone: (a, e) => `批量添加完成:新增 ${a},已存在 ${e}`, moreMenu: '更多', moreMenuTitle: '更多', menuLanguage: '语言', menuAbout: '关于', menuExport: '导出配置', menuExportUsernames: '导出用户名 txt', menuCopyUsernames: '复制用户名列表', menuMuteAll: '全部静音', menuUnmuteAll: '取消全部静音', menuPauseVisible: '⏸ 暂停可见窗口', menuResumeVisible: '继续可见窗口', menuStopRecordings: '停止所有录制', menuPureMode: '纯净模式', menuViewerMode: '窗口优先模式', menuToggleThumbs: '显示 / 隐藏缩略图', menuToggleFit: '切换画面适应', menuShortcutHelp: '快捷键 / 提示说明', shortcutsHelp: '鼠标停在任何按钮或控件上,会显示即时说明。\n\n快捷键:\n/ 聚焦用户名输入框\nr 全部刷新\ng 平铺视图\nf 主屏视图\nAlt+P 或 Alt+C 开关纯净模式\nEsc 退出纯净模式 / 全屏\n空格 主屏模式下暂停/继续主屏\n←/→ 或 [/ ] 主屏模式下切换主屏\n双击窗口 全屏', pausedVisible: '已暂停可见窗口', resumedVisible: '已继续可见窗口', menuImport: '导入配置', menuResetTileSizes: '重置窗口尺寸', 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 nav = (navigator.language || navigator.userLanguage || 'en').toLowerCase(); return nav.startsWith('zh') ? 'zh' : '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.5', 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 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: FAVORITE_GROUP_ID, name: '__fav__', order: 2, 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', 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: FAVORITE_GROUP_ID, name: '__fav__', order: 2, 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 = found.name || g.name; found.system = true; if (!Number.isFinite(Number(found.order))) 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)); } 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, FAVORITE_GROUP_ID].includes(id), }); }); ensureSystemGroups(out); const validGroupIds = new Set(out.groups.map(g => g.id).filter(id => id !== LIBRARY_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.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; return getRoomGroups(room).includes(groupId || DEFAULT_GROUP_ID); } function roomOrderInGroup(room, groupId) { if (groupId === LIBRARY_GROUP_ID || groupId === DEFAULT_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) 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) 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 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 ? 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) { 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 === 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) 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) 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) 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)); 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 && 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)); } 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) { if (sessions.get(id)?.userPaused) video.pause(); else video.play().catch(() => {}); } }); 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 }; } /* ============================================================= * 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(); }; // ---- 浮动按钮 ---- const root = $('div', { style: { position: 'fixed', right: '18px', bottom: '18px', zIndex: 99999, fontFamily: 'system-ui,-apple-system,sans-serif', userSelect: 'none', }, }); let collapsed = localStorage.getItem('ryujo_fab_collapsed') === '1'; const fab = $('button', { title: 'Alt+M / Alt+A', style: { width: '52px', height: '52px', borderRadius: '50%', border: 'none', cursor: 'pointer', background: 'linear-gradient(135deg,#f6921e,#ff6b35)', color: '#fff', fontSize: '13px', fontWeight: '700', letterSpacing: '.03em', boxShadow: '0 6px 20px rgba(0,0,0,.45)', transition: 'transform .15s', }, html: 'RG', onclick: (e) => { if (!dragged) toggleMenu(); }, onmouseenter: (e) => e.currentTarget.style.transform = 'scale(1.08)', onmouseleave: (e) => e.currentTarget.style.transform = 'scale(1)', }); const menu = $('div', { style: { position: 'absolute', right: 0, bottom: '60px', minWidth: '220px', background: '#2b2d31', backdropFilter: 'blur(12px)', borderRadius: '12px', padding: '8px', boxShadow: '0 10px 30px rgba(0,0,0,.5)', display: 'none', flexDirection: 'column', gap: '4px', border: '1px solid rgba(255,255,255,.08)', }, }); const menuBtn = (text, fn, color = '#fff') => $('button', { style: { background: 'transparent', border: 'none', color, padding: '10px 12px', textAlign: 'left', cursor: 'pointer', borderRadius: '8px', fontSize: '13px', transition: 'background .15s', }, onmouseenter: (e) => e.currentTarget.style.background = 'rgba(255,255,255,.08)', onmouseleave: (e) => e.currentTarget.style.background = 'transparent', onclick: fn, }, String(text || '')); // —— 当前房间「加入/已加入」按钮(响应式)—— const addBtn = menuBtn('', () => { 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(); updateAddBtn(); }); function updateAddBtn() { if (!currentRoom) { addBtn.style.display = 'none'; return; } addBtn.style.display = ''; const inList = Storage.has(currentRoom); addBtn.textContent = inList ? (LANG === 'zh' ? `${currentRoom} 已在工作台` : `${currentRoom} already in workstation`) : (LANG === 'zh' ? `加入 ${currentRoom}` : `Add ${currentRoom}`); addBtn.style.color = inList ? '#23a559' : '#fff'; } currentRoomSubs.add(updateAddBtn); storageSubs.add(updateAddBtn); updateAddBtn(); menu.appendChild(addBtn); menu.appendChild(menuBtn(t('openWorkstation'), openWorkstationNew)); menu.appendChild(menuBtn(t('openWorkstationHere'), () => { if (confirm(t('openWorkstationHereConfirm'))) openWorkstationHere(); }, '#b5bac1')); menu.appendChild(menuBtn(t('memoryView'), () => { const s = Storage.load(); toast(t('memoryStat', s.rooms.length), 2500); })); menu.appendChild(menuBtn(t('collapseFAB'), () => { collapsed = true; localStorage.setItem('ryujo_fab_collapsed', '1'); fab.style.opacity = '0.25'; menu.style.display = 'none'; }, '#888')); const toggleMenu = () => { if (collapsed) { collapsed = false; localStorage.setItem('ryujo_fab_collapsed', '0'); fab.style.opacity = '1'; return; } menu.style.display = menu.style.display === 'none' ? 'flex' : 'none'; }; if (collapsed) fab.style.opacity = '0.25'; root.append(menu, fab); document.body.appendChild(root); // —— 拖动 —— let dragged = false, sx, sy, ox, oy; fab.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(); updateAddBtn(); } }); // —— 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; width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer; background: rgba(17,24,39,.42); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 16px; font-weight: bold; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .15s, background .15s, transform .15s; padding: 0; line-height: 1; } .multicam-quick-add:hover { background: #f6921e; transform: scale(1.1); } .multicam-qa-host:hover .multicam-quick-add, .multicam-quick-add:focus, .multicam-quick-add.added { opacity: 1; } .multicam-quick-add.added { background: #23a559; } .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 ? '' : '+'; 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); }, }, '+'); 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(); } catch (_) {} try { service.stopAll(); } catch (_) { stopAllPageMedia(); } }); // 全局样式 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.resizing { outline:2px solid var(--accent); outline-offset:2px; box-shadow:var(--shadow-md); } .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; } .mc-resize-handle { position:absolute; right:8px; bottom:8px; z-index:30; width:22px; height:22px; border-radius:8px; cursor:nwse-resize; opacity:.72; display:flex; align-items:center; justify-content:center; color:#fff; font-size:12px; line-height:1; background:rgba(15,23,42,.42); user-select:none; pointer-events:auto; box-shadow:0 4px 12px rgba(0,0,0,.16); transition:opacity .12s, background .12s, transform .12s; } .mc-resize-handle:hover { opacity:1; background:var(--accent); transform:translateY(-1px); } .grid.view-focus .mc-resize-handle { display:none; } .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-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; } .cam-card.recording { outline:2px solid var(--danger); outline-offset:2px; } @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, .cam-card .mc-resize-handle { 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, .cam-card:hover .mc-resize-handle, .cam-card:focus-within .mc-resize-handle { 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; } .cam-card .mc-resize-handle { right:8px; bottom:8px; transform:translateY(4px); background:rgba(15,23,42,.34); box-shadow:none; } .cam-card:hover .mc-resize-handle, .cam-card:focus-within .mc-resize-handle { transform:translateY(0); } .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; } .cam-card .mc-resize-handle { opacity:0 !important; pointer-events:none !important; } .cam-card:hover .mc-resize-handle, .cam-card:focus-within .mc-resize-handle { opacity:.8 !important; pointer-events:auto !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, .grid.view-focus .thumbs-row .mc-resize-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, .cam-card .mc-resize-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, .cam-card:hover .mc-resize-handle, .cam-card:focus-within .mc-resize-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 .mc-resize-handle { right:6px !important; bottom:6px !important; width:18px !important; height:18px !important; border-radius:5px !important; background:rgba(0,0,0,.38) !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; } .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 .mc-resize-handle, 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; } .grid.view-grid .mc-resize-handle { display: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 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 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, 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 total = store.state.rooms.length; const online = store.state.rooms.filter(r => r.lastStatus === 'online').length; const muted = store.state.rooms.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'), ]), ])); 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 === '__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) return; e.preventDefault(); tab.classList.add('drop-target'); }, ondragleave: () => tab.classList.remove('drop-target'), ondrop: (e) => { if (g.id === LIBRARY_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); } 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 c = { [LIBRARY_GROUP_ID]: store.state.rooms.length }; for (const r of store.state.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} 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, fullBtn, moreOpsBtn); const resizeHandle = $('div', { class: 'mc-resize-handle', title: t('opResize'), onmousedown: (e) => startTileResize(e, room.id, card), onclick: (e) => { e.preventDefault(); e.stopPropagation(); }, html: trustedHtml(iconSvg('resize', 14)), }); // 状态文字(中央覆盖层) const statusEl = $('div', { class: 'status-layer' }); card.append(badge, name, opsRow, dragHandle, resizeHandle, 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') || e.target.closest('.mc-resize-handle')) return; document.fullscreenElement ? document.exitFullscreen() : card.requestFullscreen().catch(() => {}); }); card.addEventListener('contextmenu', (e) => { if (e.target.closest('.icon-btn') || e.target.closest('.drag-handle') || e.target.closest('.mc-resize-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'); }); 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') || e.target.closest('.mc-resize-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) ? 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 ? t('opRecordStop') : t('opRecordStart')); c.recordBtn.classList.toggle('recording', !!rec); } c.root.classList.toggle('recording', !!rec); } 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 startCardRecording(roomId) { if (recordings.has(roomId)) return; const c = cardMap.get(roomId); const video = c?.video; if (!video) { toast(t('recordingUnsupported')); return; } if (!confirm(t('recordingConsent'))) return; const source = getVideoCaptureStream(video); if (!source) { toast(t('recordingUnsupported')); return; } const tracks = source.getVideoTracks(); try { source.getAudioTracks?.().forEach(tr => tr.stop()); } catch (_) {} if (!tracks.length || typeof MediaRecorder === 'undefined') { try { source.getTracks().forEach(tr => tr.stop()); } catch (_) {} toast(t('recordingUnsupported')); return; } const stream = new MediaStream(tracks); // video-only on purpose const mimes = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']; const mimeType = mimes.find(m => { try { return MediaRecorder.isTypeSupported(m); } catch (_) { return false; } }) || ''; const chunks = []; let recorder; try { recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); } catch (err) { try { stream.getTracks().forEach(tr => tr.stop()); } catch (_) {} toast(t('recordingUnsupported')); return; } recorder.ondataavailable = ev => { if (ev.data && ev.data.size) chunks.push(ev.data); }; recorder.onstop = () => { const recState = recordings.get(roomId); const silentStop = !!recState?.silentStop; try { stream.getTracks().forEach(tr => tr.stop()); } catch (_) {} recordings.delete(roomId); updateCardButtons(roomId); if (chunks.length) { const blob = new Blob(chunks, { type: mimeType || 'video/webm' }); downloadBlob(blob, `roomgrid-${safeFilePart(roomId)}-${stampForFile()}.webm`); if (!silentStop) toast(t('recordingSaved')); } }; const timer = setTimeout(() => stopCardRecording(roomId), 15 * 60 * 1000); recordings.set(roomId, { recorder, stream, chunks, timer }); try { recorder.start(1000); } catch (err) { recordings.delete(roomId); clearTimeout(timer); try { stream.getTracks().forEach(tr => tr.stop()); } catch (_) {} toast(t('recordingUnsupported')); return; } updateCardButtons(roomId); toast(t('recordingStarted')); } function stopCardRecording(roomId, silent = false) { const rec = recordings.get(roomId); if (!rec) return; try { clearTimeout(rec.timer); } catch (_) {} try { rec.silentStop = !!silent; if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop(); else { recordings.delete(roomId); updateCardButtons(roomId); } } catch (_) { try { rec.stream?.getTracks?.().forEach(tr => tr.stop()); } catch (_) {} recordings.delete(roomId); updateCardButtons(roomId); if (!silent) toast(t('recordingSaved')); } } function toggleCardRecording(roomId) { if (recordings.has(roomId)) stopCardRecording(roomId); else startCardRecording(roomId); } function stopAllRecordings() { [...recordings.keys()].forEach(id => stopCardRecording(id, true)); } function startTileResize(e, roomId, card) { if (!card || store.state.settings.viewMode !== 'grid') return; e.preventDefault(); e.stopPropagation(); const room = store.state.rooms.find(r => r.id === roomId); if (!room) return; const activeGroupForSize = store.state.settings.activeGroup || DEFAULT_GROUP_ID; const { base, row } = gridMetrics(); const start = tileSizeForRoom(room); const startX = e.clientX; const startY = e.clientY; let current = { ...start }; let raf = 0; const applyLive = () => { raf = 0; setCardSizeForGroup(room, activeGroupForSize, current); applyCardGridSizing(card, room); }; const onMove = (ev) => { const dCols = Math.round((ev.clientX - startX) / base); const dRows = Math.round((ev.clientY - startY) / row); current = normalizeCardSize({ cols: start.cols + dCols, rows: start.rows + dRows }) || start; if (!raf) raf = requestAnimationFrame(applyLive); }; const onUp = () => { if (raf) { cancelAnimationFrame(raf); raf = 0; } card.classList.remove('resizing'); document.body.style.cursor = ''; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); setCardSizeForGroup(room, activeGroupForSize, current); applyCardGridSizing(card, room); store.setRoomCardSize(roomId, activeGroupForSize, current); }; card.classList.add('resizing'); document.body.style.cursor = 'nwse-resize'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp, { once: 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('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 ? 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 === '__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).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(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') }), item(store.state.settings.notifyOnline ? sectionLabel('关闭上线提醒', 'Disable online alerts') : sectionLabel('开启上线提醒', 'Enable online alerts'), async () => { if (!store.state.settings.notifyOnline) { const ok = await Notify.request(); if (!ok) { alert(t('permDenied')); return; } } store.patchSettings({ notifyOnline: !store.state.settings.notifyOnline }); }, { title: t('notifyTitle') }), ]); 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('menuStopRecordings'), () => stopAllRecordings()), ]); addSection(sectionLabel('数据', 'Data'), [ item(t('manualImport'), () => openManualImportPrompt(), { title: t('manualImport') }), 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')); }), item(t('menuResetTileSizes'), () => { store.resetTileSizes(); renderGrid(); }), ]); 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('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' } }, t('title')), 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 ? [...s.rooms] : s.rooms.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'); 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)); } 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); } else { c.root.classList.add('not-online'); c.statusEl.style.display = 'flex'; // 关键修复:状态非 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 = store.state.rooms.find(x => x.id === 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 attachVideoElement(roomId) { const c = cardMap.get(roomId); if (!c) return null; // 移除旧 video:必须同步销毁 HLS,否则旧 buffer 可能继续出声。 if (c.video) { 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 = store.state.rooms.find(x => x.id === 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)); service.attachVideo(roomId, video); updateCardButtons(roomId); return video; } // ---- 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(store.state.rooms.map(r => r.id)); for (const [id, c] of cardMap) { if (!wantIds.has(id)) { stopCardRecording(id, true); 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.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.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' ); 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 (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); // 状态排序时,单卡状态变化也要重排 if (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 (!store.state.rooms.find(x => x.id === id)) { service.stop(id); return; } const video = attachVideoElement(id); if (!video) return; service.startHls(id, hlsSource); const r = store.state.rooms.find(x => x.id === 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()); // ---- 快捷键 ---- document.addEventListener('keydown', (e) => { const key = String(e.key || '').toLowerCase(); if (e.key === 'Escape') closeTransientUi(); if (e.altKey && (key === 'p' || key === 'c')) { e.preventDefault(); togglePureMode(); return; } if (e.altKey && key === 'v') { e.preventDefault(); toggleViewerMode(); return; } if (e.altKey && key === 't') { 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 (e.key === 'r' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); service.refreshAll(); } if (e.key === '/') { e.preventDefault(); tbInput.focus(); } 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 (e.key === 'g' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); store.patchSettings({ viewMode: 'grid' }); } if (e.key === 'f' && !e.ctrlKey && !e.metaKey) { 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 }); } } }); // ---- 首次渲染 + 全量启动 service ---- // service 独立于 UI 运行:所有 store 中的房间都参与轮询, // 这样切到其它分组时,被隐藏房间的上线事件也能被检测到并触发桌面通知。 renderSidebar(); renderGrid(); applyPureModeState(); for (const r of store.state.rooms) service.start(r.id); // 兜底:定期检查 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 (_) {} } })();