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

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

目前為 2025-09-24 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         SOOP 다시보기 라이브 당시 시간 표시
// @namespace    http://tampermonkey.net/
// @version      5.2.1
// @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;

  // ⏱ 사이트 기준 타임존(방송시간 tip 파싱용)
  const SITE_TZ = 'Asia/Seoul';
  // 🔒 Live 라벨 고정폭(px)
  const LIVE_LABEL_WIDTH_PX = 240;
  // 📋 클립보드 읽기 캐시(ms)
  const CLIPBOARD_CACHE_MS = 1200;

  // ---------------- State ----------------
  let startTime = null, endTime = null;
  let currentLiveTimeStr = '';
  let updateTimer = null, routeObserver = null, initDoneForHref = null;
  let timeObserver = null;
  let lastActiveEl = null;

  // ARIA announcer(복사 때만 말하기)
  let liveAnnouncerEl = null;
  function ensureAnnouncer(){
    if (liveAnnouncerEl) return liveAnnouncerEl;
    liveAnnouncerEl = document.createElement('div');
    liveAnnouncerEl.setAttribute('aria-live','polite');
    liveAnnouncerEl.setAttribute('role','status');
    // 시각적으로 숨김
    Object.assign(liveAnnouncerEl.style, {
      position:'absolute', width:'1px', height:'1px', margin:'-1px', border:'0', padding:'0',
      clip:'rect(0 0 0 0)', overflow:'hidden'
    });
    document.body.appendChild(liveAnnouncerEl);
    return liveAnnouncerEl;
  }
  function announce(msg){
    const el = ensureAnnouncer();
    // 동일 문구도 다시 읽히도록 리셋
    el.textContent = '';
    setTimeout(()=> { el.textContent = String(msg ?? ''); }, 10);
  }

  // 클립보드 캐시
  let _clipCacheVal = '';
  let _clipCacheErr = false;
  let _clipCachedAt = 0;

  // ---------------- 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 esc = (s='') => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');

  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() {
    // 컨테이너 우선
    const wrap = getTimeWrap();
    if (wrap) {
      const scoped = pickFirst(CURRENT_TIME_CANDIDATES, wrap);
      if (scoped) return scoped;
    }
    // 폴백
    let el = pickFirst(CURRENT_TIME_CANDIDATES);
    if (el) return el;
    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;
  }

  // ---- duration: 컨테이너 한정 → 폴백 (보강)
  function getTimeWrap(){
    return document.querySelector(
      '#player .player_ctrlBox .ctrlBox .ctrl .time_display,' +
      '#player .time_display,' +
      '.player .time_display,' +
      '.time_display'
    );
  }
  function isTimeLikeText(t){ return /^\d{1,2}:\d{2}(?::\d{2})?$/.test((t||'').trim()); }

  function getDurationElScoped(){
    const wrap = getTimeWrap();
    if (!wrap) return null;
    const nodes = Array.from(wrap.querySelectorAll('span,div,time')).filter(el => isTimeLikeText(el.textContent));
    // 명시 셀렉터 우선
    const explicit = nodes.find(n => n.matches('.time-duration,[aria-label="Duration"],[data-role="duration"]'));
    if (explicit) return explicit;
    // 현재시간 제외 후 마지막(보통 "현재 / 전체")
    const nonCurrent = nodes.filter(n => !n.matches('.time-current,[aria-label="Current time"],[data-role="current-time"]'));
    if (nonCurrent.length === 1) return nonCurrent[0];
    if (nonCurrent.length > 1) return nonCurrent[nonCurrent.length - 1];
    return null;
  }
  function getDurationElRobust(){
    const scoped = getDurationElScoped();
    if (scoped) return scoped;

    // 기본 후보
    const basic = pickFirst(DURATION_CANDIDATES);
    if (basic) return basic;

    // 최후 폴백: 전역 휴리스틱
    const all = Array.from(document.querySelectorAll('span,div,time')).filter(el => isTimeLikeText(el.textContent));
    const currentEl = getCurrentTimeEl();
    const curTxt = (currentEl?.textContent||'').trim();
    const scored = all
      .filter(el => el !== currentEl && (el.textContent||'').trim() !== curTxt)
      .map(el => {
        const t = (el.textContent||'').trim();
        let s = 0;
        if (/^\d{1,2}:\d{2}:\d{2}$/.test(t)) s += 3;
        const meta = ((el.className||'') + ' ' + (el.getAttribute('aria-label')||'') + ' ' + (el.getAttribute('data-role')||'')).toLowerCase();
        if (/duration|total|length/.test(meta)) s += 5;
        return { el, s };
      })
      .sort((a,b)=> b.s - a.s);
    return scored[0]?.el || null;
  }

  // ---------------- Parse helpers ----------------
  // ▶ tip의 'YYYY-MM-DD HH:mm:ss'를 사이트 기준 타임존(SITE_TZ)으로 해석해 UTC ms로 보정
  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 sComp = { y:+m[1],  M:+m[2],  d:+m[3],  h:+m[4],  m:+m[5],  s:+m[6]  };
    const eComp = { y:+m[7],  M:+m[8],  d:+m[9],  h:+m[10], m:+m[11], s:+m[12] };
    const sMs = zonedComponentsToUTCms(sComp, SITE_TZ);
    const eMs = zonedComponentsToUTCms(eComp, SITE_TZ);
    return { start: new Date(sMs), end: new Date(eMs) };
  };

  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;

  // 공유 URL 파라미터 중복 방지
  function setChangeSecondParam(url, sec){
    // 혹시 모를 유사 키도 정리
    ['change_second','t','time','sec'].forEach(k => url.searchParams.delete(k));
    url.searchParams.set('change_second', String(sec));
    return url;
  }

  // 클립보드 읽기(1.2초 캐시)
  async function readClipboardCached(){
    const now = Date.now();
    if (now - _clipCachedAt < CLIPBOARD_CACHE_MS) {
      return { text:_clipCacheVal, error:_clipCacheErr };
    }
    try {
      const text = (await navigator.clipboard.readText()) || '';
      _clipCacheVal = text; _clipCacheErr = false; _clipCachedAt = now;
      return { text, error:false };
    } catch {
      _clipCacheVal = ''; _clipCacheErr = true; _clipCachedAt = now;
      return { text:'', error:true };
    }
  }

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

    const startStr = fmtDate(startTime);
    const endStr   = fmtDate(endTime);
    const durEl = getDurationElRobust();
    const totalDuration = durEl ? parseHMSFlexible((durEl.textContent || '').trim()) : null;
    const expectedSec = Math.max(0, ((endTime - startTime)/1000) | 0);
    const isEditedLike = (totalDuration != null) && (totalDuration + EDIT_THRESHOLD_SEC < expectedSec);

    const editedBadgeHTML = isEditedLike
      ? `<span id="edit-badge" style="margin-left:90px;font-size:12px;color:#ffc107;background:#2a2f36;padding:2px 8px;border-radius:999px;">해당 다시보기는 시네티 같이보기 진행 또는 편집된 영상일 수 있습니다.</span>`
      : '';

    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, Arial, 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:#048BFF; }

      .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; gap:8px; padding:8px 10px; border-radius:10px; cursor:pointer; }
      .hist-item:hover { background:#2a2f36; }
      .hist-ico { opacity:.9; }
      .hist-text { flex:1; pointer-events:none; }
      .ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
      .hist-del { all:initial; color:#9aa3ad; cursor:pointer; padding:2px 6px; border-radius:6px; margin-left:2px; }
      .hist-del:hover { background:#2a2f36; color:#e9edf3; }
      .divider { height:1px; background:#2a2f36; margin:6px 4px; }
      .hist-caption { font-size:11px; opacity:.7; padding:0 10px 4px; }

      /* 현재 클립보드 칩 */
      .hist-panel .hist-item[data-clip]{
        background:#2a2f36; border:1px solid #2a2f36;
        border-radius:9999px;
        margin-left: 8px;
        padding:4px 10px;
        line-height:14px;
        font-size:12px;
      }
      .hist-panel .hist-item[data-clip]:hover{ background:#343a43; border-color:#4b5563; }
      .hist-panel .hist-item[data-clip] .hist-ico{ font-size:12px; opacity:.9; }
      .hist-panel .hist-item[data-clip] .hist-text{
        color:#ffc107; font-weight:300;
        overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
        font-family: "Pretendard", -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Malgun Gothic", "맑은 고딕", Helvetica, Arial, sans-serif;
      }
      /* 권한 실패 시 빨간 글자 */
      .hist-panel .hist-item[data-clip].error .hist-text{ color:#ff4d4f; }

      /* 둥근모서리 칩 모양 */
      .hist-chip{ background:#2a2f36; border:1px solid #3b414c; border-radius:10px; padding:8px 10px; margin-left: 8px;}
      .hist-chip:hover{ background:#343a43; }
      .hist-chip .hist-ico{ opacity:.9; }
      .hist-chip .hist-text{
        flex:1; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
        font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size:12px;
      }

      .hist-footer { display:flex; justify-content:flex-end; padding-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:#048BFF; }

      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:#048BFF; }
      .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">방송 정보 ${editedBadgeHTML}</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 primary" 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>
          <button class="pill primary" id="copyInput">입력한 시간 복사</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 ----------
    async function renderHistory() {
      const list = loadHistory();
      const { text:clip, error:clipError } = await readClipboardCached();

      const hasHist = list.length > 0;
      const hasClip = !!clip;
      if (!hasHist && !hasClip && !clipError) { histBox.innerHTML = ''; return; }

      const histHTML = hasHist ? list.map((v,i)=>`
        <div class="hist-item" role="option" data-index="${i}">
          <span class="hist-ico">↺</span>
          <div class="hist-text ellipsis" title="${esc(v)}">${esc(v)}</div>
          <button class="hist-del" title="삭제" aria-label="삭제" data-del="${i}">×</button>
        </div>`).join('') : '';

      const clipSectionTitle = (hasClip || clipError) ? `<div class="divider"></div><div class="hist-caption">현재 클립보드</div>` : '';

      const clipItem = hasClip ? `
        <div class="hist-item" data-clip="1" title="${esc(clip)}">
          <span class="hist-ico">📋</span>
          <div class="hist-text ellipsis">${esc(clip)}</div>
        </div>` : (clipError ? `
        <div class="hist-item error" data-clip="1" title="클립보드 읽기 권한이 없습니다.">
          <span class="hist-ico">📋</span>
          <div class="hist-text ellipsis">클립보드 읽기 권한이 없습니다.</div>
        </div>` : '');

      const footerHTML = `
        <div class="divider"></div>
        <div class="hist-footer"><button class="hist-clear">히스토리 삭제</button></div>
      `;

      histBox.innerHTML = `${histHTML}${clipSectionTitle}${clipItem}${footerHTML}`;

      histBox.querySelectorAll('.hist-item[data-index]').forEach(el=>{
        el.addEventListener('click', ()=>{
          const idx = Number(el.getAttribute('data-index'));
          const item = loadHistory()[idx];
          if (!item) return;
          dt.value = item;
          hideHistory();
        });
      });

      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 clipEl = histBox.querySelector('.hist-item[data-clip]');
      if (clipEl && !clipEl.classList.contains('error')) {
        clipEl.addEventListener('click', ()=>{
          dt.value = clip;
          hideHistory();
        });
      }

      const clearBtn = histBox.querySelector('.hist-clear');
      if (clearBtn) clearBtn.addEventListener('click', ()=> { clearHistory(); renderHistory(); });
    }
    async function showHistory() {
      await renderHistory();
      if (loadHistory().length || (await readClipboardCached()).text || histBox.querySelector('.hist-item.error')) histBox.classList.add('show');
      else histBox.classList.remove('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();
    });

    function bindStrictTwoDigit(input, max) {
      if (!input) return;
      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; }

        const curDigitsLen = ((input.value||'').replace(/\D/g,'')).length;
        if (input.dataset.overwrite === '1' || curDigitsLen >= 2) {
          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) {
      if (!input) return;
      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'));
      input.dataset.overwrite = '1';
    }
    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 modalCurrentEl = getCurrentTimeEl(); // ☑ 모달 내 1회만 캡처
    const secNow = modalCurrentEl ? parseHMSFlexible(modalCurrentEl.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)) { closeModal(); }
    });

    pkApply.addEventListener('click', ()=> {
      const h = hhInp && hhInp.value === '' ? NaN : +(hhInp?.value ?? NaN);
      const Mins = mmInp && mmInp.value === '' ? NaN : +(mmInp?.value ?? NaN);
      const Secs = ssInp && ssInp.value === '' ? NaN : +(ssInp?.value ?? NaN);
      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 readSceneSec = () => modalCurrentEl ? parseHMSFlexible(modalCurrentEl.textContent) : 0;
    const refreshHint = () => {
      const sNow = readSceneSec();
      const live = startTime ? new Date(startTime.getTime() + sNow*1000) : new Date();
      card.querySelector('#hint-now').textContent = `현재 장면(내 타임존): ${fmtDate(live)}`;
    };
    refreshHint();
    let hintObs = null, hintTimer = null;
    function startHintAutoUpdate(){
      if (modalCurrentEl) {
        hintObs = new MutationObserver(() => refreshHint());
        hintObs.observe(modalCurrentEl, { childList:true, characterData:true, subtree:true });
      }
      if (!hintTimer) hintTimer = setInterval(refreshHint, UPDATE_INTERVAL_MS);
    }
    startHintAutoUpdate();

    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 sNow2 = readSceneSec();
      const live2 = startTime ? new Date(startTime.getTime() + sNow2*1000) : new Date();
      dt.value = fmtDate(live2);
      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);
        hideHistory();
      });
    });
    card.querySelector('#copyInput').addEventListener('click', () => {
      if (!dt.value) { showToastMessage('시간을 먼저 지정하세요.', true); announce('시간을 먼저 지정하세요.'); return; }
      const text = dt.value;
      (navigator.clipboard ? navigator.clipboard.writeText(text) : Promise.reject())
        .then(()=> { showToastMessage('입력한 시간 복사 완료'); announce('입력한 시간 복사 완료'); })
        .catch(()=> { showToastMessage('복사 실패', true); announce('복사 실패'); });
    });
    card.querySelector('#copyShare').addEventListener('click', () => {
      if (!dt.value || !startTime) { showToastMessage('시간을 먼저 지정하세요.', true); announce('시간을 먼저 지정하세요.'); return; }
      const parsed = parseInputToTarget(dt.value);
      if (!parsed?.comp) { showToastMessage('형식이 올바르지 않습니다.', true); announce('형식이 올바르지 않습니다.'); return; }
      const target = new Date(zonedComponentsToUTCms(parsed.comp, userTZ));
      if (target < startTime || target > endTime) { showToastMessage('방송 시간 범위를 벗어났습니다.', true); announce('방송 시간 범위를 벗어났습니다.'); return; }
      const diffSec = Math.floor((target - startTime)/1000);
      const url = setChangeSecondParam(new URL(location.href), diffSec);
      navigator.clipboard.writeText(url.toString())
        .then(()=> { showToastMessage('공유 링크 복사 완료'); announce('공유 링크 복사 완료'); })
        .catch(()=> { showToastMessage('복사 실패', true); announce('복사 실패'); });
    });

    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() {
      if (hintObs) { try{ hintObs.disconnect(); }catch{} hintObs = null; }
      if (hintTimer) { clearInterval(hintTimer); hintTimer = null; }
      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) { showToastMessage('시간을 먼저 지정하세요.', true); announce('시간을 먼저 지정하세요.'); return; }
      const parsed = parseInputToTarget(dt.value);
      if (!parsed?.comp) { showToastMessage('형식이 올바르지 않습니다.', true); announce('형식이 올바르지 않습니다.'); return; }
      const target = new Date(zonedComponentsToUTCms(parsed.comp, userTZ));
      if (target < startTime || target > endTime) { showToastMessage('방송 시간 범위를 벗어났습니다.', true); announce('방송 시간 범위를 벗어났습니다.'); return; }
      addHistory(fmtDate(target));
      const diffSec = Math.floor((target - startTime)/1000);
      const url = setChangeSecondParam(new URL(location.href), 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 {};

    const parent = ul.parentElement;
    if (parent) {
      Object.assign(parent.style, {
        display: 'flex',
        alignItems: 'center',
        gap: '8px',
        flexWrap: 'wrap',
      });
    }

    // 오른쪽 그리드(안내문/Live | 조회수·날짜)
    let rightWrap = document.getElementById('live-right-wrap');
    if (!rightWrap) {
      rightWrap = document.createElement('div');
      rightWrap.id = 'live-right-wrap';
      Object.assign(rightWrap.style, {
        display: 'grid',
        gridTemplateColumns: 'minmax(0,1fr) auto', // ← 첫 칸(라이브 라벨)이 늘어날 수 있게
        columnGap: '8px',
        rowGap: '0px',
        justifyContent: 'end',
        alignItems: 'center',
        marginLeft: 'auto',
        minWidth: '0'
      });
      parent.insertBefore(rightWrap, ul);
    }

    // Live + ⇋
    let liveRow = document.getElementById('live-row');
    if (!liveRow) {
      liveRow = document.createElement('div');
      liveRow.id = 'live-row';
      Object.assign(liveRow.style, {
        display: 'flex',
        alignItems: 'center',
        gap: '10px',
        minWidth: '0',
        gridColumn: '1',
        gridRow: '2'
      });
      rightWrap.appendChild(liveRow);
    } else if (liveRow.parentElement !== rightWrap) {
      rightWrap.appendChild(liveRow);
    }

    // Live 텍스트 (고정폭) — 자동 읽기 해제(ARIA live 미사용)
    let liveSpan = document.getElementById('live-time-display');
    if (!liveSpan) {
      liveSpan = document.createElement('span');
      liveSpan.id = 'live-time-display';
      Object.assign(liveSpan.style, {
        fontSize: '14px',
        lineHeight: '28px',
        cursor: 'pointer',
        flex: '1 1 auto',
        width: 'auto',
        minWidth: '0',
        maxWidth: '100%',
        display: 'block',
        fontVariantNumeric: 'tabular-nums',
        fontFeatureSettings: '"tnum" 1, "lnum" 1',
        overflow: 'hidden',
        whiteSpace: 'nowrap',
        textOverflow: 'ellipsis',
      });
      liveSpan.setAttribute('aria-label','Live 당시 시간. 클릭 시 복사.');
      liveSpan.title = '클릭 시 시간 복사';
      liveSpan.addEventListener('click', () => {
        if (!currentLiveTimeStr) return;
        (navigator.clipboard
          ? navigator.clipboard.writeText(currentLiveTimeStr)
          : Promise.reject()
        ).then(()=>{
          showToastMessage(`복사 완료: ${currentLiveTimeStr}`);
          announce(`복사 완료. ${currentLiveTimeStr}`);
        }).catch(()=>{
          showToastMessage('복사 실패', true);
          announce('복사 실패');
        });
      });
      liveRow.appendChild(liveSpan);
    } else if (liveSpan.parentElement !== liveRow) {
      liveRow.appendChild(liveSpan);
    }

    // ⇋ 버튼 (간격 의도 유지: marginRight=10px)
    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: '0px',
        marginRight: '10px',
        color: '#FF2F00',
        background: 'transparent',
        border: 'none',
        cursor: 'pointer',
        fontSize: '16px',
        lineHeight: '28px',
        flex: '0 0 auto',
      });
      jumpBtn.title = '특정 시간으로 이동하기';
      jumpBtn.addEventListener('click', () => {
        if (!startTime || !endTime) return showToastMessage('방송 정보가 아직 준비되지 않았습니다.', true);
        openJumpModal(jumpBtn);
      });
      liveRow.appendChild(jumpBtn);
    } else if (jumpBtn.parentElement !== liveRow) {
      liveRow.appendChild(jumpBtn);
    }

    // 조회수/날짜 블록 위치
    Object.assign(ul.style, {
      marginLeft: '0',
      width: 'auto',
      gridColumn: '2',
      gridRow: '2',
      alignSelf: 'center'
    });
    if (ul.parentElement !== rightWrap) rightWrap.appendChild(ul);
    return {};
  };

  // 안내문구: rightWrap의 1행 배치
  function maybeShowEditNotice(durationEl) {
    if (!startTime || !endTime || !durationEl) return;

    const total = parseHMSFlexible((durationEl.textContent || '').trim());
    const expected = Math.max(0, ((endTime - startTime) / 1000) | 0);

    const rightWrap = document.getElementById('live-right-wrap');
    if (!rightWrap) return;

    let note = document.getElementById('edit-notice');

    if (total + EDIT_THRESHOLD_SEC < expected) {
      if (!note) {
        note = document.createElement('strong');
        note.id = 'edit-notice';
        note.textContent = '[같이보기 진행 또는 편집된 영상일 수 있습니다.]';
        Object.assign(note.style, {
          fontSize: '12px',
          lineHeight: '14px',
          color: '#9196a1',
          whiteSpace: 'nowrap',
          gridColumn: '1',
          gridRow: '1',
          alignSelf: 'center'
        });
      }
      if (note.parentElement !== rightWrap) rightWrap.appendChild(note);
    } else {
      if (note && note.parentElement) note.parentElement.removeChild(note);
    }
  }

  // 최적화된 업데이트 루프 + 옵저버 안전망(+ 탭 비활성화 절전)
  let cachedCurrentEl = null;
  let lastCurrentText = '';
  let lastRendered = '';

  function attachTimeObserver(el){
    if (timeObserver) try{ timeObserver.disconnect(); }catch{}
    timeObserver = new MutationObserver(() => renderLiveTime(el));
    timeObserver.observe(el, { characterData:true, subtree:true, childList:true });
  }

  function refreshCurrentEl() {
    if (!cachedCurrentEl || !document.contains(cachedCurrentEl)) {
      cachedCurrentEl = getCurrentTimeEl();
      lastCurrentText = '';
      if (!document.hidden && cachedCurrentEl) attachTimeObserver(cachedCurrentEl);
    }
    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 startRenderLoop() {
    if (updateTimer) clearInterval(updateTimer);
    if (document.hidden) return; // 절전: 숨김이면 루프 시작하지 않음
    updateTimer = setInterval(() => {
      const el = refreshCurrentEl();
      if (el) renderLiveTime(el);
    }, UPDATE_INTERVAL_MS);
  }
  function stopRenderLoop(){
    if (updateTimer) { clearInterval(updateTimer); updateTimer = null; }
    if (timeObserver) { try{ timeObserver.disconnect(); }catch{} timeObserver = null; }
  }

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

    stopRenderLoop();
    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();

    // duration 보강 탐색
    let durationEl = getDurationElRobust();
    if (!durationEl) { try { durationEl = await waitFor(DURATION_CANDIDATES.join(','), { timeout:10000 }); } catch {} }
    maybeShowEditNotice(durationEl);

    startRenderLoop();
  };

  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(); });
    // 🔽 부하 절감: body만, subtree:false
    routeObserver.observe(document.body || document.documentElement, { childList:true, subtree:false });
  };

  // 탭 가시성에 따라 루프/옵저버 일시정지/재개
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      stopRenderLoop();
    } else {
      // 복귀 시 한 번 갱신 + 루프 재시작
      refreshCurrentEl();
      const el = cachedCurrentEl || getCurrentTimeEl();
      if (el) renderLiveTime(el);
      startRenderLoop();
      // SPA 전환이 있었다면 재초기화
      initOncePerRoute();
    }
  });

  window.addEventListener('load', () => { hookHistory(); initOncePerRoute(); });
})();