您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
숲 라이브/다시보기 타임스탬프 복사 + 설정창(단축키/라이브 오프셋). GM 메뉴 또는 Ctrl+Shift+Y(Cmd+Shift+Y)로 설정창 열기. (기본 단축키: Y / 오프셋 기본: 0초)
// ==UserScript== // @name SOOP 타임 스탬프(라이브 & 다시보기) // @namespace http://tampermonkey.net/ // @version 2.0.0 // @description 숲 라이브/다시보기 타임스탬프 복사 + 설정창(단축키/라이브 오프셋). GM 메뉴 또는 Ctrl+Shift+Y(Cmd+Shift+Y)로 설정창 열기. (기본 단축키: Y / 오프셋 기본: 0초) // @author WakViewer // @match https://play.sooplive.co.kr/* // @match https://vod.sooplive.co.kr/player/* // @icon https://www.google.com/s2/favicons?sz=64&domain=www.sooplive.co.kr // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @run-at document-end // @license MIT // ==/UserScript== (function () { 'use strict'; // ===== 공통 상수 ===== const TOAST_MS = 1700; // ========= 저장 키/기본값 ========= const STORAGE_KEYS = { hotkey: 'soop_ts_hotkey', offset: 'soop_ts_live_offset', }; const DEFAULTS = { hotkey: { code: 'KeyY', ctrl: false, alt: false, shift: false, meta: false }, offset: 0, }; let settingsOpen = false; // ========= 유틸 ========= const $ = (sel, root = document) => root.querySelector(sel); const isMac = () => /Mac|iPhone|iPad|iPod/i.test(navigator.platform); function isTyping(e){ const t=e.target; return !!(t && t.closest('input, textarea, [contenteditable="true"], [role="textbox"]')); } const loadHotkey = () => Object.assign({}, DEFAULTS.hotkey, GM_getValue(STORAGE_KEYS.hotkey) || {}); const saveHotkey = (hk) => GM_setValue(STORAGE_KEYS.hotkey, hk); const loadOffset = () => { const v = GM_getValue(STORAGE_KEYS.offset); return Number.isFinite(v) ? v : DEFAULTS.offset; }; const saveOffset = (v) => GM_setValue(STORAGE_KEYS.offset, Math.max(0, parseInt(v,10) || 0)); // 키 라벨/매칭 const HUMAN_KEY_MAP = { 'KeyA':'A','KeyB':'B','KeyC':'C','KeyD':'D','KeyE':'E','KeyF':'F','KeyG':'G','KeyH':'H','KeyI':'I','KeyJ':'J','KeyK':'K','KeyL':'L','KeyM':'M','KeyN':'N','KeyO':'O','KeyP':'P','KeyQ':'Q','KeyR':'R','KeyS':'S','KeyT':'T','KeyU':'U','KeyV':'V','KeyW':'W','KeyX':'X','KeyY':'Y','KeyZ':'Z', 'Digit0':'0','Digit1':'1','Digit2':'2','Digit3':'3','Digit4':'4','Digit5':'5','Digit6':'6','Digit7':'7','Digit8':'8','Digit9':'9', 'Numpad0':'Num0','Numpad1':'Num1','Numpad2':'Num2','Numpad3':'Num3','Numpad4':'Num4','Numpad5':'Num5','Numpad6':'Num6','Numpad7':'Num7','Numpad8':'Num8','Numpad9':'Num9', 'NumpadAdd':'Num+','NumpadSubtract':'Num-','NumpadMultiply':'Num*','NumpadDivide':'Num/','NumpadEnter':'NumEnter','NumpadDecimal':'Num.', 'ArrowUp':'↑','ArrowDown':'↓','ArrowLeft':'←','ArrowRight':'→', 'Space':'Space','Enter':'Enter','Escape':'Esc','Backspace':'Backspace','Tab':'Tab', 'Minus':'-','Equal':'=','BracketLeft':'[','BracketRight':']','Backslash':'\\','Semicolon':';','Quote':'\'','Backquote':'`','Comma':',','Period':'.','Slash':'/', 'F1':'F1','F2':'F2','F3':'F3','F4':'F4','F5':'F5','F6':'F6','F7':'F7','F8':'F8','F9':'F9','F10':'F10','F11':'F11','F12':'F12' }; const codeToHumanKey=(c)=>HUMAN_KEY_MAP[c] || c; const hotkeyToLabel=(hk)=>[hk.ctrl&&'Ctrl',hk.alt&&'Alt',hk.shift&&'Shift',hk.meta&&(isMac()?'Cmd':'Meta'),codeToHumanKey(hk.code)].filter(Boolean).join(' + '); const matchHotkey=(e,hk)=> e.code===hk.code && !!e.ctrlKey===!!hk.ctrl && !!e.altKey===!!hk.alt && !!e.shiftKey===!!hk.shift && !!e.metaKey===!!hk.meta; // 현재 저장된 단축키 라벨을 즉시 계산 const currentHotkeyLabel = () => hotkeyToLabel(loadHotkey()); function parseHMS(str){ const p=String(str||'').trim().split(':').map(n=>parseInt(n,10)); if(p.some(isNaN))return 0; const [h=0,m=0,s=0]=(p.length===3)?p:[0,p[0]||0,p[1]||0]; return h*3600+m*60+s; } function secondsToHMS(x){ x=Math.max(0,Math.floor(x)); const h=String(Math.floor(x/3600)).padStart(2,'0'); const m=String(Math.floor((x%3600)/60)).padStart(2,'0'); const s=String(x%60).padStart(2,'0'); return `${h}:${m}:${s}`; } // 클립보드 유틸 async function copyText(text, toastFn){ try{ await navigator.clipboard.writeText(text); toastFn && toastFn(`복사 완료: ${text}`); }catch(err){ toastFn && toastFn('클립보드 복사 실패. 브라우저 권한을 확인해주세요.'); console.error('[SOOP TS] clipboard error:', err); } } // ========= 설정창 열기 (GM 메뉴 + 단축키) ========= GM_registerMenuCommand('설정 메뉴 열기 (Ctrl+Shift+Y / Cmd+Shift+Y)', openSettings); document.addEventListener('keydown', (e) => { if (e.repeat || e.isComposing || settingsOpen || isTyping(e)) return; const openKey = (e.ctrlKey && e.shiftKey && e.code==='KeyY') || (e.metaKey && e.shiftKey && e.code==='KeyY'); if (openKey) { e.preventDefault(); e.stopPropagation(); openSettings(); } }, true); function openSettings() { if (settingsOpen) return; settingsOpen = true; const currentHK = loadHotkey(); let pendingHotkey = { ...currentHK }; let pendingOffset = loadOffset(); let capturing = false; const overlay = document.createElement('div'); overlay.id='soop-ts-overlay'; overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483647;display:flex;align-items:center;justify-content:center;'; const modal = document.createElement('div'); modal.id='soop-ts-modal'; modal.setAttribute('role','dialog'); modal.setAttribute('aria-modal','true'); modal.style.cssText='--field-h:40px;width:min(720px,92vw);max-width:720px;background:#121318;color:#E9E9EF;border:1px solid #262833;border-radius:14px;box-shadow:0 12px 40px rgba(0,0,0,.5);font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,"Noto Sans KR",sans-serif;position:relative;'; const style = document.createElement('style'); style.textContent = ` #soop-ts-modal .soop-ts-heading { font-size: 16px; font-weight:700; margin-bottom:12px; } #soop-ts-modal .soop-ts-desc { color:#B7BAC7; margin-bottom:6px; } #soop-ts-modal .soop-ts-muted { color:#8C90A3; margin-bottom:10px; } #soop-ts-modal .row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } #soop-ts-modal .field { height:var(--field-h); background:#191B22; border:1px solid #2B2E3A; border-radius:10px; color:#E9E9EF; } #soop-ts-modal .btn-primary { height:40px; padding:0 14px; border-radius:10px; background:#2C66FF; color:#fff; border:0; } #soop-ts-modal .btn-secondary { height:40px; padding:0 14px; border-radius:10px; background:#2B2E3A; color:#E9E9EF; border:0; } #soop-ts-modal .ghost { background:#191B22; border:1px solid #2B2E3A; color:#E9E9EF; text-align:left; padding:10px 12px; cursor:pointer; } #soop-ts-modal .unit { display:inline-block; height:var(--field-h); line-height:var(--field-h); margin:0 !important; } `; modal.appendChild(style); modal.innerHTML += ` <div style="padding:18px 20px 14px; border-bottom:1px solid #242632; display:flex; align-items:center; gap:8px;"> <div style="font-size:18px; font-weight:700;">타임스탬프 설정창 (Ctrl+Shift+Y / Cmd+Shift+Y)</div> </div> <div style="padding:18px 20px;"> <div class="soop-ts-heading">단축키 설정</div> <div class="soop-ts-desc">아래 입력란을 클릭한 뒤 원하는 단축키를 누르세요. (예: Y, Ctrl+Y, Shift+F8)</div> <div class="soop-ts-muted">(브라우저/OS가 예약한 조합(Ctrl+W 등)은 동작하지 않을 수 있습니다.)</div> <div class="row" style="margin-bottom:10px;"> <button id="soop-ts-capture" type="button" class="field ghost" style="flex:1 1 360px; min-height:40px;"> <span id="soop-ts-capture-label">${hotkeyToLabel(currentHK)}</span> <span id="soop-ts-capture-hint" class="soop-ts-muted" style="margin-left:8px;">(클릭하여 변경)</span> </button> <button id="soop-ts-apply-hotkey" type="button" class="btn-primary">적용</button> <button id="soop-ts-reset-hotkey" type="button" class="btn-secondary">기본값</button> </div> <hr style="border:0; border-top:1px solid #242632; margin:18px 0;"> <div class="soop-ts-heading">라이브 타임 오프셋 설정</div> <div class="soop-ts-desc">라이브 방송시간 복사 시, 입력한 초만큼 <b>과거</b> 시간을 복사합니다. (라이브에서만 적용)</div> <div class="soop-ts-muted">(예: 60 → 01:00:00 ▶ 00:59:00)</div> <div class="row" style="margin-bottom:8px;"> <input id="soop-ts-offset" type="number" min="0" step="1" value="${pendingOffset}" class="field" style="width:160px; padding:0 10px;"> <span class="soop-ts-muted unit">초</span> </div> <div style="display:flex; justify-content:flex-end; gap:8px; margin-top:20px;"> <button id="soop-ts-confirm" type="button" class="btn-primary">확인</button> <button id="soop-ts-close" type="button" class="btn-secondary">닫기</button> </div> </div> `; overlay.appendChild(modal); document.body.appendChild(overlay); const elCaptureBtn = $('#soop-ts-capture', modal); const elCaptureLabel = $('#soop-ts-capture-label', modal); const elCaptureHint = $('#soop-ts-capture-hint', modal); const elApplyHotkey = $('#soop-ts-apply-hotkey', modal); const elResetHotkey = $('#soop-ts-reset-hotkey', modal); const elOffsetInput = $('#soop-ts-offset', modal); const elConfirm = $('#soop-ts-confirm', modal); const elClose = $('#soop-ts-close', modal); function setCapturing(on) { capturing = on; elCaptureBtn.style.borderColor = on ? '#2C66FF' : '#2B2E3A'; elCaptureHint.textContent = on ? '(원하는 키를 누르세요… Esc 취소)' : '(클릭하여 변경)'; if (on) elCaptureBtn.focus(); } elCaptureBtn.addEventListener('click', () => setCapturing(true)); function normalizeEventToHotkey(ev) { if (ev.code === 'Escape' && !ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) return null; const onlyMods = ['ControlLeft','ControlRight','ShiftLeft','ShiftRight','AltLeft','AltRight','MetaLeft','MetaRight']; if (onlyMods.includes(ev.code)) return undefined; return { code: ev.code, ctrl: !!ev.ctrlKey, alt: !!ev.altKey, shift: !!ev.shiftKey, meta: !!ev.metaKey }; } function onCaptureKeydown(ev) { if (!capturing) return; ev.preventDefault(); ev.stopPropagation(); const hk = normalizeEventToHotkey(ev); if (hk === null) { setCapturing(false); elCaptureLabel.textContent = hotkeyToLabel(pendingHotkey); return; } if (hk === undefined) return; pendingHotkey = hk; elCaptureLabel.textContent = hotkeyToLabel(pendingHotkey); } document.addEventListener('keydown', onCaptureKeydown, true); overlay.addEventListener('click', (ev) => { if (ev.target === overlay) closeSettings(); }); function toastInline(text) { const old = modal.querySelector('.soop-ts-toast'); if (old) old.remove(); const bar = document.createElement('div'); bar.className = 'soop-ts-toast'; bar.textContent = text; bar.style.cssText = [ 'position:absolute','left:50%','top:90%','transform:translate(-50%,-50%)', 'max-width:80%','background:#1A1D27','color:#E9E9EF','border:1px solid #2B2E3A', 'border-radius:8px','padding:10px 14px','text-align:center','pointer-events:none','opacity:.98' ].join(';'); modal.appendChild(bar); setTimeout(() => bar.remove(), 1200); } function isReservedHotkey(hk){ const ctrlShiftY = hk && hk.code==='KeyY' && hk.ctrl && hk.shift && !hk.alt && !hk.meta; const cmdShiftY = hk && hk.code==='KeyY' && hk.meta && hk.shift && !hk.alt && !hk.ctrl; return ctrlShiftY || cmdShiftY; } const DISALLOWED_CODES = new Set(['Enter','Escape','Tab']); function isValidHotkey(hk){ return !!hk && !DISALLOWED_CODES.has(hk.code); } elApplyHotkey.addEventListener('click', () => { if (!isValidHotkey(pendingHotkey)) return toastInline('이 키는 단축키로 사용할 수 없습니다.'); if (isReservedHotkey(pendingHotkey)) return toastInline('Ctrl/Cmd+Shift+Y는 설정창 단축키로 예약되어 사용할 수 없습니다.'); saveHotkey(pendingHotkey); setCapturing(false); toastInline('단축키가 적용되었습니다.'); }); elResetHotkey.addEventListener('click', () => { pendingHotkey = { ...DEFAULTS.hotkey }; elCaptureLabel.textContent = hotkeyToLabel(pendingHotkey); saveHotkey(pendingHotkey); toastInline('단축키가 기본값으로 초기화되었습니다.'); }); elConfirm.addEventListener('click', () => { saveOffset(elOffsetInput.value); if (isValidHotkey(pendingHotkey) && !isReservedHotkey(pendingHotkey)) saveHotkey(pendingHotkey); closeSettings(); }); elClose.addEventListener('click', closeSettings); const onOverlayKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeSettings(); } else if (e.key === 'Enter') { e.preventDefault(); elConfirm.click(); } }; overlay.addEventListener('keydown', onOverlayKey); function closeSettings() { settingsOpen = false; document.removeEventListener('keydown', onCaptureKeydown, true); overlay.removeEventListener('keydown', onOverlayKey); overlay.remove(); } } // ========= 메인 동작 ========= const VOD_DRAG_PX = 5; const VOD_LONG_MS = 600; // 라이브 if (location.hostname.startsWith('play.sooplive.co.kr')) { function getLiveTime() { const el = document.getElementById('time'); return el ? el.textContent.trim() : null; } const isHMS = (t) => /^\d{1,2}:\d{2}:\d{2}$/.test(String(t||'').trim()); // 라이브 툴팁: 최초 설정 + 호버 시 최신 단축키로 갱신 const setLiveTooltip = () => { const el = document.querySelector('li.time'); if (el) el.title = `현재 방송시간 복사 (${currentHotkeyLabel()})`; }; setLiveTooltip(); document.addEventListener('mouseover', (e) => { const el = e.target.closest('li.time'); if (el) el.title = `현재 방송시간 복사 (${currentHotkeyLabel()})`; }, true); // 토스트 let liveToastTid = null; function liveToast(message){ const toastContainer = document.querySelector('#toastMessage'); if (!toastContainer) { alert(message); return; } const p = toastContainer.querySelector('p') || toastContainer.appendChild(document.createElement('p')); p.textContent = message; toastContainer.style.display = ''; if (liveToastTid) clearTimeout(liveToastTid); liveToastTid = setTimeout(() => { p.textContent = ''; toastContainer.style.display = 'none'; }, TOAST_MS); } // 클릭 복사 document.addEventListener('click', async (e) => { const hit = e.target.closest('li.time'); if (!hit) return; const t = getLiveTime(); if (!isHMS(t)) return liveToast('시간 포맷을 읽지 못했습니다. 잠시 후 다시 시도해주세요.'); const baseSec = parseHMS(t); const adjustedSec = Math.max(0, baseSec - loadOffset()); await copyText(secondsToHMS(adjustedSec), liveToast); }, true); // 단축키 복사 document.addEventListener('keydown', async (e) => { if (e.repeat || e.isComposing || settingsOpen || isTyping(e)) return; const hk = loadHotkey(); if (!matchHotkey(e, hk)) return; e.preventDefault(); e.stopPropagation(); const t = getLiveTime(); if (!isHMS(t)) return liveToast('시간 포맷을 읽지 못했습니다. 잠시 후 다시 시도해주세요.'); const baseSec = parseHMS(t); const adjustedSec = Math.max(0, baseSec - loadOffset()); await copyText(secondsToHMS(adjustedSec), liveToast); }, true); } // 다시보기 if (location.hostname.startsWith('vod.sooplive.co.kr')) { function getVodTime() { const el = document.querySelector('.time_display .time-current'); return el ? el.textContent.trim() : null; } const isHMS = (t) => /^\d{1,2}:\d{2}:\d{2}$/.test(String(t||'').trim()); // VOD 툴팁: 최초 설정 + 호버 시 최신 단축키로 갱신 const setVodTooltip = () => { const box = document.querySelector('.time_display'); if (box) box.title = `현재 재생시간 복사 (${currentHotkeyLabel()})`; }; setVodTooltip(); document.addEventListener('mouseover', (e) => { const box = e.target.closest('.time_display'); if (box) box.title = `현재 재생시간 복사 (${currentHotkeyLabel()})`; }, true); // 드래그/롱프레스 보호 let vodDownInfo = null; document.addEventListener('mousedown', (e) => { const box = e.target.closest('.time_display'); if (!box) return; vodDownInfo = { x: e.clientX, y: e.clientY, t: Date.now() }; }, true); // 토스트 function vodToast(message){ const toastContainer = document.querySelector('#toastMessage'); if (!toastContainer) { alert(message); return; } const wrap = document.createElement('div'); const p = document.createElement('p'); p.textContent = message; wrap.appendChild(p); toastContainer.appendChild(wrap); setTimeout(() => { if (wrap.parentNode === toastContainer) toastContainer.removeChild(wrap); }, TOAST_MS); } // 클릭 복사 document.addEventListener('click', async (e) => { const box = e.target.closest('.time_display'); if (!box) return; const moved = vodDownInfo && (Math.hypot(e.clientX - vodDownInfo.x, e.clientY - vodDownInfo.y) > VOD_DRAG_PX); const long = vodDownInfo && ((Date.now() - vodDownInfo.t) > VOD_LONG_MS); vodDownInfo = null; if (moved || long) return; // 클릭 순간에도 최신 라벨로 보장 box.title = `현재 재생시간 복사 (${currentHotkeyLabel()})`; const t = getVodTime(); if (!isHMS(t)) return vodToast('시간 정보를 찾을 수 없습니다. 잠시 후 다시 시도해주세요.'); await copyText(t, vodToast); }, true); // 단축키 복사 document.addEventListener('keydown', async (e) => { if (e.repeat || e.isComposing || settingsOpen || isTyping(e)) return; const hk = loadHotkey(); if (!matchHotkey(e, hk)) return; e.preventDefault(); e.stopPropagation(); const t = getVodTime(); if (!isHMS(t)) return vodToast('시간 정보를 찾을 수 없습니다. 잠시 후 다시 시도해주세요.'); await copyText(t, vodToast); }, true); } })();