RoomGrid MultiCam Pro

RoomGrid 多房间工作台:2/4/6/9 分页窗口布局、存储加固、流地址校验、丝滑交互、主屏多窗口模式、智能重连、提醒与配置导入导出。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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 (_) {}
  }

})();