RoomGrid MultiCam Pro

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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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 => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
    }[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 (_) {}
  }

})();