Greasy Fork

Greasy Fork is available in English.

SOOP 다시보기 라이브 당시 시간 표시

SOOP 다시보기에서 생방송 당시 시간을 표시/이동 (최근 기록, 셀렉터 폴백, 접근성, 최적화)

当前为 2025-09-02 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         SOOP 다시보기 라이브 당시 시간 표시
// @namespace    http://tampermonkey.net/
// @version      5.1.0
// @description  SOOP 다시보기에서 생방송 당시 시간을 표시/이동 (최근 기록, 셀렉터 폴백, 접근성, 최적화)
// @author       WakViewer
// @match        https://vod.sooplive.co.kr/player/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=www.sooplive.co.kr
// @grant        unsafeWindow
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ---------------- Config & Selectors ----------------
  const SELECTORS = {
    startTimeTip: "span.broad_time[tip*='방송시간']",
    infoUL: ".broadcast_information .cnt_info ul",
  };
  const CURRENT_TIME_CANDIDATES = [
    "span.time-current", ".time-current",
    ".player .time-current", ".time_display .time-current",
    '[aria-label="Current time"]', '[data-role="current-time"]'
  ];
  const DURATION_CANDIDATES = [
    "span.time-duration", ".time-duration",
    ".player .time-duration", ".time_display .time-duration",
    '[aria-label="Duration"]', '[data-role="duration"]'
  ];

  const EDIT_THRESHOLD_SEC = 180;     // 편집 감지 여유
  const UPDATE_INTERVAL_MS  = 500;    // 표시 갱신 주기
  const HISTORY_KEY         = 'wv_soop_dt_history';
  const HISTORY_MAX         = 5;

  // ---------------- State ----------------
  let startTime = null, endTime = null;
  let currentLiveTimeStr = '';
  let updateTimer = null, routeObserver = null, initDoneForHref = null;
  let timeObserver = null; // MutationObserver
  let lastActiveEl = null; // a11y 포커스 복귀용

  // ---------------- Tiny utils ----------------
  const $ = (sel, root=document) => root.querySelector(sel);
  const p2 = (n)=> String(n).padStart(2,'0');
  const fmtDate = (d) => `${d.getFullYear()}-${p2(d.getMonth()+1)}-${p2(d.getDate())}, ${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
  const userTZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';

  const waitFor = (selector, {timeout=10000, root=document}={}) =>
    new Promise((resolve, reject) => {
      const found = $(selector, root);
      if (found) return resolve(found);
      const obs = new MutationObserver(() => {
        const el2 = $(selector, root);
        if (el2) { obs.disconnect(); resolve(el2); }
      });
      obs.observe(root.body || root, { childList:true, subtree:true });
      if (timeout > 0) setTimeout(() => { obs.disconnect(); reject(new Error('waitFor timeout: '+selector)); }, timeout);
    });

  const pickFirst = (qList, root=document) => {
    for (const q of qList) { const el = root.querySelector(q); if (el) return el; }
    return null;
  };

  function getCurrentTimeEl() {
    let el = pickFirst(CURRENT_TIME_CANDIDATES);
    if (el) return el;
    // 패턴 폴백: 짧은 HH:MM:SS / MM:SS 텍스트
    const nodes = Array.from(document.querySelectorAll('span,div,time'))
      .filter(n => /:\d{2}/.test((n.textContent||'').trim()))
      .filter(n => (n.textContent||'').trim().length <= 8);
    return nodes[0] || null;
  }
  function getDurationEl() {
    let el = pickFirst(DURATION_CANDIDATES);
    if (el) return el;
    const cands = Array.from(document.querySelectorAll('span,div,time'))
      .filter(n => /:\d{2}/.test((n.textContent||'').trim()));
    cands.sort((a,b)=> (a.textContent||'').length - (b.textContent||'').length);
    return cands[cands.length-1] || null;
  }

  // ---------------- Parse helpers ----------------
  const parseTipTimes = (tip) => {
    const m = tip && tip.match(/방송시간\s*:\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s*~\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/);
    if (!m) return null;
    const s = new Date(m[1].replace(' ', 'T'));
    const e = new Date(m[2].replace(' ', 'T'));
    if (isNaN(s) || isNaN(e)) return null;
    return { start:s, end:e };
  };
  const parseHMSFlexible = (text) => {
    if (!text) return 0;
    const parts = text.trim().split(':').map(Number);
    if (parts.some(isNaN)) return 0;
    if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
    if (parts.length === 2) return parts[0]*60 + parts[1];
    return 0;
  };

  // --------- Timezone transforms ----------
  function zonedComponentsToUTCms(comp, timeZone) {
    const utcGuess = Date.UTC(comp.y, comp.M-1, comp.d, comp.h, comp.m, comp.s);
    const fmt = new Intl.DateTimeFormat('en-US', {
      timeZone, year:'numeric', month:'2-digit', day:'2-digit',
      hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false
    });
    const parts = fmt.formatToParts(new Date(utcGuess));
    const get = t => Number(parts.find(p => p.type === t).value);
    const tzY=get('year'), tzM=get('month'), tzD=get('day'), tzH=get('hour'), tzMin=get('minute'), tzS=get('second');
    const tzEpoch = Date.UTC(tzY, tzM-1, tzD, tzH, tzMin, tzS);
    const offset = tzEpoch - utcGuess;
    return Date.UTC(comp.y, comp.M-1, comp.d, comp.h, comp.m, comp.s) - offset;
  }
  function startOfDayZoned(date, timeZone) {
    const f = new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit'});
    const p = f.formatToParts(date);
    const y = +p.find(v=>v.type==='year').value;
    const M = +p.find(v=>v.type==='month').value;
    const d = +p.find(v=>v.type==='day').value;
    return zonedComponentsToUTCms({y,M,d,h:0,m:0,s:0}, timeZone);
  }
  function listDaysInRange(start, end) {
    const res = [];
    if (!start || !end) return res;
    const endDayMs = startOfDayZoned(end, userTZ);
    let curMs = startOfDayZoned(start, userTZ);
    let guard = 0;
    while (curMs <= endDayMs && guard < 370) {
      const d = new Date(curMs);
      const f = new Intl.DateTimeFormat('en-CA',{timeZone:userTZ,year:'numeric',month:'2-digit',day:'2-digit'});
      const p = f.formatToParts(d);
      res.push({ y:+p.find(v=>v.type==='year').value, M:+p.find(v=>v.type==='month').value, d:+p.find(v=>v.type==='day').value });
      curMs += 24*3600*1000;
      guard++;
    }
    return res;
  }

  // --------------- Natural input parse ---------------
  function normalizeSpaces(s){ return s.replace(/\u00A0/g,' ').replace(/\s+/g,' ').trim(); }
  function inferYearFromYY(yy) {
    const yys = [startTime.getFullYear()%100, endTime.getFullYear()%100];
    if (yy === yys[0]) return startTime.getFullYear();
    if (yy === yys[1]) return endTime.getFullYear();
    return 2000 + yy;
  }
  function parseInputToTarget(text) {
    if (!text) return null;
    let s = normalizeSpaces(text).replace(/,/g,' ');

    // 한국어 날짜/시간
    const korDate = s.match(/(?:(\d{2,4})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
    const korTime = s.match(/(\d{1,2})\s*시(?:\s*(\d{1,2})\s*분)?(?:\s*(\d{1,2})\s*초)?/);
    if (korDate || korTime) {
      let y, M, d, h=0, m=0, sec=0;
      if (korDate) {
        const yyRaw = korDate[1];
        M = +korDate[2]; d = +korDate[3];
        if (yyRaw) y = (yyRaw.length===2) ? inferYearFromYY(+yyRaw) : +yyRaw;
        else y = startTime.getFullYear();
      } else if (korTime) {
        h = +korTime[1]; m = korTime[2]?+korTime[2]:0; sec = korTime[3]?+korTime[3]:0;
        if (h>23||m>59||sec>59) return null;
        const days = listDaysInRange(startTime, endTime);
        for (const dc of days) {
          const ms = zonedComponentsToUTCms({y:dc.y,M:dc.M,d:dc.d,h,m,s:sec}, userTZ);
          const cand = new Date(ms);
          if (cand >= startTime && cand <= endTime) return { comp:{y:dc.y,M:dc.M,d:dc.d,h,m,s:sec} };
        }
        return null;
      }
      if (korTime) { h=+korTime[1]; m=korTime[2]?+korTime[2]:0; sec=korTime[3]?+korTime[3]:0; }
      if (h>23||m>59||sec>59) return null;
      if (!y||!M||!d) return null;
      return { comp:{y,M,d,h,m,s:sec} };
    }

    let m;
    m = s.match(/^(\d{4})[-.](\d{1,2})[-.](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
    if (m) { const y=+m[1], M=+m[2], d=+m[3], h=+m[4], mm=+m[5], ss=m[6]?+m[6]:0; if (h>23||mm>59||ss>59) return null;
      return { comp:{y,M,d,h,m:mm,s:ss} }; }
    m = s.match(/^(\d{2})[-.](\d{1,2})[-.](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
    if (m) { const y=inferYearFromYY(+m[1]), M=+m[2], d=+m[3], h=+m[4], mm=+m[5], ss=m[6]?+m[6]:0; if (h>23||mm>59||ss>59) return null;
      return { comp:{y,M,d,h,m:mm,s:ss} }; }
    m = s.match(/^(\d{1,2})[-.](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
    if (m) { const M=+m[1], d=+m[2], h=+m[3], mm=+m[4], ss=m[5]?+m[5]:0; if (h>23||mm>59||ss>59) return null;
      const candidates=[startTime.getFullYear(), endTime.getFullYear()];
      for (const y of [...new Set(candidates)]) {
        const ms=zonedComponentsToUTCms({y,M,d,h,m:mm,s:ss}, userTZ); const cand=new Date(ms);
        if (cand>=startTime && cand<=endTime) return { comp:{y,M,d,h,m:mm,s:ss} };
      }
      return { comp:{ y:startTime.getFullYear(), M, d, h, m:mm, s:ss } }; }
    m = s.match(/^(\d{4}-\d{1,2}-\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?$/);
    if (m) { const [y,M,d]=m[1].split('-').map(Number); const h=+m[2], mm=+m[3], ss=m[4]?+m[4]:0; if (h>23||mm>59||ss>59) return null;
      return { comp:{ y,M,d,h,m:mm,s:ss } }; }
    const t = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
    if (t && startTime && endTime) {
      const hh=+t[1], mm=+t[2], ss=t[3]?+t[3]:0; if (hh>23||mm>59||ss>59) return null;
      const days=listDaysInRange(startTime, endTime);
      for (const d of days) {
        const candMs=zonedComponentsToUTCms({ y:d.y,M:d.M,d:d.d,h:hh,m:mm,s:ss }, userTZ);
        const cand=new Date(candMs);
        if (cand>=startTime && cand<=endTime) return { comp:{ y:d.y,M:d.M,d:d.d,h:hh,m:mm,s:ss } };
      }
      return null;
    }
    const onlyKorTime = s.match(/^(\d{1,2})\s*시(?:\s*(\d{1,2})\s*분)?(?:\s*(\d{1,2})\s*초)?$/);
    if (onlyKorTime && startTime && endTime) {
      const hh=+onlyKorTime[1], mm=onlyKorTime[2]?+onlyKorTime[2]:0, ss=onlyKorTime[3]?+onlyKorTime[3]:0;
      if (hh>23||mm>59||ss>59) return null;
      const days=listDaysInRange(startTime, endTime);
      for (const d of days) {
        const candMs=zonedComponentsToUTCms({ y:d.y,M:d.M,d:d.d,h:hh,m:mm,s:ss }, userTZ);
        const cand=new Date(candMs);
        if (cand>=startTime && cand<=endTime) return { comp:{y:d.y,M:d.M,d:d.d,h:hh,m:mm,s:ss} };
      }
      return null;
    }
    const korDateAndHm = s.match(/(?:(\d{2,4})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일\s+(\d{1,2}):(\d{2})$/);
    if (korDateAndHm) {
      let y = korDateAndHm[1] ? (korDateAndHm[1].length===2 ? inferYearFromYY(+korDateAndHm[1]) : +korDateAndHm[1]) : startTime.getFullYear();
      const M = +korDateAndHm[2], d = +korDateAndHm[3], h = +korDateAndHm[4], m = +korDateAndHm[5];
      if (h>23||m>59) return null;
      return { comp:{ y,M,d,h,m,s:0 } };
    }
    m = s.match(/^(\d{1,2})[.-](\d{1,2})\s+(\d{1,2}):(\d{2})$/);
    if (m) {
      const M=+m[1], d=+m[2], h=+m[3], mm=+m[4];
      if (h>23||mm>59) return null;
      const candidates=[startTime.getFullYear(), endTime.getFullYear()];
      for (const y of [...new Set(candidates)]) {
        const ms=zonedComponentsToUTCms({y,M,d,h,m:mm,s:0}, userTZ);
        const cand=new Date(ms);
        if (cand>=startTime && cand<=endTime) return { comp:{y,M,d,h,m:mm,s:0} };
      }
      return { comp:{ y:startTime.getFullYear(), M, d, h, m:mm, s:0 } };
    }
    return null;
  }

  // ---------------- Toast ----------------
  function showToastMessage(message, isError=false) {
    const container =
      document.querySelector('#toastMessage') ||
      document.querySelector('#toast-message') ||
      document.querySelector('.toastMessage') ||
      document.querySelector('.toast-message') ||
      document.querySelector('.toast_container, .toast-container, .toast-wrap, .toast_wrap');

    if (container) {
      const wrap = document.createElement('div');
      const text = document.createElement('p');
      text.textContent = String(message ?? '');
      wrap.appendChild(text); container.appendChild(wrap);
      setTimeout(() => { if (wrap.parentNode === container) container.removeChild(wrap); }, 2000);
      return;
    }
    try { window.dispatchEvent(new CustomEvent('toast-message', { detail:{ message:String(message ?? ''), type:isError?'error':'info' } })); } catch {}
    alert(String(message ?? ''));
  }

  // ---------------- History store ----------------
  const loadHistory = () => {
    try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); }
    catch { return []; }
  };
  const saveHistory = (arr) => localStorage.setItem(HISTORY_KEY, JSON.stringify(arr.slice(0, HISTORY_MAX)));
  const addHistory  = (item) => {
    const list = loadHistory().filter(v => v !== item);
    list.unshift(item);
    saveHistory(list);
  };
  const clearHistory = () => saveHistory([]);

  // ---------------- Modal ----------------
  let jumpModalHost = null;

  function openJumpModal(triggerBtn) {
    lastActiveEl = triggerBtn || document.activeElement;

    const startStr = fmtDate(startTime);
    const endStr   = fmtDate(endTime);

    // 시작 + 2분 힌트
    const hintBase = new Date(startTime.getTime() + 2*60*1000);
    const y = hintBase.getFullYear(), M = p2(hintBase.getMonth()+1), D = p2(hintBase.getDate());
    const H = p2(hintBase.getHours()), m = p2(hintBase.getMinutes()), s = p2(hintBase.getSeconds());
    const yy = String(y).slice(-2), kH = String(hintBase.getHours());
    const placeholderHint = `예: ${y}-${M}-${D}, ${H}:${m}:${s}  /  ${yy}.${M}.${D} ${H}:${m}  /  ${M}월 ${D}일 ${kH}시 ${m}분`;

    if (!jumpModalHost) {
      jumpModalHost = document.createElement('div');
      jumpModalHost.style.position = 'fixed';
      jumpModalHost.style.inset = '0';
      jumpModalHost.style.zIndex = '2147483647';
      jumpModalHost.attachShadow({ mode:'open' });
      document.documentElement.appendChild(jumpModalHost);
    }
    const root = jumpModalHost.shadowRoot; root.innerHTML = '';

    const style = document.createElement('style');
    style.textContent = `
      :host { all: initial; }
      .backdrop { all: initial; position: fixed; inset: 0; background: rgba(0,0,0,.38); display: grid; place-items: center; }
      .card {
        all: initial; width: min(720px, 94vw); background: #1f2329; color: #e9edf3; border-radius: 14px;
        box-shadow: 0 20px 60px rgba(0,0,0,.45);
        font-family: "Pretendard", -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Malgun Gothic", "맑은 고딕", helvetica, sans-serif;
        text-rendering: optimizeSpeed; font-size: 14px; line-height: 1.5; padding: 22px 24px 18px;
      }
      .titlebar { display:flex; align-items:center; justify-content:space-between; margin-bottom: 14px; }
      .title { font-weight: 800; font-size: 18px; letter-spacing: .1px; }
      .desc  { opacity: .85; margin-bottom: 12px; white-space: pre-line; }

      .section { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,.08); }
      .section:first-of-type { margin-top: 0; padding-top: 0; border-top: none; }
      .section-title { display:flex; align-items:center; gap:8px; font-weight: 700; color:#dbe5f5; margin: 6px 0 8px; }
      .section-title::before { content:""; display:inline-block; width:14px; height:14px; border-radius:3px; background: linear-gradient(135deg, #3aa0ff, #8f77ff); }

      .row { display: grid; grid-template-columns: 160px 1fr; gap: 12px; align-items: center; margin: 8px 0; }
      .row > div:last-child { min-width: 0; }
      .label { opacity: .85; }

      .inputwrap { position: relative; display: flex; align-items: center; gap: 8px; }
      input[type="text"]{
        all: initial; background:#2a2f36; color:#e9edf3; padding:10px 12px; border-radius:10px; border:1px solid transparent; outline:none;
        font:13px/1.2 inherit; width:100%; box-sizing:border-box; display:block;
      }
      input[type="text"]:focus{ border-color:#FF2F00; }

      /* 히스토리 드롭다운 */
      .hist-panel {
        position: absolute; left: 0; right: 36px; top: calc(100% + 6px);
        background: #1f2329; border: 1px solid #2f3540; border-radius: 12px; box-shadow: 0 16px 40px rgba(0,0,0,.45);
        padding: 8px; z-index: 5; display: none;
      }
      .hist-panel.show { display: block; }
      .hist-item { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:8px 10px; border-radius:10px; cursor:pointer; }
      .hist-item:hover { background:#2a2f36; }
      .hist-text { pointer-events:none; }
      .hist-del { all:initial; color:#9aa3ad; cursor:pointer; padding:2px 4px; border-radius:6px; }
      .hist-del:hover { background:#2a2f36; color:#e9edf3; }
      .hist-footer { display:flex; justify-content:flex-end; padding-top:6px; border-top:1px solid #2a2f36; margin-top:6px; }
      .hist-clear { all:initial; cursor:pointer; padding:6px 10px; border-radius:999px; background:#2a2f36; color:#e9edf3; font-size:12px; }
      .hist-clear:hover { background:#343a43; }

      .iconbtn{ all: initial; cursor:pointer; width:36px; height:36px; display:grid; place-items:center; border-radius:10px; background:#2a2f36; color:#e9edf3; user-select:none; }
      .iconbtn:hover{ background:#343a43; }

      .picker{ all: initial; position:absolute; right:0; top:calc(100% + 8px); background:#22262c; color:#e9edf3; border:1px solid #2f3540; border-radius:12px; box-shadow:0 16px 50px rgba(0,0,0,.45); padding:12px; z-index:4; min-width: 440px; font-family: inherit; text-rendering: inherit; }
      .picker[hidden]{ display:none !important; }
      .pick-row{ display:flex; align-items:center; gap:10px; margin-top:8px; flex-wrap:wrap; }
      .seg{ background:#2a2f36; border-radius:10px; padding:6px 10px; font-size:12px; }

      select{ all: initial; background:#2a2f36; color:#e9edf3; padding:8px 10px; border-radius:10px; border:1px solid transparent; outline:none; font:13px/1.2 inherit; }
      select:focus{ border-color:#FF2F00; }

      input[type=number]::-webkit-outer-spin-button,
      input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
      input[type=number] { -moz-appearance: textfield; }

      .numbox { display:flex; align-items:center; background:transparent; }
      .num{ all: initial; background:#2a2f36; color:#e9edf3; padding:8px 8px; border-radius:10px; border:1px solid transparent; outline:none; width:54px; text-align:center; font:13px/1.2 inherit; }
      .num:focus{ border-color:#FF2F00; }
      .steppers { display:flex; flex-direction:column; gap:2px; margin-left:4px; }
      .step { all: initial; cursor:pointer; width:18px; height:16px; display:grid; place-items:center; border-radius:6px; background:#2a2f36; color:#e9edf3; font-size:10px; line-height:1; }
      .step:hover { background:#343a43; }
      .colon { opacity:.8; margin: 0 2px; }

      .pillbar, .tz, .hint { margin-left: 172px; }
      .pillbar { display:flex; gap:6px; margin-top: 12px; margin-bottom: 10px; flex-wrap:wrap; }
      .pill { all: initial; cursor:pointer; padding:6px 10px; border-radius:999px; background:#2a2f36; color:#e9edf3; font-size:12px; }
      .pill:hover { background:#343a43; }
      .pill.primary { background:#048BFF; color:#fff; }
      .pill.primary:hover { background:#048BFF; color:#fff; }

      .tz { font-size:12px; opacity:.8; margin-top: 14px; }
      .hint { font-size:12px; opacity:.75; margin-top:6px; }

      .actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
      .btn { all: initial; cursor: pointer; padding: 8px 12px; border-radius: 10px; background: #2a2f36; color: #e9edf3; }
      .btn.primary { background:#048BFF; color:#fff; }
    `;

    const container = document.createElement('div'); container.className = 'backdrop';
    const card = document.createElement('div'); card.className = 'card';
    card.setAttribute('role', 'dialog');
    card.setAttribute('aria-modal', 'true');
    card.setAttribute('aria-label', '특정 시간으로 이동하기');

    card.innerHTML = `
      <div class="titlebar"><div class="title" id="wv-jump-title">특정 시간으로 이동하기</div></div>
      <div class="desc" id="wv-jump-desc">시간을 입력/붙여넣기 하세요. (Enter=확인, ESC=닫기)</div>

      <div class="section" aria-labelledby="wv-jump-title">
        <div class="section-title">방송 정보</div>
        <div class="row"><div class="label">방송 시작 시간</div><div id="start-label">${startStr}</div></div>
        <div class="row" style="margin-bottom:8px;"><div class="label">방송 종료 시간</div><div id="end-label">${endStr}</div></div>
      </div>

      <div class="section" style="margin-top:16px;">
        <div class="section-title">이동 설정</div>
        <div class="row">
          <div class="label">이동할 시간 입력</div>
          <div>
            <div class="inputwrap">
              <input id="dt" type="text" aria-describedby="wv-jump-desc" placeholder="${placeholderHint}" autocomplete="off" autocapitalize="off" spellcheck="false">
              <!-- 히스토리 드롭다운 -->
              <div id="hist" class="hist-panel" role="listbox" aria-label="최근 입력 기록"></div>

              <!-- 달력 버튼/피커 -->
              <button id="openPicker" class="iconbtn" title="날짜/시간 선택" aria-label="날짜/시간 선택">📅</button>
              <div id="picker" class="picker" hidden>
                <div class="seg">방송 날짜 선택(해당 방송이 진행된 일자 중 선택 가능)</div>
                <div class="pick-row">
                  <div class="numbox">
                    <select id="daySel"></select>
                    <div class="steppers" style="margin-left:6px;">
                      <button class="step" id="dayUp"   title="다음 날짜">▲</button>
                      <button class="step" id="dayDown" title="이전 날짜">▼</button>
                    </div>
                  </div>
                </div>

                <div class="seg" style="margin-top:8px;">시/분/초 입력</div>
                <div class="pick-row" id="hmsRow">
                  <div class="numbox">
                    <input id="hh" class="num" type="number" min="0" max="23" step="1" placeholder="HH" aria-label="시(0-23)" inputmode="numeric">
                    <div class="steppers">
                      <button class="step" data-target="hh" data-delta="+1">▲</button>
                      <button class="step" data-target="hh" data-delta="-1">▼</button>
                    </div>
                  </div>
                  <span class="colon">:</span>
                  <div class="numbox">
                    <input id="mm" class="num" type="number" min="0" max="59" step="1" placeholder="MM" aria-label="분(0-59)" inputmode="numeric">
                    <div class="steppers">
                      <button class="step" data-target="mm" data-delta="+1">▲</button>
                      <button class="step" data-target="mm" data-delta="-1">▼</button>
                    </div>
                  </div>
                  <span class="colon">:</span>
                  <div class="numbox">
                    <input id="ss" class="num" type="number" min="0" max="59" step="1" placeholder="SS" aria-label="초(0-59)" inputmode="numeric">
                    <div class="steppers">
                      <button class="step" data-target="ss" data-delta="+1">▲</button>
                      <button class="step" data-target="ss" data-delta="-1">▼</button>
                    </div>
                  </div>
                </div>

                <div class="pick-row">
                  <div class="pillbar">
                    <button class="pill primary" id="pkApply">적용</button>
                    <button class="pill" id="pkCancel">닫기</button>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>

        <div class="pillbar">
          <button class="pill" id="useNow">현재 화면 시간 적용</button>
          <button class="pill" data-bump="-60">-60s</button>
          <button class="pill" data-bump="-30">-30s</button>
          <button class="pill" data-bump="+30">+30s</button>
          <button class="pill" data-bump="+60">+60s</button>
          <button class="pill" id="copyShare">URL 복사</button>
        </div>

        <div class="tz">표시 타임존: ${userTZ}</div>
        <div class="hint" id="hint-now"></div>
      </div>

      <div class="actions">
        <button class="btn primary" id="ok">확인</button>
        <button class="btn" id="cancel">닫기</button>
      </div>
    `;

    const dt       = card.querySelector('#dt');
    const histBox  = card.querySelector('#hist');
    const picker   = card.querySelector('#picker');
    const openBtn  = card.querySelector('#openPicker');
    const pkCancel = card.querySelector('#pkCancel');
    const pkApply  = card.querySelector('#pkApply');
    const daySel   = card.querySelector('#daySel');
    const dayUp    = card.querySelector('#dayUp');
    const dayDown  = card.querySelector('#dayDown');
    const hhInp    = card.querySelector('#hh');
    const mmInp    = card.querySelector('#mm');
    const ssInp    = card.querySelector('#ss');

    // ---------- History dropdown ----------
    function renderHistory() {
      const list = loadHistory();
      if (!list.length) { histBox.innerHTML = ''; return; }
      histBox.innerHTML = `
        ${list.map((v,i)=>`
          <div class="hist-item" role="option" data-index="${i}">
            <div class="hist-text">${v}</div>
            <button class="hist-del" title="삭제" aria-label="삭제" data-del="${i}">×</button>
          </div>`).join('')}
        <div class="hist-footer"><button class="hist-clear">전체 삭제</button></div>
      `;
      // 개별 선택
      histBox.querySelectorAll('.hist-item').forEach(el=>{
        el.addEventListener('click', (e)=>{
          const idx = Number(el.getAttribute('data-index'));
          const item = loadHistory()[idx];
          if (!item) return;
          dt.value = item;
          dt.focus(); dt.select();
          histBox.classList.remove('show');
        });
      });
      // 개별 삭제
      histBox.querySelectorAll('.hist-del').forEach(btn=>{
        btn.addEventListener('click',(e)=>{
          e.stopPropagation();
          const idx = Number(btn.getAttribute('data-del'));
          const list = loadHistory();
          list.splice(idx,1);
          saveHistory(list);
          renderHistory();
        });
      });
      // 전체 삭제
      const clearBtn = histBox.querySelector('.hist-clear');
      if (clearBtn) clearBtn.addEventListener('click', ()=> { clearHistory(); renderHistory(); });
    }
    function showHistory() { renderHistory(); if (loadHistory().length) histBox.classList.add('show'); }
    function hideHistory() { histBox.classList.remove('show'); }

    dt.addEventListener('focus', showHistory);
    dt.addEventListener('input', showHistory);
    // 입력란 밖 클릭 시 닫기 (피커/드롭다운 포함 예외 처리)
    root.addEventListener('click', (e)=>{
      const path = e.composedPath();
      if (!path.includes(histBox) && !path.includes(dt)) hideHistory();
    });

    // ---------- Number strict (overwrite on type) ----------
    function bindStrictTwoDigit(input, max) {
      const setOverwrite = on => input.dataset.overwrite = on ? '1':'0';
      setOverwrite(true);

      const clamp = v => {
        if (v === '') return '';
        let n = parseInt(v,10); if (isNaN(n)) n = 0;
        if (n > max) n = max; if (n < 0) n = 0; return String(n);
      };
      const coerce = () => {
        let raw = (input.value||'').replace(/\D/g,'');
        if (raw.length>2) raw = raw.slice(-2);
        raw = clamp(raw);
        input.value = raw === '' ? '' : String(parseInt(raw,10));
      };

      input.addEventListener('focus', ()=>{ try{input.select();}catch{} setOverwrite(true); });
      input.addEventListener('mousedown', ()=> setOverwrite(true));

      input.addEventListener('keydown', (e)=>{
        const edit = ['Backspace','Delete','ArrowLeft','ArrowRight','Tab','Home','End'];
        if (edit.includes(e.key)) return;
        if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
          e.preventDefault();
          let cur = parseInt(input.value,10); if (isNaN(cur)) cur = 0;
          const delta = (e.key === 'ArrowUp') ? +1 : -1;
          const mod = max + 1;
          const next = ((cur + delta) % mod + mod) % mod;
          input.value = String(next);
          input.dispatchEvent(new Event('input'));
          setOverwrite(true);
          return;
        }
        if (e.key.length===1 && !/\d/.test(e.key)) { e.preventDefault(); return; }
        // 숫자 입력
        if (input.dataset.overwrite === '1') { input.value = ''; setOverwrite(false); }
        e.preventDefault();
        const cur = (input.value||'').replace(/\D/g,'');
        let next = (cur + e.key).slice(-2);
        next = clamp(next);
        input.value = next;
        input.dispatchEvent(new Event('input'));
      });
      input.addEventListener('input', coerce);
      input.addEventListener('paste', (e)=>{
        const t = (e.clipboardData||window.clipboardData)?.getData('text')||'';
        const d = t.replace(/\D/g,''); e.preventDefault();
        if (!d) return;
        let v = d.slice(-2); v = clamp(v); input.value = v;
        input.dispatchEvent(new Event('input')); setOverwrite(false);
      });
      input.addEventListener('blur', ()=>{
        let v = (input.value||'').replace(/\D/g,''); if (v==='') return;
        v = clamp(v); input.value = String(parseInt(v,10)).padStart(2,'0'); setOverwrite(true);
      });
      input.addEventListener('wheel', (e)=>{
        if (document.activeElement !== input) return;
        e.preventDefault();
        let cur = parseInt(input.value,10); if (isNaN(cur)) cur = 0;
        const delta = e.deltaY < 0 ? +1 : -1;
        const mod = max + 1;
        const next = ((cur + delta) % mod + mod) % mod;
        input.value = String(next);
        input.dispatchEvent(new Event('input'));
        setOverwrite(true);
      }, {passive:false});
    }
    bindStrictTwoDigit(hhInp,23);
    bindStrictTwoDigit(mmInp,59);
    bindStrictTwoDigit(ssInp,59);

    // 시/분/초 ▲▼ 버튼
    function stepWrap(input, max, delta) {
      let cur = parseInt(input.value,10); if (isNaN(cur)) cur = 0;
      const mod = max + 1;
      const next = ((cur + delta) % mod + mod) % mod;
      input.value = String(next);
      input.dispatchEvent(new Event('input'));
    }
    card.querySelectorAll('.step[data-target]').forEach(btn=>{
      const id = btn.getAttribute('data-target');
      const delta = btn.getAttribute('data-delta') === '+1' ? +1 : -1;
      const max = id === 'hh' ? 23 : 59;
      const input = card.querySelector('#'+id);
      btn.addEventListener('click', ()=> stepWrap(input,max,delta));
    });

    // 날짜 옵션 생성
    const toYMD = (date) => {
      const f = new Intl.DateTimeFormat('en-CA',{timeZone:userTZ,year:'numeric',month:'2-digit',day:'2-digit'});
      const p = f.formatToParts(date);
      return `${p.find(v=>v.type==='year').value}-${p.find(v=>v.type==='month').value}-${p.find(v=>v.type==='day').value}`;
    };
    const daysComp = listDaysInRange(startTime, endTime);
    daySel.innerHTML = '';
    for (const d of daysComp) {
      const ymd = `${d.y}-${p2(d.M)}-${p2(d.d)}`;
      const opt = document.createElement('option');
      opt.value = ymd; opt.textContent = ymd;
      daySel.appendChild(opt);
    }
    const curElForDay = getCurrentTimeEl();
    const secNow = curElForDay ? parseHMSFlexible(curElForDay.textContent) : 0;
    const liveNow = startTime ? new Date(startTime.getTime() + secNow*1000) : new Date();
    const liveDateStr = toYMD(liveNow);
    const optsArr = Array.prototype.slice.call(daySel.options || []);
    daySel.value = (optsArr.find(o=>o.value===liveDateStr)?.value) || (optsArr[0]?.value || '');

    // 날짜 ▲/▼
    const stepDay = (delta) => {
      const opts = daySel.options; const len = opts.length; if (!len) return;
      let idx = daySel.selectedIndex; if (idx<0) idx=0;
      idx = ((idx + delta) % len + len) % len;
      daySel.selectedIndex = idx; daySel.dispatchEvent(new Event('change'));
    };
    dayUp.addEventListener('click',   ()=> stepDay(+1));
    dayDown.addEventListener('click', ()=> stepDay(-1));

    // 피커 토글
    const togglePicker = (show) => { if (show) picker.removeAttribute('hidden'); else picker.setAttribute('hidden',''); };
    togglePicker(false);
    openBtn.addEventListener('click', (e)=>{ e.stopPropagation(); togglePicker(picker.hasAttribute('hidden')); });
    pkCancel.addEventListener('click', ()=> togglePicker(false));
    container.addEventListener('click', (e) => {
      const path = e.composedPath();
      if (!path.includes(card)) { jumpModalHost.style.display = 'none'; }
    });

    // 피커 적용
    pkApply.addEventListener('click', ()=> {
      const h = hhInp.value === '' ? NaN : +hhInp.value;
      const Mins = mmInp.value === '' ? NaN : +mmInp.value;
      const Secs = ssInp.value === '' ? NaN : +ssInp.value;
      if ([h,Mins,Secs].some(v=>Number.isNaN(v))) return showToastMessage('시/분/초를 입력하세요.', true);
      if (h<0||h>23||Mins<0||Mins>59||Secs<0||Secs>59) return showToastMessage('시/분/초 범위를 확인하세요.', true);

      const baseDate = daySel.value;
      const comp = { y:+baseDate.slice(0,4), M:+baseDate.slice(5,7), d:+baseDate.slice(8,10), h, m:Mins, s:Secs };
      const ms = zonedComponentsToUTCms(comp, userTZ);
      const target = new Date(ms);
      if (target < startTime || target > endTime) return showToastMessage('방송 시간 범위를 벗어났습니다.', true);
      dt.value = fmtDate(target);
      dt.focus(); dt.select();
      togglePicker(false);
      hideHistory(); // 충돌 방지
    });

    // 힌트
    const refreshHint = () => {
      const curEl = getCurrentTimeEl();
      const sNow = curEl ? parseHMSFlexible(curEl.textContent) : 0;
      const live = startTime ? new Date(startTime.getTime() + sNow*1000) : new Date();
      card.querySelector('#hint-now').textContent = `현재 장면(내 타임존): ${fmtDate(live)}`;
      card.querySelector('#start-label').textContent = fmtDate(startTime);
      card.querySelector('#end-label').textContent   = fmtDate(endTime);
    };
    refreshHint();

    // 입력/붙여넣기 파싱
    function applyParsedFromText(text) {
      const parsed = parseInputToTarget(text);
      if (!parsed) return false;
      const target = new Date(zonedComponentsToUTCms(parsed.comp, userTZ));
      if (target < startTime || target > endTime) return false;
      dt.value = fmtDate(target);
      dt.focus(); dt.select();
      return true;
    }
    dt.addEventListener('paste', (e) => {
      const text = (e.clipboardData || window.clipboardData)?.getData('text');
      if (!text) return;
      if (applyParsedFromText(text)) e.preventDefault();
    });
    dt.addEventListener('change', () => { if (dt.value) applyParsedFromText(dt.value); });

    // 현재 화면 시간/±초/공유
    card.querySelector('#useNow').addEventListener('click', () => {
      const curEl = getCurrentTimeEl();
      const sNow = curEl ? parseHMSFlexible(curEl.textContent) : 0;
      const live = startTime ? new Date(startTime.getTime() + sNow*1000) : new Date();
      dt.value = fmtDate(live);
      dt.focus(); dt.select();
      refreshHint();
      hideHistory();
    });
    card.querySelectorAll('.pill[data-bump]').forEach(btn=>{
      btn.addEventListener('click', ()=>{
        if (!dt.value) return;
        const parsed = parseInputToTarget(dt.value); if (!parsed?.comp) return;
        const base = new Date(zonedComponentsToUTCms(parsed.comp, userTZ));
        const bumped = new Date(base.getTime() + Number(btn.getAttribute('data-bump'))*1000);
        dt.value = fmtDate(bumped);
        dt.focus(); dt.select();
        hideHistory();
      });
    });
    card.querySelector('#copyShare').addEventListener('click', () => {
      if (!dt.value || !startTime) return showToastMessage('시간을 먼저 지정하세요.', true);
      const parsed = parseInputToTarget(dt.value);
      if (!parsed?.comp) return showToastMessage('형식이 올바르지 않습니다.', true);
      const target = new Date(zonedComponentsToUTCms(parsed.comp, userTZ));
      if (target < startTime || target > endTime) return showToastMessage('방송 시간 범위를 벗어났습니다.', true);
      const diffSec = Math.floor((target - startTime)/1000);
      const url = new URL(location.href); url.searchParams.set('change_second', String(diffSec));
      navigator.clipboard.writeText(url.toString()).then(()=> showToastMessage('공유 링크 복사 완료')).catch(()=> showToastMessage('복사 실패', true));
    });

    // a11y: 포커스 트랩
    const focusables = card.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
    const firstF = focusables[0], lastF = focusables[focusables.length-1];
    (firstF || card).focus();
    card.addEventListener('keydown', (e)=>{
      if (e.key === 'Escape') { e.stopPropagation(); closeModal(); }
      if (e.key === 'Enter')  { e.stopPropagation(); card.querySelector('#ok').click(); }
      if (e.key === 'Tab') {
        if (e.shiftKey && document.activeElement === firstF) { e.preventDefault(); (lastF||firstF).focus(); }
        else if (!e.shiftKey && document.activeElement === lastF) { e.preventDefault(); (firstF||lastF).focus(); }
      }
    });

    function closeModal() {
      jumpModalHost.style.display = 'none';
      if (lastActiveEl && typeof lastActiveEl.focus === 'function') lastActiveEl.focus();
    }

    // 닫기/확인
    card.querySelector('#cancel').addEventListener('click', closeModal);
    card.querySelector('#ok').addEventListener('click', () => {
      if (!dt.value || !startTime) return showToastMessage('시간을 먼저 지정하세요.', true);
      const parsed = parseInputToTarget(dt.value);
      if (!parsed?.comp) return showToastMessage('형식이 올바르지 않습니다.', true);
      const target = new Date(zonedComponentsToUTCms(parsed.comp, userTZ));
      if (target < startTime || target > endTime) return showToastMessage('방송 시간 범위를 벗어났습니다.', true);
      // ★ 실제 이동 확정 시 히스토리에 저장
      addHistory(fmtDate(target));
      const diffSec = Math.floor((target - startTime)/1000);
      const url = new URL(location.href); url.searchParams.set('change_second', String(diffSec));
      window.location.replace(url.toString());
    });

    container.appendChild(card);
    root.append(style, container);
    jumpModalHost.style.display = 'block';
  }

  // ---------------- Top UI & loop ----------------
  const upsertLiveUI = () => {
    const ul = $(SELECTORS.infoUL);
    if (!ul) return {};
    try { ul.style.width = '180px'; } catch {}

    let liveSpan = document.getElementById('live-time-display');
    if (!liveSpan) {
      liveSpan = document.createElement('span');
      liveSpan.id = 'live-time-display';
      liveSpan.style.fontSize = '14px';
      liveSpan.style.lineHeight = '28px';
      liveSpan.style.cursor = 'pointer';
      liveSpan.title = '라이브 당시 시간 복사';
      liveSpan.addEventListener('click', () => {
        if (!currentLiveTimeStr) return;
        const doClipboard = () => navigator.clipboard.writeText(currentLiveTimeStr);
        const legacy = () => {
          const ta = document.createElement('textarea'); ta.value = currentLiveTimeStr;
          ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta);
          ta.select(); const ok = document.execCommand && document.execCommand('copy');
          document.body.removeChild(ta); return ok ? Promise.resolve() : Promise.reject();
        };
        (navigator.clipboard ? doClipboard() : legacy())
          .then(()=> showToastMessage(`복사 완료: ${currentLiveTimeStr}`))
          .catch(()=> showToastMessage('복사 실패', true));
      });
      ul.parentNode.insertBefore(liveSpan, ul);
    }

    let jumpBtn = document.getElementById('jump-button');
    if (!jumpBtn) {
      jumpBtn = document.createElement('button');
      jumpBtn.id = 'jump-button';
      jumpBtn.innerHTML = '<strong>⇋</strong>';
      Object.assign(jumpBtn.style, { marginLeft:'10px', color:'#FF2F00', background:'transparent', border:'none', cursor:'pointer', fontSize:'16px', lineHeight:'28px' });
      jumpBtn.title = '특정 시간으로 이동하기';
      jumpBtn.addEventListener('click', () => {
        if (!startTime || !endTime) return showToastMessage('방송 정보가 아직 준비되지 않았습니다.', true);
        openJumpModal(jumpBtn);
      });
      liveSpan.insertAdjacentElement('afterend', jumpBtn);
    }
    return {};
  };

  const maybeShowEditNotice = (durationEl) => {
    if (!startTime || !endTime || !durationEl) return;
    const totalDuration = parseHMSFlexible((durationEl.textContent||'').trim());
    const expected = Math.max(0, ((endTime - startTime)/1000) | 0);
    if (totalDuration + EDIT_THRESHOLD_SEC < expected) {
      let note = document.getElementById('edit-notice');
      if (!note) {
        note = document.createElement('strong');
        note.id = 'edit-notice';
        note.textContent = '[같이보기 진행 또는 편집된 영상일 수 있습니다.]';
        Object.assign(note.style, { fontSize:'14px', lineHeight:'28px', color:'#9196a1', marginRight:'10px' });
        const liveSpan = document.getElementById('live-time-display');
        if (liveSpan && liveSpan.parentNode) liveSpan.parentNode.insertBefore(note, liveSpan);
      }
    }
  };

  // 최적화된 업데이트 루프 + 옵저버 안전망
  let cachedCurrentEl = null;
  let lastCurrentText = '';
  let lastRendered = '';

  function refreshCurrentEl() {
    if (!cachedCurrentEl || !document.contains(cachedCurrentEl)) {
      cachedCurrentEl = getCurrentTimeEl();
      lastCurrentText = '';
      // 옵저버 재설치
      if (timeObserver) timeObserver.disconnect();
      if (cachedCurrentEl) {
        timeObserver = new MutationObserver(() => renderLiveTime(cachedCurrentEl));
        timeObserver.observe(cachedCurrentEl, { characterData:true, subtree:true, childList:true });
      }
    }
    return cachedCurrentEl;
  }
  function renderLiveTime(el) {
    const liveSpan = document.getElementById('live-time-display');
    if (!el || !liveSpan || !startTime) return;
    const txt = (el.textContent||'').trim();
    if (txt === lastCurrentText) return;
    lastCurrentText = txt;
    const sec = parseHMSFlexible(txt);
    const live = new Date(startTime.getTime() + sec*1000);
    const html = `<span style="color:#9196a1;">Live 당시 시간⠀</span><span style="color:#FF2F00;">${fmtDate(live)}</span>`;
    if (html !== lastRendered) {
      liveSpan.innerHTML = html;
      currentLiveTimeStr = fmtDate(live);
      lastRendered = html;
    }
  }
  function updateLoopStart() {
    if (updateTimer) clearInterval(updateTimer);
    updateTimer = setInterval(() => {
      const el = refreshCurrentEl();
      if (el) renderLiveTime(el);
    }, UPDATE_INTERVAL_MS);
  }

  // ---------------- Init / SPA handling ----------------
  const initOncePerRoute = async () => {
    const href = location.href;
    if (initDoneForHref === href) return;
    initDoneForHref = href;

    if (updateTimer) { clearInterval(updateTimer); updateTimer = null; }
    if (timeObserver) { timeObserver.disconnect(); timeObserver = null; }
    cachedCurrentEl = null; lastCurrentText=''; lastRendered='';

    let tipEl;
    try { tipEl = await waitFor(SELECTORS.startTimeTip, { timeout:15000, root:document }); }
    catch {
      tipEl = Array.from(document.querySelectorAll('span[tip]')).find(el => /방송시간/.test(el.getAttribute('tip')||''));
      if (!tipEl) return;
    }
    const times = parseTipTimes(tipEl.getAttribute('tip') || '');
    if (!times) return;
    startTime = times.start; endTime = times.end;

    upsertLiveUI();
    let durationEl = getDurationEl();
    if (!durationEl) { try { durationEl = await waitFor(DURATION_CANDIDATES.join(','), { timeout:10000 }); } catch {} }
    maybeShowEditNotice(durationEl);
    updateLoopStart();
  };

  const hookHistory = () => {
    if (routeObserver) return;
    ['pushState','replaceState'].forEach(fn => {
      const orig = history[fn];
      history[fn] = function(...args){ const ret = orig.apply(this, args); setTimeout(()=>initOncePerRoute(), 50); return ret; };
    });
    window.addEventListener('popstate', () => setTimeout(()=>initOncePerRoute(), 50));
    routeObserver = new MutationObserver(() => { if (location.href !== initDoneForHref) initOncePerRoute(); });
    routeObserver.observe(document.documentElement, { childList:true, subtree:true });
  };

  window.addEventListener('load', () => { hookHistory(); initOncePerRoute(); });
  document.addEventListener('visibilitychange', () => { if (!document.hidden) initOncePerRoute(); });
})();